@push.rocks/smartproxy 3.37.2 → 3.38.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,25 @@ import { Buffer } from 'buffer';
2
2
 
3
3
  /**
4
4
  * SNI (Server Name Indication) handler for TLS connections.
5
- * Provides robust extraction of SNI values from TLS ClientHello messages.
5
+ * Provides robust extraction of SNI values from TLS ClientHello messages
6
+ * with support for fragmented packets, TLS 1.3 resumption, and Chrome-specific
7
+ * connection behaviors.
6
8
  */
7
9
  export class SniHandler {
8
10
  // TLS record types and constants
9
11
  private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22;
12
+ private static readonly TLS_APPLICATION_DATA_TYPE = 23; // TLS Application Data record type
10
13
  private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1;
11
14
  private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000;
12
15
  private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023;
13
16
  private static readonly TLS_SNI_HOST_NAME_TYPE = 0;
17
+ private static readonly TLS_PSK_EXTENSION_TYPE = 0x0029; // Pre-Shared Key extension type for TLS 1.3
18
+ private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002D; // PSK Key Exchange Modes
19
+ private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = 0x002A; // Early Data (0-RTT) extension
20
+
21
+ // Buffer for handling fragmented ClientHello messages
22
+ private static fragmentedBuffers: Map<string, Buffer> = new Map();
23
+ private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup
14
24
 
15
25
  /**
16
26
  * Checks if a buffer contains a TLS handshake message (record type 22)
@@ -20,6 +30,107 @@ export class SniHandler {
20
30
  public static isTlsHandshake(buffer: Buffer): boolean {
21
31
  return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE;
22
32
  }
33
+
34
+ /**
35
+ * Checks if a buffer contains TLS application data (record type 23)
36
+ * @param buffer - The buffer to check
37
+ * @returns true if the buffer starts with a TLS application data record type
38
+ */
39
+ public static isTlsApplicationData(buffer: Buffer): boolean {
40
+ return buffer.length > 0 && buffer[0] === this.TLS_APPLICATION_DATA_TYPE;
41
+ }
42
+
43
+ /**
44
+ * Creates a connection ID based on source/destination information
45
+ * Used to track fragmented ClientHello messages across multiple packets
46
+ *
47
+ * @param connectionInfo - Object containing connection identifiers (IP/port)
48
+ * @returns A string ID for the connection
49
+ */
50
+ public static createConnectionId(connectionInfo: {
51
+ sourceIp?: string;
52
+ sourcePort?: number;
53
+ destIp?: string;
54
+ destPort?: number;
55
+ }): string {
56
+ const { sourceIp, sourcePort, destIp, destPort } = connectionInfo;
57
+ return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`;
58
+ }
59
+
60
+ /**
61
+ * Handles potential fragmented ClientHello messages by buffering and reassembling
62
+ * TLS record fragments that might span multiple TCP packets.
63
+ *
64
+ * @param buffer - The current buffer fragment
65
+ * @param connectionId - Unique identifier for the connection
66
+ * @param enableLogging - Whether to enable logging
67
+ * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed
68
+ */
69
+ public static handleFragmentedClientHello(
70
+ buffer: Buffer,
71
+ connectionId: string,
72
+ enableLogging: boolean = false
73
+ ): Buffer | undefined {
74
+ const log = (message: string) => {
75
+ if (enableLogging) {
76
+ console.log(`[SNI Fragment] ${message}`);
77
+ }
78
+ };
79
+
80
+ // Check if we've seen this connection before
81
+ if (!this.fragmentedBuffers.has(connectionId)) {
82
+ // New connection, start with this buffer
83
+ this.fragmentedBuffers.set(connectionId, buffer);
84
+
85
+ // Set timeout to clean up if we don't get a complete ClientHello
86
+ setTimeout(() => {
87
+ if (this.fragmentedBuffers.has(connectionId)) {
88
+ this.fragmentedBuffers.delete(connectionId);
89
+ log(`Connection ${connectionId} timed out waiting for complete ClientHello`);
90
+ }
91
+ }, this.fragmentTimeout);
92
+
93
+ // Evaluate if this buffer already contains a complete ClientHello
94
+ try {
95
+ if (buffer.length >= 5) {
96
+ const recordLength = (buffer[3] << 8) + buffer[4];
97
+ if (buffer.length >= recordLength + 5) {
98
+ log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`);
99
+ return buffer;
100
+ }
101
+ }
102
+ } catch (e) {
103
+ log(`Error checking initial buffer completeness: ${e}`);
104
+ }
105
+
106
+ log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`);
107
+ return undefined; // Need more fragments
108
+ } else {
109
+ // Existing connection, append this buffer
110
+ const existingBuffer = this.fragmentedBuffers.get(connectionId)!;
111
+ const newBuffer = Buffer.concat([existingBuffer, buffer]);
112
+ this.fragmentedBuffers.set(connectionId, newBuffer);
113
+
114
+ log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`);
115
+
116
+ // Check if we now have a complete ClientHello
117
+ try {
118
+ if (newBuffer.length >= 5) {
119
+ const recordLength = (newBuffer[3] << 8) + newBuffer[4];
120
+ if (newBuffer.length >= recordLength + 5) {
121
+ log(`Assembled complete ClientHello, length: ${newBuffer.length}`);
122
+ // Complete message received, remove from tracking
123
+ this.fragmentedBuffers.delete(connectionId);
124
+ return newBuffer;
125
+ }
126
+ }
127
+ } catch (e) {
128
+ log(`Error checking reassembled buffer completeness: ${e}`);
129
+ }
130
+
131
+ return undefined; // Still need more fragments
132
+ }
133
+ }
23
134
 
