diff --git a/changelog.md b/changelog.md index 70a3294..5c0c27e 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # Changelog +## 2025-03-11 - 3.40.0 - feat(SniHandler) +Add session cache support and tab reactivation detection to improve SNI extraction in TLS handshakes + +- Introduce a session cache mechanism to store and retrieve cached SNI values based on client IP (and optionally client random) to better handle tab reactivation scenarios. +- Implement functions to initialize, update, and clean up the session cache for TLS ClientHello messages. +- Enhance SNI extraction logic to check for tab reactivation handshakes and to return cached SNI for resumed connections or 0-RTT scenarios. +- Update PSK extension handling to safely skip over obfuscated ticket age bytes. + ## 2025-03-11 - 3.39.0 - feat(PortProxy) Add domain-specific NetworkProxy integration support to PortProxy diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 8930d0c..5fdeacf 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.39.0', + version: '3.40.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 f7f9d73..2ad230d 100644 --- a/ts/classes.snihandler.ts +++ b/ts/classes.snihandler.ts @@ -3,8 +3,8 @@ import { Buffer } from 'buffer'; /** * SNI (Server Name Indication) handler for TLS connections. * Provides robust extraction of SNI values from TLS ClientHello messages - * with support for fragmented packets, TLS 1.3 resumption, and Chrome-specific - * connection behaviors. + * with support for fragmented packets, TLS 1.3 resumption, Chrome-specific + * connection behaviors, and tab hibernation/reactivation scenarios. */ export class SniHandler { // TLS record types and constants @@ -22,6 +22,132 @@ export class SniHandler { 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(); + + // 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; + + /** + * Initialize the session cache cleanup mechanism. + * This should be called during application startup. + */ + public static initSessionCacheCleanup(): void { + if (this.sessionCleanupInterval === null) { + this.sessionCleanupInterval = setInterval(() => { + this.cleanupSessionCache(); + }, 60 * 60 * 1000); // Run every hour + } + } + + /** + * Clean up expired entries from the session cache + */ + 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 => { + this.sessionCache.delete(key); + }); + } + + /** + * 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 + */ + private static createClientKey(sourceIp: string, clientRandom?: Buffer): string { + if (clientRandom) { + // If we have the client random, use it for more precise tracking + return `${sourceIp}:${clientRandom.toString('hex')}`; + } + // Fall back to just IP-based tracking + return sourceIp; + } + + /** + * 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 + */ + private static cacheSession(sourceIp: string, sni: string, clientRandom?: Buffer): void { + const key = this.createClientKey(sourceIp, clientRandom); + this.sessionCache.set(key, { + sni, + timestamp: Date.now(), + 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 + */ + private static getCachedSession(sourceIp: string, clientRandom?: Buffer): string | undefined { + // Try with client random first for precision + if (clientRandom) { + const preciseKey = this.createClientKey(sourceIp, clientRandom); + const preciseSession = this.sessionCache.get(preciseKey); + if (preciseSession) { + return preciseSession.sni; + } + } + + // Fall back to IP-only lookup + const ipKey = this.createClientKey(sourceIp); + const session = this.sessionCache.get(ipKey); + if (session) { + // Update the timestamp to keep the session alive + 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 + */ + private static extractClientRandom(buffer: Buffer): Buffer | undefined { + try { + 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), + // handshake length (3 bytes), and client version (2 bytes) + return buffer.slice(11, 11 + 32); + } catch (error) { + return undefined; + } + } + /** * Checks if a buffer contains a TLS handshake message (record type 22) * @param buffer - The buffer to check @@ -153,6 +279,103 @@ export class SniHandler { return buffer[5] === this.TLS_CLIENT_HELLO_HANDSHAKE_TYPE; } + /** + * 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, + enableLogging: boolean = false + ): boolean { + const log = (message: string) => { + if (enableLogging) { + 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) { + hasSNI = true; + } 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'); + return true; + } + } + } 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. @@ -523,7 +746,11 @@ export class SniHandler { pos += identityLength; // Skip obfuscated ticket age (4 bytes) - pos += 4; + if (pos + 4 <= identitiesEnd) { + pos += 4; + } else { + break; + } // Try to parse the identity as UTF-8 try { @@ -673,6 +900,7 @@ export class SniHandler { * 4. Fragmented ClientHello messages * 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 @@ -718,15 +946,41 @@ export class SniHandler { 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, 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 + 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, @@ -738,18 +992,26 @@ export class SniHandler { const pskSni = this.extractSNIFromPSKExtension(processBuffer, enableLogging); if (pskSni) { log(`Extracted SNI from PSK extension: ${pskSni}`); + + // Cache this SNI for future reference + if (connectionInfo?.sourceIp) { + const clientRandom = this.extractClientRandom(processBuffer); + this.cacheSession(connectionInfo.sourceIp, pskSni, clientRandom); + log(`Cached PSK-derived SNI: ${pskSni}`); + } + return pskSni; } - // 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 + // 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; + } + } log('Failed to extract SNI from resumption mechanisms'); } @@ -763,7 +1025,7 @@ export class SniHandler { * * The method uses connection tracking to handle fragmented ClientHello * messages and various TLS 1.3 behaviors, including Chrome's connection - * racing patterns. + * racing patterns and tab reactivation behaviors. * * @param buffer - The buffer containing TLS data * @param connectionInfo - Connection metadata (IPs and ports) @@ -794,7 +1056,7 @@ export class SniHandler { connectionInfo.timestamp = Date.now(); } - // Check if this is a TLS handshake + // 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; @@ -804,15 +1066,26 @@ export class SniHandler { 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; + // Handle application data with cached SNI (for connection racing) + if (this.isTlsApplicationData(buffer)) { + // First check if explicit cachedSni was provided + if (cachedSni) { + 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; } - // Try to extract SNI with full resumption support and fragment handling + // For handshake messages, try the full extraction process const sni = this.extractSNIWithResumptionSupport( buffer, connectionInfo, @@ -828,6 +1101,13 @@ export class SniHandler { // If it is, but we couldn't get an SNI, it might be a fragment or // a connection race situation if (this.isClientHello(buffer)) { + // Check if we have a cached session for this IP + const sessionCachedSni = this.getCachedSession(connectionInfo.sourceIp); + if (sessionCachedSni) { + log(`Using session cache for ClientHello without SNI: ${sessionCachedSni}`); + return sessionCachedSni; + } + log('Valid ClientHello detected, but no SNI extracted - might need more data'); }