diff --git a/changelog.md b/changelog.md index 81bc93d..cde672b 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2025-03-11 - 3.38.0 - feat(SniHandler) +Enhance SNI extraction to support fragmented ClientHello messages, TLS 1.3 early data, and improved PSK parsing + +- Added isTlsApplicationData method for detecting TLS application data packets +- Implemented handleFragmentedClientHello to buffer and reassemble fragmented ClientHello messages +- Extended extractSNIWithResumptionSupport to accept connection information and use reassembled data +- Added detection for TLS 1.3 early data (0-RTT) in the ClientHello, supporting session resumption scenarios +- Improved logging and heuristics for handling potential connection racing in modern browsers + ## 2025-03-11 - 3.37.3 - fix(snihandler) Enhance SNI extraction to support TLS 1.3 PSK-based session resumption by adding a dedicated extractSNIFromPSKExtension method and improved logging for session resumption indicators. diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index f962f5f..8d8b8cd 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.37.3', + version: '3.38.0', 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 ef41e12..f7f9d73 100644 --- a/ts/classes.snihandler.ts +++ b/ts/classes.snihandler.ts @@ -2,17 +2,25 @@ import { Buffer } from 'buffer'; /** * SNI (Server Name Indication) handler for TLS connections. - * Provides robust extraction of SNI values from TLS ClientHello messages. + * Provides robust extraction of SNI values from TLS ClientHello messages + * with support for fragmented packets, TLS 1.3 resumption, and Chrome-specific + * connection behaviors. */ 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_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 + + // Buffer for handling fragmented ClientHello messages + private static fragmentedBuffers: Map = new Map(); + private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup /** * Checks if a buffer contains a TLS handshake message (record type 22) @@ -22,6 +30,107 @@ 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 + * @returns true if the buffer starts with a TLS application data record type + */ + 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; + }): 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 + * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed + */ + public static handleFragmentedClientHello( + buffer: Buffer, + connectionId: string, + enableLogging: boolean = false + ): Buffer | undefined { + const log = (message: string) => { + if (enableLogging) { + 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)) { + this.fragmentedBuffers.delete(connectionId); + 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) { + log(`Initial buffer contains complete ClientHello, length: ${buffer.length}`); + return buffer; + } + } + } catch (e) { + log(`Error checking initial buffer completeness: ${e}`); + } + + log(`Started buffering connection ${connectionId}, initial size: ${buffer.length}`); + return undefined; // Need more fragments + } else { + // Existing connection, append this buffer + 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; + } + } + } catch (e) { + log(`Error checking reassembled buffer completeness: ${e}`); + } + + return undefined; // Still need more fragments + } + } /** * Checks if a buffer contains a TLS ClientHello message @@ -466,6 +575,93 @@ export class SniHandler { } } + /** + * Checks if the buffer contains TLS 1.3 early data (0-RTT) + * @param buffer - The buffer to check + * @param enableLogging - Whether to enable logging + * @returns true if early data is detected + */ + 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. @@ -474,44 +670,165 @@ export class SniHandler { * 1. Standard SNI extraction * 2. TLS 1.3 PSK-based resumption (Chrome, Firefox, etc.) * 3. Session ticket-based resumption + * 4. Fragmented ClientHello messages + * 5. TLS 1.3 Early Data (0-RTT) + * 6. Chrome's connection racing behaviors * * @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 * @returns The extracted server name or undefined if not found */ public static extractSNIWithResumptionSupport( - buffer: Buffer, + buffer: Buffer, + connectionInfo?: { + sourceIp?: string; + sourcePort?: number; + destIp?: string; + destPort?: number; + }, enableLogging: boolean = false ): string | undefined { - // First try the standard SNI extraction - const standardSni = this.extractSNI(buffer, enableLogging); - if (standardSni) { + const log = (message: string) => { if (enableLogging) { - console.log(`[SNI Extraction] Found standard SNI: ${standardSni}`); + console.log(`[SNI Extraction] ${message}`); } + }; + + // Check if we need to handle fragmented packets + let processBuffer = buffer; + if (connectionInfo) { + const connectionId = this.createConnectionId(connectionInfo); + const reassembledBuffer = this.handleFragmentedClientHello( + 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}`); return standardSni; } + // Check for TLS 1.3 early data (0-RTT) + const hasEarly = this.hasEarlyData(processBuffer, enableLogging); + if (hasEarly) { + log('TLS 1.3 Early Data detected, using special handling'); + // In 0-RTT, Chrome often relies on server remembering the SNI from previous sessions + // We could implement session tracking here if necessary + } + // If standard extraction failed and we have a valid ClientHello, // this might be a session resumption with non-standard format - if (this.isClientHello(buffer)) { - if (enableLogging) { - console.log('[SNI Extraction] Detected ClientHello without standard SNI, possible session resumption'); - } + 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(buffer, enableLogging); + const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); if (pskSni) { - if (enableLogging) { - console.log(`[SNI Extraction] Extracted SNI from PSK extension: ${pskSni}`); - } + log(`Extracted SNI from PSK extension: ${pskSni}`); return pskSni; } - // Could add more browser-specific heuristics here if needed + // Special handling for Chrome connection racing + // Chrome often opens multiple connections in parallel with different + // characteristics to improve performance + // Here we would look for specific patterns in ClientHello that indicate + // it's part of a connection race + + // Detect if this is likely a secondary connection in a race + // by examining the cipher suites and extensions + // This would require session state tracking across connections + + log('Failed to extract SNI from resumption mechanisms'); + } + + 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. + * + * @param buffer - The buffer containing TLS data + * @param connectionInfo - Connection metadata (IPs and ports) + * @param enableLogging - Whether to enable detailed debug logging + * @param cachedSni - Optional cached SNI from previous connections (for racing detection) + * @returns The extracted server name or undefined if not found or more data needed + */ + public static processTlsPacket( + buffer: Buffer, + connectionInfo: { + sourceIp: string; + sourcePort: number; + destIp: string; + destPort: number; + timestamp?: number; + }, + enableLogging: boolean = false, + cachedSni?: string + ): string | undefined { + const log = (message: string) => { if (enableLogging) { - console.log('[SNI Extraction] Failed to extract SNI from resumption mechanisms'); + console.log(`[TLS Packet] ${message}`); } + }; + + // Add timestamp if not provided + if (!connectionInfo.timestamp) { + connectionInfo.timestamp = Date.now(); + } + + // Check if this is a TLS handshake + 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 special case: if we already have a cached SNI from a previous + // connection from the same client IP within a short time window, + // this might be a connection racing situation + if (cachedSni && this.isTlsApplicationData(buffer)) { + log(`Using cached SNI from connection racing: ${cachedSni}`); + return cachedSni; + } + + // Try to extract SNI with full resumption support and fragment handling + 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 + if (this.isClientHello(buffer)) { + log('Valid ClientHello detected, but no SNI extracted - might need more data'); } return undefined;