24
135
  /**
25
136
  * Checks if a buffer contains a TLS ClientHello message
@@ -178,6 +289,7 @@ export class SniHandler {
178
289
 
179
290
  // Track if we found session tickets (for improved resumption handling)
180
291
  let hasSessionTicket = false;
292
+ let hasPskExtension = false;
181
293
 
182
294
  // Iterate through extensions
183
295
  while (pos + 4 <= extensionsEnd) {
@@ -275,15 +387,21 @@ export class SniHandler {
275
387
  log('Found session ticket extension');
276
388
  hasSessionTicket = true;
277
389
  pos += extensionLength; // Skip this extension
390
+ } else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
391
+ // TLS 1.3 PSK extension - mark for resumption support
392
+ log('Found PSK extension (TLS 1.3 resumption indicator)');
393
+ hasPskExtension = true;
394
+ // We'll skip the extension here and process it separately if needed
395
+ pos += extensionLength;
278
396
  } else {
279
397
  // Skip this extension
280
398
  pos += extensionLength;
281
399
  }
282
400
  }
283
401
 
284
- // Log if we found a session ticket but no SNI
285
- if (hasSessionTicket) {
286
- log('Session ticket present but no SNI found - possible resumption scenario');
402
+ // Log if we found session resumption indicators but no SNI
403
+ if (hasSessionTicket || hasPskExtension) {
404
+ log('Session resumption indicators present but no SNI found');
287
405
  }
288
406
 
289
407
  log('No SNI extension found in ClientHello');
@@ -294,36 +412,423 @@ export class SniHandler {
294
412
  }
295
413
  }
296
414
 
415
+ /**
416
+ * Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello.
417
+ *
418
+ * In TLS 1.3, when a client attempts to resume a session, it may include
419
+ * the server name in the PSK identity hint rather than in the SNI extension.
420
+ *
421
+ * @param buffer - The buffer containing the TLS ClientHello message
422
+ * @param enableLogging - Whether to enable detailed debug logging
423
+ * @returns The extracted server name or undefined if not found
424
+ */
425
+ public static extractSNIFromPSKExtension(
426
+ buffer: Buffer,
427
+ enableLogging: boolean = false
428
+ ): string | undefined {
429
+ const log = (message: string) => {
430
+ if (enableLogging) {
431
+ console.log(`[PSK-SNI Extraction] ${message}`);
432
+ }
433
+ };
434
+
435
+ try {
436
+ // Ensure this is a ClientHello
437
+ if (!this.isClientHello(buffer)) {
438
+ log('Not a ClientHello message');
439
+ return undefined;
440
+ }
441
+
442
+ // Find the start position of extensions
443
+ let pos = 5; // Start after record header
444
+
445
+ // Skip handshake type (1 byte)
446
+ pos += 1;
447
+
448
+ // Skip handshake length (3 bytes)
449
+ pos += 3;
450
+
451
+ // Skip client version (2 bytes)
452
+ pos += 2;
453
+
454
+ // Skip client random (32 bytes)
455
+ pos += 32;
456
+
457
+ // Skip session ID
458
+ if (pos + 1 > buffer.length) return undefined;
459
+ const sessionIdLength = buffer[pos];
460
+ pos += 1 + sessionIdLength;
461
+
462
+ // Skip cipher suites
463
+ if (pos + 2 > buffer.length) return undefined;
464
+ const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
465
+ pos += 2 + cipherSuitesLength;
466
+
467
+ // Skip compression methods
468
+ if (pos + 1 > buffer.length) return undefined;
469
+ const compressionMethodsLength = buffer[pos];
470
+ pos += 1 + compressionMethodsLength;
471
+
472
+ // Check if we have extensions
473
+ if (pos + 2 > buffer.length) {
474
+ log('No extensions present');
475
+ return undefined;
476
+ }
477
+
478
+ // Get extensions length
479
+ const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
480
+ pos += 2;
481
+
482
+ // Extensions end position
483
+ const extensionsEnd = pos + extensionsLength;
484
+ if (extensionsEnd > buffer.length) return undefined;
485
+
486
+ // Look for PSK extension
487
+ while (pos + 4 <= extensionsEnd) {
488
+ const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
489
+ pos += 2;
490
+
491
+ const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
492
+ pos += 2;
493
+
494
+ if (extensionType === this.TLS_PSK_EXTENSION_TYPE) {
495
+ log('Found PSK extension');
496
+
497
+ // PSK extension structure:
498
+ // 2 bytes: identities list length
499
+ if (pos + 2 > extensionsEnd) break;
500
+ const identitiesLength = (buffer[pos] << 8) + buffer[pos + 1];
501
+ pos += 2;
502
+
503
+ // End of identities list
504
+ const identitiesEnd = pos + identitiesLength;
505
+ if (identitiesEnd > extensionsEnd) break;
506
+
507
+ // Process each PSK identity
508
+ while (pos + 2 <= identitiesEnd) {
509
+ // Identity length (2 bytes)
510
+ if (pos + 2 > identitiesEnd) break;
511
+ const identityLength = (buffer[pos] << 8) + buffer[pos + 1];
512
+ pos += 2;
513
+
514
+ if (pos + identityLength > identitiesEnd) break;
515
+
516
+ // Try to extract hostname from identity
517
+ // Chrome often embeds the hostname in the PSK identity
518
+ // This is a heuristic as there's no standard format
519
+ if (identityLength > 0) {
520
+ const identity = buffer.slice(pos, pos + identityLength);
521
+
522
+ // Skip identity bytes
523
+ pos += identityLength;
524
+
525
+ // Skip obfuscated ticket age (4 bytes)
526
+ pos += 4;
527
+
528
+ // Try to parse the identity as UTF-8
529
+ try {
530
+ const identityStr = identity.toString('utf8');
531
+ log(`PSK identity: ${identityStr}`);
532
+
533
+ // Check if the identity contains hostname hints
534
+ // Chrome often embeds the hostname in a known format
535
+ // Try to extract using common patterns
536
+
537
+ // Pattern 1: Look for domain name pattern
538
+ const domainPattern = /([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?/i;
539
+ const domainMatch = identityStr.match(domainPattern);
540
+ if (domainMatch && domainMatch[0]) {
541
+ log(`Found domain in PSK identity: ${domainMatch[0]}`);
542
+ return domainMatch[0];
543
+ }
544
+
545
+ // Pattern 2: Chrome sometimes uses a specific format with delimiters
546
+ // This is a heuristic approach since the format isn't standardized
547
+ const parts = identityStr.split('|');
548
+ if (parts.length > 1) {
549
+ for (const part of parts) {
550
+ if (part.includes('.') && !part.includes('/')) {
551
+ const possibleDomain = part.trim();
552
+ if (/^[a-z0-9.-]+$/i.test(possibleDomain)) {
553
+ log(`Found possible domain in PSK delimiter format: ${possibleDomain}`);
554
+ return possibleDomain;
555
+ }
556
+ }
557
+ }
558
+ }
559
+ } catch (e) {
560
+ log('Failed to parse PSK identity as UTF-8');
561
+ }
562
+ }
563
+ }
564
+ } else {
565
+ // Skip this extension
566
+ pos += extensionLength;
567
+ }
568
+ }
569
+
570
+ log('No hostname found in PSK extension');
571
+ return undefined;
572
+ } catch (error) {
573
+ log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`);
574
+ return undefined;
575
+ }
576
+ }
577
+
578
+ /**
579
+ * Checks if the buffer contains TLS 1.3 early data (0-RTT)
580
+ * @param buffer - The buffer to check
581
+ * @param enableLogging - Whether to enable logging
582
+ * @returns true if early data is detected
583
+ */
584
+ public static hasEarlyData(
585
+ buffer: Buffer,
586
+ enableLogging: boolean = false
587
+ ): boolean {
588
+ const log = (message: string) => {
589
+ if (enableLogging) {
590
+ console.log(`[Early Data] ${message}`);
591
+ }
592
+ };
593
+
594
+ try {
595
+ // Check if this is a valid ClientHello first
596
+ if (!this.isClientHello(buffer)) {
597
+ return false;
598
+ }
599
+
600
+ // Find the extensions section
601
+ let pos = 5; // Start after record header
602
+
603
+ // Skip handshake type (1 byte)
604
+ pos += 1;
605
+
606
+ // Skip handshake length (3 bytes)
607
+ pos += 3;
608
+
609
+ // Skip client version (2 bytes)
610
+ pos += 2;
611
+
612
+ // Skip client random (32 bytes)
613
+ pos += 32;
614
+
615
+ // Skip session ID
616
+ if (pos + 1 > buffer.length) return false;
617
+ const sessionIdLength = buffer[pos];
618
+ pos += 1 + sessionIdLength;
619
+
620
+ // Skip cipher suites
621
+ if (pos + 2 > buffer.length) return false;
622
+ const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1];
623
+ pos += 2 + cipherSuitesLength;
624
+
625
+ // Skip compression methods
626
+ if (pos + 1 > buffer.length) return false;
627
+ const compressionMethodsLength = buffer[pos];
628
+ pos += 1 + compressionMethodsLength;
629
+
630
+ // Check if we have extensions
631
+ if (pos + 2 > buffer.length) return false;
632
+
633
+ // Get extensions length
634
+ const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1];
635
+ pos += 2;
636
+
637
+ // Extensions end position
638
+ const extensionsEnd = pos + extensionsLength;
639
+ if (extensionsEnd > buffer.length) return false;
640
+
641
+ // Look for early data extension
642
+ while (pos + 4 <= extensionsEnd) {
643
+ const extensionType = (buffer[pos] << 8) + buffer[pos + 1];
644
+ pos += 2;
645
+
646
+ const extensionLength = (buffer[pos] << 8) + buffer[pos + 1];
647
+ pos += 2;
648
+
649
+ if (extensionType === this.TLS_EARLY_DATA_EXTENSION_TYPE) {
650
+ log('Early Data (0-RTT) extension detected');
651
+ return true;
652
+ }
653
+
654
+ // Skip to next extension
655
+ pos += extensionLength;
656
+ }
657
+
658
+ return false;
659
+ } catch (error) {
660
+ log(`Error checking for early data: ${error}`);
661
+ return false;
662
+ }
663
+ }
664
+
297
665
  /**
298
666
  * Attempts to extract SNI from an initial ClientHello packet and handles
299
667
  * session resumption edge cases more robustly than the standard extraction.
300
668
  *
301
- * This method is specifically designed for Chrome and other browsers that
302
- * may send different ClientHello formats during session resumption.
669
+ * This method handles:
670
+ * 1. Standard SNI extraction
671
+ * 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.)
672
+ * 3. Session ticket-based resumption
673
+ * 4. Fragmented ClientHello messages
674
+ * 5. TLS 1.3 Early Data (0-RTT)
675
+ * 6. Chrome's connection racing behaviors
303
676
  *
304
677
  * @param buffer - The buffer containing the TLS ClientHello message
678
+ * @param connectionInfo - Optional connection information for fragment handling
305
679
  * @param enableLogging - Whether to enable detailed debug logging
306
680
  * @returns The extracted server name or undefined if not found
307
681
  */
308
682
  public static extractSNIWithResumptionSupport(
309
- buffer: Buffer,
683
+ buffer: Buffer,
684
+ connectionInfo?: {
685
+ sourceIp?: string;
686
+ sourcePort?: number;
687
+ destIp?: string;
688
+ destPort?: number;
689
+ },
310
690
  enableLogging: boolean = false
311
691
  ): string | undefined {
692
+ const log = (message: string) => {
693
+ if (enableLogging) {
694
+ console.log(`[SNI Extraction] ${message}`);
695
+ }
696
+ };
697
+
698
+ // Check if we need to handle fragmented packets
699
+ let processBuffer = buffer;
700
+ if (connectionInfo) {
701
+ const connectionId = this.createConnectionId(connectionInfo);
702
+ const reassembledBuffer = this.handleFragmentedClientHello(
703
+ buffer,
704
+ connectionId,
705
+ enableLogging
706
+ );
707
+
708
+ if (!reassembledBuffer) {
709
+ log(`Waiting for more fragments on connection ${connectionId}`);
710
+ return undefined; // Need more fragments to complete ClientHello
711
+ }
712
+
713
+ processBuffer = reassembledBuffer;
714
+ log(`Using reassembled buffer of length ${processBuffer.length}`);
715
+ }
716
+
312
717
  // First try the standard SNI extraction
313
- const standardSni = this.extractSNI(buffer, enableLogging);
718
+ const standardSni = this.extractSNI(processBuffer, enableLogging);
314
719
  if (standardSni) {
720
+ log(`Found standard SNI: ${standardSni}`);
315
721
  return standardSni;
316
722
  }
317
723
 
724
+ // Check for TLS 1.3 early data (0-RTT)
725
+ const hasEarly = this.hasEarlyData(processBuffer, enableLogging);
726
+ if (hasEarly) {
727
+ log('TLS 1.3 Early Data detected, using special handling');
728
+ // In 0-RTT, Chrome often relies on server remembering the SNI from previous sessions
729
+ // We could implement session tracking here if necessary
730
+ }
731
+
318
732
  // If standard extraction failed and we have a valid ClientHello,
319
733
  // this might be a session resumption with non-standard format
320
- if (this.isClientHello(buffer)) {
321
- if (enableLogging) {
322
- console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption');
734
+ if (this.isClientHello(processBuffer)) {
735
+ log('Detected ClientHello without standard SNI, possible session resumption');
736
+
737
+ // Try to extract from PSK extension (TLS 1.3 resumption)
738
+ const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging);
739
+ if (pskSni) {
740
+ log(`Extracted SNI from PSK extension: ${pskSni}`);
741
+ return pskSni;
323
742
  }
324
743
 
325
- // Additional handling could be implemented here for specific browser behaviors
326
- // For now, this is a placeholder for future improvements
744
+ // Special handling for Chrome connection racing
745
+ // Chrome often opens multiple connections in parallel with different
746
+ // characteristics to improve performance
747
+ // Here we would look for specific patterns in ClientHello that indicate
748
+ // it's part of a connection race
749
+
750
+ // Detect if this is likely a secondary connection in a race
751
+ // by examining the cipher suites and extensions
752
+ // This would require session state tracking across connections
753
+
754
+ log('Failed to extract SNI from resumption mechanisms');
755
+ }
756
+
757
+ return undefined;
758
+ }
759
+
760
+ /**
761
+ * Main entry point for SNI extraction that handles all edge cases.
762
+ * This should be called for each TLS packet received from a client.
763
+ *
764
+ * The method uses connection tracking to handle fragmented ClientHello
765
+ * messages and various TLS 1.3 behaviors, including Chrome's connection
766
+ * racing patterns.
767
+ *
768
+ * @param buffer - The buffer containing TLS data
769
+ * @param connectionInfo - Connection metadata (IPs and ports)
770
+ * @param enableLogging - Whether to enable detailed debug logging
771
+ * @param cachedSni - Optional cached SNI from previous connections (for racing detection)
772
+ * @returns The extracted server name or undefined if not found or more data needed
773
+ */
774
+ public static processTlsPacket(
775
+ buffer: Buffer,
776
+ connectionInfo: {
777
+ sourceIp: string;
778
+ sourcePort: number;
779
+ destIp: string;
780
+ destPort: number;
781
+ timestamp?: number;
782
+ },
783
+ enableLogging: boolean = false,
784
+ cachedSni?: string
785
+ ): string | undefined {
786
+ const log = (message: string) => {
787
+ if (enableLogging) {
788
+ console.log(`[TLS Packet] ${message}`);
789
+ }
790
+ };
791
+
792
+ // Add timestamp if not provided
793
+ if (!connectionInfo.timestamp) {
794
+ connectionInfo.timestamp = Date.now();
795
+ }
796
+
797
+ // Check if this is a TLS handshake
798
+ if (!this.isTlsHandshake(buffer) && !this.isTlsApplicationData(buffer)) {
799
+ log('Not a TLS handshake or application data packet');
800
+ return undefined;
801
+ }
802
+
803
+ // Create connection ID for tracking
804
+ const connectionId = this.createConnectionId(connectionInfo);
805
+ log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`);
806
+
807
+ // Handle special case: if we already have a cached SNI from a previous
808
+ // connection from the same client IP within a short time window,
809
+ // this might be a connection racing situation
810
+ if (cachedSni && this.isTlsApplicationData(buffer)) {
811
+ log(`Using cached SNI from connection racing: ${cachedSni}`);
812
+ return cachedSni;
813
+ }
814
+
815
+ // Try to extract SNI with full resumption support and fragment handling
816
+ const sni = this.extractSNIWithResumptionSupport(
817
+ buffer,
818
+ connectionInfo,
819
+ enableLogging
820
+ );
821
+
822
+ if (sni) {
823
+ log(`Successfully extracted SNI: ${sni}`);
824
+ return sni;
825
+ }
826
+
827
+ // If we couldn't extract an SNI, check if this is a valid ClientHello
828
+ // If it is, but we couldn't get an SNI, it might be a fragment or
829
+ // a connection race situation
830
+ if (this.isClientHello(buffer)) {
831
+ log('Valid ClientHello detected, but no SNI extracted - might need more data');
327
832
  }
328
833
 
329
834
  return undefined;