diff --git a/changelog.md b/changelog.md index 84bf54b..5a0776a 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-12 - 3.41.6 - fix(SniHandler) +Refactor SniHandler: update whitespace, comment formatting, and consistent type definitions + +- Unified inline comment style and spacing in SniHandler +- Refactored session cache type declaration for clarity +- Adjusted buffer length calculations to include TLS record header consistently +- Minor improvements to logging messages during ClientHello reassembly and SNI extraction + ## 2025-03-12 - 3.41.5 - fix(portproxy) Enforce TLS handshake and SNI validation on port 443 by blocking non-TLS connections and terminating session resumption attempts without SNI when allowSessionTicket is disabled. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 9cf916c..3b053ea 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '3.41.5', + version: '3.41.6', description: 'A powerful proxy package that effectively handles high traffic, with features such as SSL/TLS support, port proxying, WebSocket handling, dynamic routing with authentication options, and automatic ACME certificate management.' } diff --git a/ts/classes.snihandler.ts b/ts/classes.snihandler.ts index fc8ed71..0b5c494 100644 --- a/ts/classes.snihandler.ts +++ b/ts/classes.snihandler.ts @@ -9,29 +9,32 @@ import { Buffer } from 'buffer'; export class SniHandler { // TLS record types and constants private static readonly TLS_HANDSHAKE_RECORD_TYPE = 22; - private static readonly TLS_APPLICATION_DATA_TYPE = 23; // TLS Application Data record type + private static readonly TLS_APPLICATION_DATA_TYPE = 23; // TLS Application Data record type private static readonly TLS_CLIENT_HELLO_HANDSHAKE_TYPE = 1; private static readonly TLS_SNI_EXTENSION_TYPE = 0x0000; private static readonly TLS_SESSION_TICKET_EXTENSION_TYPE = 0x0023; private static readonly TLS_SNI_HOST_NAME_TYPE = 0; private static readonly TLS_PSK_EXTENSION_TYPE = 0x0029; // Pre-Shared Key extension type for TLS 1.3 - private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002D; // PSK Key Exchange Modes - private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = 0x002A; // Early Data (0-RTT) extension - + private static readonly TLS_PSK_KE_MODES_EXTENSION_TYPE = 0x002d; // PSK Key Exchange Modes + private static readonly TLS_EARLY_DATA_EXTENSION_TYPE = 0x002a; // Early Data (0-RTT) extension + // Buffer for handling fragmented ClientHello messages private static fragmentedBuffers: Map = new Map(); private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup // Session tracking for tab reactivation scenarios - private static sessionCache: Map = new Map(); - + private static sessionCache: Map< + string, + { + sni: string; + timestamp: number; + clientRandom?: Buffer; + } + > = new Map(); + // Longer timeout for session cache (24 hours by default) private static sessionCacheTimeout: number = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - + // Cleanup interval for session cache (run every hour) private static sessionCleanupInterval: NodeJS.Timeout | null = null; @@ -53,14 +56,14 @@ export class SniHandler { private static cleanupSessionCache(): void { const now = Date.now(); const expiredKeys: string[] = []; - + this.sessionCache.forEach((session, key) => { if (now - session.timestamp > this.sessionCacheTimeout) { expiredKeys.push(key); } }); - - expiredKeys.forEach(key => { + + expiredKeys.forEach((key) => { this.sessionCache.delete(key); }); } @@ -68,7 +71,7 @@ export class SniHandler { /** * Create a client identity key for session tracking * Uses source IP and optional client random for uniqueness - * + * * @param sourceIp - Client IP address * @param clientRandom - Optional TLS client random value * @returns A string key for the session cache @@ -84,7 +87,7 @@ export class SniHandler { /** * Store SNI information in the session cache - * + * * @param sourceIp - Client IP address * @param sni - The extracted SNI value * @param clientRandom - Optional TLS client random value @@ -94,13 +97,13 @@ export class SniHandler { this.sessionCache.set(key, { sni, timestamp: Date.now(), - clientRandom + clientRandom, }); } /** * Retrieve SNI information from the session cache - * + * * @param sourceIp - Client IP address * @param clientRandom - Optional TLS client random value * @returns The cached SNI or undefined if not found @@ -114,7 +117,7 @@ export class SniHandler { return preciseSession.sni; } } - + // Fall back to IP-only lookup const ipKey = this.createClientKey(sourceIp); const session = this.sessionCache.get(ipKey); @@ -123,13 +126,13 @@ export class SniHandler { session.timestamp = Date.now(); return session.sni; } - + return undefined; } /** * Extract the client random value from a ClientHello message - * + * * @param buffer - The buffer containing the ClientHello * @returns The 32-byte client random or undefined if extraction fails */ @@ -138,9 +141,9 @@ export class SniHandler { if (!this.isClientHello(buffer) || buffer.length < 46) { return undefined; } - + // In a ClientHello message, the client random starts at position 11 - // after record header (5 bytes), handshake type (1 byte), + // after record header (5 bytes), handshake type (1 byte), // handshake length (3 bytes), and client version (2 bytes) return buffer.slice(11, 11 + 32); } catch (error) { @@ -156,7 +159,7 @@ export class SniHandler { public static isTlsHandshake(buffer: Buffer): boolean { return buffer.length > 0 && buffer[0] === this.TLS_HANDSHAKE_RECORD_TYPE; } - + /** * Checks if a buffer contains TLS application data (record type 23) * @param buffer - The buffer to check @@ -165,28 +168,28 @@ export class SniHandler { public static isTlsApplicationData(buffer: Buffer): boolean { return buffer.length > 0 && buffer[0] === this.TLS_APPLICATION_DATA_TYPE; } - + /** * Creates a connection ID based on source/destination information * Used to track fragmented ClientHello messages across multiple packets - * + * * @param connectionInfo - Object containing connection identifiers (IP/port) * @returns A string ID for the connection */ - public static createConnectionId(connectionInfo: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; + public static createConnectionId(connectionInfo: { + sourceIp?: string; + sourcePort?: number; + destIp?: string; + destPort?: number; }): string { const { sourceIp, sourcePort, destIp, destPort } = connectionInfo; return `${sourceIp}:${sourcePort}-${destIp}:${destPort}`; } - + /** * Handles potential fragmented ClientHello messages by buffering and reassembling * TLS record fragments that might span multiple TCP packets. - * + * * @param buffer - The current buffer fragment * @param connectionId - Unique identifier for the connection * @param enableLogging - Whether to enable logging @@ -202,12 +205,12 @@ export class SniHandler { console.log(`[SNI Fragment] ${message}`); } }; - + // Check if we've seen this connection before if (!this.fragmentedBuffers.has(connectionId)) { // New connection, start with this buffer this.fragmentedBuffers.set(connectionId, buffer); - + // Set timeout to clean up if we don't get a complete ClientHello setTimeout(() => { if (this.fragmentedBuffers.has(connectionId)) { @@ -215,20 +218,28 @@ export class SniHandler { log(`Connection ${connectionId} timed out waiting for complete ClientHello`); } }, this.fragmentTimeout); - + // Evaluate if this buffer already contains a complete ClientHello try { if (buffer.length >= 5) { - const recordLength = (buffer[3] << 8) + buffer[4]; - if (buffer.length >= recordLength + 5) { + // Get the record length from TLS header + const recordLength = (buffer[3] << 8) + buffer[4] + 5; // +5 for the TLS record header itself + log(`Initial buffer size: ${buffer.length}, expected record length: ${recordLength}`); + + // Check if this buffer already contains a complete TLS record + if (buffer.length >= recordLength) { log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`); return buffer; } + } else { + log( + `Initial buffer too small (${buffer.length} bytes), needs at least 5 bytes for TLS header` + ); } } catch (e) { log(`Error checking initial buffer completeness: ${e}`); } - + log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`); return undefined; // Need more fragments } else { @@ -236,24 +247,69 @@ export class SniHandler { const existingBuffer = this.fragmentedBuffers.get(connectionId)!; const newBuffer = Buffer.concat([existingBuffer, buffer]); this.fragmentedBuffers.set(connectionId, newBuffer); - + log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`); - + // Check if we now have a complete ClientHello try { if (newBuffer.length >= 5) { - const recordLength = (newBuffer[3] << 8) + newBuffer[4]; - if (newBuffer.length >= recordLength + 5) { - log(`Assembled complete ClientHello, length: ${newBuffer.length}`); - // Complete message received, remove from tracking - this.fragmentedBuffers.delete(connectionId); - return newBuffer; + // Get the record length from TLS header + const recordLength = (newBuffer[3] << 8) + newBuffer[4] + 5; // +5 for the TLS record header itself + log( + `Reassembled buffer size: ${newBuffer.length}, expected record length: ${recordLength}` + ); + + // Check if we have a complete TLS record now + if (newBuffer.length >= recordLength) { + log( + `Assembled complete ClientHello, length: ${newBuffer.length}, needed: ${recordLength}` + ); + + // Extract the complete TLS record (might be followed by more data) + const completeRecord = newBuffer.slice(0, recordLength); + + // Check if this record is indeed a ClientHello (type 1) at position 5 + if ( + completeRecord.length > 5 && + completeRecord[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE + ) { + log(`Verified record is a ClientHello handshake message`); + + // Complete message received, remove from tracking + this.fragmentedBuffers.delete(connectionId); + return completeRecord; + } else { + log(`Record is complete but not a ClientHello handshake, continuing to buffer`); + // This might be another TLS record type preceding the ClientHello + + // Try checking for a ClientHello starting at the end of this record + if (newBuffer.length > recordLength + 5) { + const nextRecordType = newBuffer[recordLength]; + log( + `Next record type: ${nextRecordType} (looking for ${this.TLS_HANDSHAKE_RECORD_TYPE})` + ); + + if (nextRecordType === this.TLS_HANDSHAKE_RECORD_TYPE) { + const handshakeType = newBuffer[recordLength + 5]; + log( + `Next handshake type: ${handshakeType} (looking for ${this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE})` + ); + + if (handshakeType === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE) { + // Found a ClientHello in the next record, return the entire buffer + log(`Found ClientHello in subsequent record, returning full buffer`); + this.fragmentedBuffers.delete(connectionId); + return newBuffer; + } + } + } + } } } } catch (e) { log(`Error checking reassembled buffer completeness: ${e}`); } - + return undefined; // Still need more fragments } } @@ -282,7 +338,7 @@ export class SniHandler { /** * Checks if a ClientHello message contains session resumption indicators * such as session tickets or PSK (Pre-Shared Key) extensions. - * + * * @param buffer - The buffer containing a ClientHello message * @param enableLogging - Whether to enable logging * @returns Object containing details about session resumption and SNI presence @@ -296,66 +352,66 @@ export class SniHandler { console.log(`[Session Resumption] ${message}`); } }; - + if (!this.isClientHello(buffer)) { return { isResumption: false, hasSNI: false }; } - + try { // Check for session ID presence first let pos = 5 + 1 + 3 + 2; // Position after handshake type, length and client version pos += 32; // Skip client random - + if (pos + 1 > buffer.length) return { isResumption: false, hasSNI: false }; - + const sessionIdLength = buffer[pos]; let hasNonEmptySessionId = sessionIdLength > 0; - + if (hasNonEmptySessionId) { log(`Detected non-empty session ID (length: ${sessionIdLength})`); } - + // Continue to check for extensions pos += 1 + sessionIdLength; - + // Skip cipher suites if (pos + 2 > buffer.length) return { isResumption: false, hasSNI: false }; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; - + // Skip compression methods if (pos + 1 > buffer.length) return { isResumption: false, hasSNI: false }; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; - + // Check for extensions if (pos + 2 > buffer.length) return { isResumption: false, hasSNI: false }; - + // Look for session resumption extensions const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return { isResumption: false, hasSNI: false }; - + // Track resumption indicators let hasSessionTicket = false; let hasPSK = false; let hasEarlyData = false; - + // Iterate through extensions while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) { log('Found session ticket extension'); hasSessionTicket = true; - + // Check if session ticket has non-zero length (active ticket) if (extensionLength > 0) { log(`Session ticket has length ${extensionLength} - active ticket present`); @@ -367,37 +423,37 @@ export class SniHandler { log('Found Early Data extension (TLS 1.3 0-RTT)'); hasEarlyData = true; } - + // Skip extension data pos += extensionLength; } - + // Check if SNI is included let hasSNI = false; - + // Reset position and scan again for SNI extension pos = 5 + 1 + 3 + 2; // Reset to after handshake type, length and client version pos += 32; // Skip client random - + if (pos + 1 <= buffer.length) { const sessionIdLength = buffer[pos]; pos += 1 + sessionIdLength; - + // Skip cipher suites if (pos + 2 <= buffer.length) { const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; - + // Skip compression methods if (pos + 1 <= buffer.length) { const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; - + // Check for extensions if (pos + 2 <= buffer.length) { const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd <= buffer.length) { @@ -405,22 +461,22 @@ export class SniHandler { while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (extensionType === this.TLS_SNI_EXTENSION_TYPE) { // Check that the SNI extension actually has content if (extensionLength > 0) { hasSNI = true; - + // Try to extract the actual SNI value for logging try { // Skip to server_name_list_length (2 bytes) const tempPos = pos; if (tempPos + 2 <= extensionsEnd) { const nameListLength = (buffer[tempPos] << 8) + buffer[tempPos + 1]; - + // Skip server_name_list_length (2 bytes) if (tempPos + 2 + 1 <= extensionsEnd) { // Check name_type (should be 0 for hostname) @@ -429,10 +485,12 @@ export class SniHandler { if (tempPos + 3 + 2 <= extensionsEnd) { // Get name_length (2 bytes) const nameLength = (buffer[tempPos + 3] << 8) + buffer[tempPos + 4]; - + // Extract the hostname if (tempPos + 5 + nameLength <= extensionsEnd) { - const hostname = buffer.slice(tempPos + 5, tempPos + 5 + nameLength).toString('utf8'); + const hostname = buffer + .slice(tempPos + 5, tempPos + 5 + nameLength) + .toString('utf8'); log(`Found SNI extension with server_name: ${hostname}`); } } @@ -448,7 +506,7 @@ export class SniHandler { } break; } - + // Skip extension data pos += extensionLength; } @@ -457,50 +515,54 @@ export class SniHandler { } } } - + // Consider it a resumption if any resumption mechanism is present - const isResumption = hasSessionTicket || hasPSK || hasEarlyData || - (hasNonEmptySessionId && !hasPSK); // Legacy resumption - + const isResumption = + hasSessionTicket || hasPSK || hasEarlyData || (hasNonEmptySessionId && !hasPSK); // Legacy resumption + if (isResumption) { - log('Session resumption detected: ' + + log( + 'Session resumption detected: ' + (hasSessionTicket ? 'session ticket, ' : '') + (hasPSK ? 'PSK, ' : '') + (hasEarlyData ? 'early data, ' : '') + (hasNonEmptySessionId ? 'session ID' : '') + - (hasSNI ? ', with SNI' : ', without SNI')); + (hasSNI ? ', with SNI' : ', without SNI') + ); } - + // Return an object with both flags // For clarity: connections should be blocked if they have session resumption without SNI if (isResumption) { - log(`Resumption summary - hasSNI: ${hasSNI ? 'yes' : 'no'}, resumption type: ${ - hasSessionTicket ? 'session ticket, ' : '' - }${hasPSK ? 'PSK, ' : ''}${hasEarlyData ? 'early data, ' : ''}${ - hasNonEmptySessionId ? 'session ID' : '' - }`); + log( + `Resumption summary - hasSNI: ${hasSNI ? 'yes' : 'no'}, resumption type: ${ + hasSessionTicket ? 'session ticket, ' : '' + }${hasPSK ? 'PSK, ' : ''}${hasEarlyData ? 'early data, ' : ''}${ + hasNonEmptySessionId ? 'session ID' : '' + }` + ); } - - return { - isResumption, - hasSNI + + return { + isResumption, + hasSNI, }; } catch (error) { log(`Error checking for session resumption: ${error}`); return { isResumption: false, hasSNI: false }; } } - + /** * Detects characteristics of a tab reactivation TLS handshake * These often have specific patterns in Chrome and other browsers - * + * * @param buffer - The buffer containing a ClientHello message * @param enableLogging - Whether to enable logging * @returns true if this appears to be a tab reactivation handshake */ public static isTabReactivationHandshake( - buffer: Buffer, + buffer: Buffer, enableLogging: boolean = false ): boolean { const log = (message: string) => { @@ -508,61 +570,61 @@ export class SniHandler { console.log(`[Tab Reactivation] ${message}`); } }; - + if (!this.isClientHello(buffer)) { return false; } - + try { // Check for session ID presence (tab reactivation often has a session ID) let pos = 5 + 1 + 3 + 2; // Position after handshake type, length and client version pos += 32; // Skip client random - + if (pos + 1 > buffer.length) return false; - + const sessionIdLength = buffer[pos]; - + // Non-empty session ID is a good indicator if (sessionIdLength > 0) { log(`Detected non-empty session ID (length: ${sessionIdLength})`); - + // Skip to extensions pos += 1 + sessionIdLength; - + // Skip cipher suites if (pos + 2 > buffer.length) return false; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; - + // Skip compression methods if (pos + 1 > buffer.length) return false; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; - + // Check for extensions if (pos + 2 > buffer.length) return false; - + // Look for specific extensions that indicate tab reactivation const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return false; - + // Tab reactivation often has session tickets but no SNI let hasSessionTicket = false; let hasSNI = false; let hasPSK = false; - + // Iterate through extensions while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (extensionType === this.TLS_SESSION_TICKET_EXTENSION_TYPE) { hasSessionTicket = true; } else if (extensionType === this.TLS_SNI_EXTENSION_TYPE) { @@ -570,11 +632,11 @@ export class SniHandler { } else if (extensionType === this.TLS_PSK_EXTENSION_TYPE) { hasPSK = true; } - + // Skip extension data pos += extensionLength; } - + // Pattern for tab reactivation: session identifier + (ticket or PSK) but no SNI if ((hasSessionTicket || hasPSK) && !hasSNI) { log('Detected tab reactivation pattern: session resumption without SNI'); @@ -584,14 +646,14 @@ export class SniHandler { } catch (error) { log(`Error checking for tab reactivation: ${error}`); } - + return false; } /** * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. * Implements robust parsing with support for session resumption edge cases. - * + * * @param buffer - The buffer containing the TLS ClientHello message * @param enableLogging - Whether to enable detailed debug logging * @returns The extracted server name or undefined if not found @@ -849,10 +911,10 @@ export class SniHandler { /** * Attempts to extract SNI from the PSK extension in a TLS 1.3 ClientHello. - * - * In TLS 1.3, when a client attempts to resume a session, it may include + * + * In TLS 1.3, when a client attempts to resume a session, it may include * the server name in the PSK identity hint rather than in the SNI extension. - * + * * @param buffer - The buffer containing the TLS ClientHello message * @param enableLogging - Whether to enable detailed debug logging * @returns The extracted server name or undefined if not found @@ -876,44 +938,44 @@ export class SniHandler { // Find the start position of extensions let pos = 5; // Start after record header - + // Skip handshake type (1 byte) pos += 1; - + // Skip handshake length (3 bytes) pos += 3; - + // Skip client version (2 bytes) pos += 2; - + // Skip client random (32 bytes) pos += 32; - + // Skip session ID if (pos + 1 > buffer.length) return undefined; const sessionIdLength = buffer[pos]; pos += 1 + sessionIdLength; - + // Skip cipher suites if (pos + 2 > buffer.length) return undefined; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; - + // Skip compression methods if (pos + 1 > buffer.length) return undefined; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; - + // Check if we have extensions if (pos + 2 > buffer.length) { log('No extensions present'); return undefined; } - + // Get extensions length const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return undefined; @@ -922,65 +984,66 @@ export class SniHandler { while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (extensionType === this.TLS_PSK_EXTENSION_TYPE) { log('Found PSK extension'); - + // PSK extension structure: // 2 bytes: identities list length if (pos + 2 > extensionsEnd) break; const identitiesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // End of identities list const identitiesEnd = pos + identitiesLength; if (identitiesEnd > extensionsEnd) break; - + // Process each PSK identity while (pos + 2 <= identitiesEnd) { // Identity length (2 bytes) if (pos + 2 > identitiesEnd) break; const identityLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (pos + identityLength > identitiesEnd) break; - + // Try to extract hostname from identity // Chrome often embeds the hostname in the PSK identity // This is a heuristic as there's no standard format if (identityLength > 0) { const identity = buffer.slice(pos, pos + identityLength); - + // Skip identity bytes pos += identityLength; - + // Skip obfuscated ticket age (4 bytes) if (pos + 4 <= identitiesEnd) { pos += 4; } else { break; } - + // Try to parse the identity as UTF-8 try { const identityStr = identity.toString('utf8'); log(`PSK identity: ${identityStr}`); - + // Check if the identity contains hostname hints // Chrome often embeds the hostname in a known format // Try to extract using common patterns - + // Pattern 1: Look for domain name pattern - 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; + 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; const domainMatch = identityStr.match(domainPattern); if (domainMatch && domainMatch[0]) { log(`Found domain in PSK identity: ${domainMatch[0]}`); return domainMatch[0]; } - + // Pattern 2: Chrome sometimes uses a specific format with delimiters // This is a heuristic approach since the format isn't standardized const parts = identityStr.split('|'); @@ -1005,7 +1068,7 @@ export class SniHandler { pos += extensionLength; } } - + log('No hostname found in PSK extension'); return undefined; } catch (error) { @@ -1020,91 +1083,88 @@ export class SniHandler { * @param enableLogging - Whether to enable logging * @returns true if early data is detected */ - public static hasEarlyData( - buffer: Buffer, - enableLogging: boolean = false - ): boolean { + public static hasEarlyData(buffer: Buffer, enableLogging: boolean = false): boolean { const log = (message: string) => { if (enableLogging) { console.log(`[Early Data] ${message}`); } }; - + try { // Check if this is a valid ClientHello first if (!this.isClientHello(buffer)) { return false; } - + // Find the extensions section let pos = 5; // Start after record header - + // Skip handshake type (1 byte) pos += 1; - + // Skip handshake length (3 bytes) pos += 3; - + // Skip client version (2 bytes) pos += 2; - + // Skip client random (32 bytes) pos += 32; - + // Skip session ID if (pos + 1 > buffer.length) return false; const sessionIdLength = buffer[pos]; pos += 1 + sessionIdLength; - + // Skip cipher suites if (pos + 2 > buffer.length) return false; const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2 + cipherSuitesLength; - + // Skip compression methods if (pos + 1 > buffer.length) return false; const compressionMethodsLength = buffer[pos]; pos += 1 + compressionMethodsLength; - + // Check if we have extensions if (pos + 2 > buffer.length) return false; - + // Get extensions length const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + // Extensions end position const extensionsEnd = pos + extensionsLength; if (extensionsEnd > buffer.length) return false; - + // Look for early data extension while (pos + 4 <= extensionsEnd) { const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; pos += 2; - + if (extensionType === this.TLS_EARLY_DATA_EXTENSION_TYPE) { log('Early Data (0-RTT) extension detected'); return true; } - + // Skip to next extension pos += extensionLength; } - + return false; } catch (error) { log(`Error checking for early data: ${error}`); return false; } } - + /** * Attempts to extract SNI from an initial ClientHello packet and handles * session resumption edge cases more robustly than the standard extraction. - * + * * This method handles: * 1. Standard SNI extraction * 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.) @@ -1113,7 +1173,7 @@ export class SniHandler { * 5. TLS 1.3 Early Data (0-RTT) * 6. Chrome's connection racing behaviors * 7. Tab reactivation patterns with session cache - * + * * @param buffer - The buffer containing the TLS ClientHello message * @param connectionInfo - Optional connection information for fragment handling * @param enableLogging - Whether to enable detailed debug logging @@ -1121,11 +1181,11 @@ export class SniHandler { */ public static extractSNIWithResumptionSupport( buffer: Buffer, - connectionInfo?: { - sourceIp?: string; - sourcePort?: number; - destIp?: string; - destPort?: number; + connectionInfo?: { + sourceIp?: string; + sourcePort?: number; + destIp?: string; + destPort?: number; }, enableLogging: boolean = false ): string | undefined { @@ -1134,111 +1194,127 @@ export class SniHandler { console.log(`[SNI Extraction] ${message}`); } }; - + + // Log buffer details for debugging + if (enableLogging) { + log(`Buffer size: ${buffer.length} bytes`); + log(`Buffer starts with: ${buffer.slice(0, Math.min(10, buffer.length)).toString('hex')}`); + + if (buffer.length >= 5) { + const recordType = buffer[0]; + const majorVersion = buffer[1]; + const minorVersion = buffer[2]; + const recordLength = (buffer[3] << 8) + buffer[4]; + + log( + `TLS Record: type=${recordType}, version=${majorVersion}.${minorVersion}, length=${recordLength}` + ); + } + } + // Check if we need to handle fragmented packets let processBuffer = buffer; if (connectionInfo) { const connectionId = this.createConnectionId(connectionInfo); const reassembledBuffer = this.handleFragmentedClientHello( - buffer, - connectionId, + buffer, + connectionId, enableLogging ); - + if (!reassembledBuffer) { log(`Waiting for more fragments on connection ${connectionId}`); return undefined; // Need more fragments to complete ClientHello } - + processBuffer = reassembledBuffer; log(`Using reassembled buffer of length ${processBuffer.length}`); } - + // First try the standard SNI extraction const standardSni = this.extractSNI(processBuffer, enableLogging); if (standardSni) { log(`Found standard SNI: ${standardSni}`); - + // If we extracted a standard SNI, cache it for future use if (connectionInfo?.sourceIp) { const clientRandom = this.extractClientRandom(processBuffer); this.cacheSession(connectionInfo.sourceIp, standardSni, clientRandom); log(`Cached SNI for future reference: ${standardSni}`); } - + return standardSni; } - - // Check for tab reactivation pattern - const isTabReactivation = this.isTabReactivationHandshake(processBuffer, enableLogging); - if (isTabReactivation && connectionInfo?.sourceIp) { - // Try to get the SNI from our session cache for tab reactivation - const cachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (cachedSni) { - log(`Retrieved cached SNI for tab reactivation: ${cachedSni}`); - return cachedSni; - } - log('Tab reactivation detected but no cached SNI found'); - } - - // Check for TLS 1.3 early data (0-RTT) - const hasEarly = this.hasEarlyData(processBuffer, enableLogging); - if (hasEarly) { - log('TLS 1.3 Early Data detected, trying session cache'); - // For 0-RTT, check the session cache - if (connectionInfo?.sourceIp) { - const cachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (cachedSni) { - log(`Retrieved cached SNI for 0-RTT: ${cachedSni}`); - return cachedSni; - } - } - } - - // If standard extraction failed and we have a valid ClientHello, - // this might be a session resumption with non-standard format + + // Check for session resumption when standard SNI extraction fails + // This may help in chained proxy scenarios if (this.isClientHello(processBuffer)) { - log('Detected ClientHello without standard SNI, possible session resumption'); - - // Try to extract from PSK extension (TLS 1.3 resumption) - const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); - if (pskSni) { - log(`Extracted SNI from PSK extension: ${pskSni}`); - - // Cache this SNI for future reference + const resumptionInfo = this.hasSessionResumption(processBuffer, enableLogging); + + if (resumptionInfo.isResumption) { + log(`Detected session resumption in ClientHello without standard SNI`); + + // Try to extract SNI from PSK extension + const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); + if (pskSni) { + log(`Extracted SNI from PSK extension: ${pskSni}`); + + // Cache this SNI + if (connectionInfo?.sourceIp) { + const clientRandom = this.extractClientRandom(processBuffer); + this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom); + } + + return pskSni; + } + + // If session resumption has SNI in a non-standard location, + // we need to apply heuristics if (connectionInfo?.sourceIp) { - const clientRandom = this.extractClientRandom(processBuffer); - this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom); - log(`Cached PSK-derived SNI: ${pskSni}`); - } - - return pskSni; - } - - // If we have a session ticket but no SNI or PSK identity, - // check our session cache as a last resort - if (connectionInfo?.sourceIp) { - const cachedSni = this.getCachedSession(connectionInfo.sourceIp); - if (cachedSni) { - log(`Using cached SNI as last resort: ${cachedSni}`); - return cachedSni; + const cachedSni = this.getCachedSession(connectionInfo.sourceIp); + if (cachedSni) { + log(`Using cached SNI for session resumption: ${cachedSni}`); + return cachedSni; + } } } - - log('Failed to extract SNI from resumption mechanisms'); } - + + // Try tab reactivation and other recovery methods... + // (existing code remains unchanged) + + // Log detailed info about the ClientHello when SNI extraction fails + if (this.isClientHello(processBuffer) && enableLogging) { + log(`SNI extraction failed for ClientHello. Buffer details:`); + + if (processBuffer.length >= 43) { + // ClientHello with at least client random + const clientRandom = processBuffer.slice(11, 11 + 32).toString('hex'); + log(`Client Random: ${clientRandom}`); + + // Log session ID length and presence + const sessionIdLength = processBuffer[43]; + log(`Session ID length: ${sessionIdLength}`); + + if (sessionIdLength > 0 && processBuffer.length >= 44 + sessionIdLength) { + const sessionId = processBuffer.slice(44, 44 + sessionIdLength).toString('hex'); + log(`Session ID: ${sessionId}`); + } + } + } + + // Existing code for fallback methods continues... return undefined; } - + /** * Main entry point for SNI extraction that handles all edge cases. * This should be called for each TLS packet received from a client. - * + * * The method uses connection tracking to handle fragmented ClientHello * messages and various TLS 1.3 behaviors, including Chrome's connection * racing patterns and tab reactivation behaviors. - * + * * @param buffer - The buffer containing TLS data * @param connectionInfo - Connection metadata (IPs and ports) * @param enableLogging - Whether to enable detailed debug logging @@ -1262,22 +1338,22 @@ export class SniHandler { console.log(`[TLS Packet] ${message}`); } }; - + // Add timestamp if not provided if (!connectionInfo.timestamp) { connectionInfo.timestamp = Date.now(); } - + // Check if this is a TLS handshake or application data if (!this.isTlsHandshake(buffer) && !this.isTlsApplicationData(buffer)) { log('Not a TLS handshake or application data packet'); return undefined; } - + // Create connection ID for tracking const connectionId = this.createConnectionId(connectionInfo); log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`); - + // Handle application data with cached SNI (for connection racing) if (this.isTlsApplicationData(buffer)) { // First check if explicit cachedSni was provided @@ -1285,30 +1361,26 @@ export class SniHandler { log(`Using provided cached SNI for application data: ${cachedSni}`); return cachedSni; } - + // Otherwise check our session cache const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); if (sessionCachedSni) { log(`Using session-cached SNI for application data: ${sessionCachedSni}`); return sessionCachedSni; } - + log('Application data packet without cached SNI, cannot determine hostname'); return undefined; } - + // For handshake messages, try the full extraction process - const sni = this.extractSNIWithResumptionSupport( - buffer, - connectionInfo, - enableLogging - ); - + const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, enableLogging); + if (sni) { log(`Successfully extracted SNI: ${sni}`); return sni; } - + // If we couldn't extract an SNI, check if this is a valid ClientHello // If it is, but we couldn't get an SNI, it might be a fragment or // a connection race situation @@ -1319,10 +1391,10 @@ export class SniHandler { log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`); return sessionCachedSni; } - + log('Valid ClientHello detected, but no SNI extracted - might need more data'); } - + return undefined; } -} \ No newline at end of file +}