update structure
This commit is contained in:
		
							
								
								
									
										629
									
								
								ts/tls/sni/client-hello-parser.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										629
									
								
								ts/tls/sni/client-hello-parser.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,629 @@ | ||||
| import { Buffer } from 'buffer'; | ||||
| import {  | ||||
|   TlsRecordType, | ||||
|   TlsHandshakeType, | ||||
|   TlsExtensionType, | ||||
|   TlsUtils | ||||
| } from '../utils/tls-utils.js'; | ||||
|  | ||||
| /** | ||||
|  * Interface for logging functions used by the parser | ||||
|  */ | ||||
| export type LoggerFunction = (message: string) => void; | ||||
|  | ||||
| /** | ||||
|  * Result of a session resumption check | ||||
|  */ | ||||
| export interface SessionResumptionResult { | ||||
|   isResumption: boolean; | ||||
|   hasSNI: boolean; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Information about parsed TLS extensions | ||||
|  */ | ||||
| export interface ExtensionInfo { | ||||
|   type: number; | ||||
|   length: number; | ||||
|   data: Buffer; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Result of a ClientHello parse operation | ||||
|  */ | ||||
| export interface ClientHelloParseResult { | ||||
|   isValid: boolean; | ||||
|   version?: [number, number]; | ||||
|   random?: Buffer; | ||||
|   sessionId?: Buffer; | ||||
|   hasSessionId: boolean; | ||||
|   cipherSuites?: Buffer; | ||||
|   compressionMethods?: Buffer; | ||||
|   extensions: ExtensionInfo[]; | ||||
|   serverNameList?: string[]; | ||||
|   hasSessionTicket: boolean; | ||||
|   hasPsk: boolean; | ||||
|   hasEarlyData: boolean; | ||||
|   error?: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Fragment tracking information | ||||
|  */ | ||||
| export interface FragmentTrackingInfo { | ||||
|   buffer: Buffer; | ||||
|   timestamp: number; | ||||
|   connectionId: string; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Class for parsing TLS ClientHello messages | ||||
|  */ | ||||
| export class ClientHelloParser { | ||||
|   // Buffer for handling fragmented ClientHello messages | ||||
|   private static fragmentedBuffers: Map<string, FragmentTrackingInfo> = new Map(); | ||||
|   private static fragmentTimeout: number = 1000; // ms to wait for fragments before cleanup | ||||
|  | ||||
|   /** | ||||
|    * Clean up expired fragments | ||||
|    */ | ||||
|   private static cleanupExpiredFragments(): void { | ||||
|     const now = Date.now(); | ||||
|     for (const [connectionId, info] of this.fragmentedBuffers.entries()) { | ||||
|       if (now - info.timestamp > this.fragmentTimeout) { | ||||
|         this.fragmentedBuffers.delete(connectionId); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * 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 logger Optional logging function | ||||
|    * @returns A complete buffer if reassembly is successful, or undefined if more fragments are needed | ||||
|    */ | ||||
|   public static handleFragmentedClientHello( | ||||
|     buffer: Buffer, | ||||
|     connectionId: string, | ||||
|     logger?: LoggerFunction | ||||
|   ): Buffer | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Periodically clean up expired fragments | ||||
|     this.cleanupExpiredFragments(); | ||||
|  | ||||
|     // Check if we've seen this connection before | ||||
|     if (!this.fragmentedBuffers.has(connectionId)) { | ||||
|       // New connection, start with this buffer | ||||
|       this.fragmentedBuffers.set(connectionId, { | ||||
|         buffer, | ||||
|         timestamp: Date.now(), | ||||
|         connectionId | ||||
|       }); | ||||
|  | ||||
|       // Evaluate if this buffer already contains a complete ClientHello | ||||
|       try { | ||||
|         if (buffer.length >= 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 { | ||||
|       // Existing connection, append this buffer | ||||
|       const existingInfo = this.fragmentedBuffers.get(connectionId)!; | ||||
|       const newBuffer = Buffer.concat([existingInfo.buffer, buffer]); | ||||
|        | ||||
|       // Update the buffer and timestamp | ||||
|       this.fragmentedBuffers.set(connectionId, { | ||||
|         ...existingInfo, | ||||
|         buffer: newBuffer, | ||||
|         timestamp: Date.now() | ||||
|       }); | ||||
|  | ||||
|       log(`Appended to buffer for ${connectionId}, new size: ${newBuffer.length}`); | ||||
|  | ||||
|       // Check if we now have a complete ClientHello | ||||
|       try { | ||||
|         if (newBuffer.length >= 5) { | ||||
|           // 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] === TlsHandshakeType.CLIENT_HELLO | ||||
|             ) { | ||||
|               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 ${TlsRecordType.HANDSHAKE})` | ||||
|                 ); | ||||
|  | ||||
|                 if (nextRecordType === TlsRecordType.HANDSHAKE) { | ||||
|                   const handshakeType = newBuffer[recordLength + 5]; | ||||
|                   log( | ||||
|                     `Next handshake type: ${handshakeType} (looking for ${TlsHandshakeType.CLIENT_HELLO})` | ||||
|                   ); | ||||
|  | ||||
|                   if (handshakeType === TlsHandshakeType.CLIENT_HELLO) { | ||||
|                     // 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 | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /** | ||||
|    * Parses a TLS ClientHello message and extracts all components | ||||
|    *  | ||||
|    * @param buffer The buffer containing the ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns Parsed ClientHello or undefined if parsing failed | ||||
|    */ | ||||
|   public static parseClientHello( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): ClientHelloParseResult { | ||||
|     const log = logger || (() => {}); | ||||
|     const result: ClientHelloParseResult = { | ||||
|       isValid: false, | ||||
|       hasSessionId: false, | ||||
|       extensions: [], | ||||
|       hasSessionTicket: false, | ||||
|       hasPsk: false, | ||||
|       hasEarlyData: false | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       // Check basic validity | ||||
|       if (buffer.length < 5) { | ||||
|         result.error = 'Buffer too small for TLS record header'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Check record type (must be HANDSHAKE) | ||||
|       if (buffer[0] !== TlsRecordType.HANDSHAKE) { | ||||
|         result.error = `Not a TLS handshake record: ${buffer[0]}`; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Get TLS version from record header | ||||
|       const majorVersion = buffer[1]; | ||||
|       const minorVersion = buffer[2]; | ||||
|       result.version = [majorVersion, minorVersion]; | ||||
|       log(`TLS record version: ${majorVersion}.${minorVersion}`); | ||||
|  | ||||
|       // Parse record length (bytes 3-4, big-endian) | ||||
|       const recordLength = (buffer[3] << 8) + buffer[4]; | ||||
|       log(`Record length: ${recordLength}`); | ||||
|  | ||||
|       // Validate record length against buffer size | ||||
|       if (buffer.length < recordLength + 5) { | ||||
|         result.error = 'Buffer smaller than expected record length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Start of handshake message in the buffer | ||||
|       let pos = 5; | ||||
|  | ||||
|       // Check handshake type (must be CLIENT_HELLO) | ||||
|       if (buffer[pos] !== TlsHandshakeType.CLIENT_HELLO) { | ||||
|         result.error = `Not a ClientHello message: ${buffer[pos]}`; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Skip handshake type (1 byte) | ||||
|       pos += 1; | ||||
|  | ||||
|       // Parse handshake length (3 bytes, big-endian) | ||||
|       const handshakeLength = (buffer[pos] << 16) + (buffer[pos + 1] << 8) + buffer[pos + 2]; | ||||
|       log(`Handshake length: ${handshakeLength}`); | ||||
|  | ||||
|       // Skip handshake length (3 bytes) | ||||
|       pos += 3; | ||||
|  | ||||
|       // Check client version (2 bytes) | ||||
|       const clientMajorVersion = buffer[pos]; | ||||
|       const clientMinorVersion = buffer[pos + 1]; | ||||
|       log(`Client version: ${clientMajorVersion}.${clientMinorVersion}`); | ||||
|  | ||||
|       // Skip client version (2 bytes) | ||||
|       pos += 2; | ||||
|  | ||||
|       // Extract client random (32 bytes) | ||||
|       if (pos + 32 > buffer.length) { | ||||
|         result.error = 'Buffer too small for client random'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.random = buffer.slice(pos, pos + 32); | ||||
|       log(`Client random: ${result.random.toString('hex')}`); | ||||
|  | ||||
|       // Skip client random (32 bytes) | ||||
|       pos += 32; | ||||
|  | ||||
|       // Parse session ID | ||||
|       if (pos + 1 > buffer.length) { | ||||
|         result.error = 'Buffer too small for session ID length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       const sessionIdLength = buffer[pos]; | ||||
|       log(`Session ID length: ${sessionIdLength}`); | ||||
|       pos += 1; | ||||
|        | ||||
|       result.hasSessionId = sessionIdLength > 0; | ||||
|        | ||||
|       if (sessionIdLength > 0) { | ||||
|         if (pos + sessionIdLength > buffer.length) { | ||||
|           result.error = 'Buffer too small for session ID'; | ||||
|           return result; | ||||
|         } | ||||
|          | ||||
|         result.sessionId = buffer.slice(pos, pos + sessionIdLength); | ||||
|         log(`Session ID: ${result.sessionId.toString('hex')}`); | ||||
|       } | ||||
|  | ||||
|       // Skip session ID | ||||
|       pos += sessionIdLength; | ||||
|  | ||||
|       // Check if we have enough bytes left for cipher suites | ||||
|       if (pos + 2 > buffer.length) { | ||||
|         result.error = 'Buffer too small for cipher suites length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse cipher suites length (2 bytes, big-endian) | ||||
|       const cipherSuitesLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|       log(`Cipher suites length: ${cipherSuitesLength}`); | ||||
|       pos += 2; | ||||
|        | ||||
|       // Extract cipher suites | ||||
|       if (pos + cipherSuitesLength > buffer.length) { | ||||
|         result.error = 'Buffer too small for cipher suites'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.cipherSuites = buffer.slice(pos, pos + cipherSuitesLength); | ||||
|  | ||||
|       // Skip cipher suites | ||||
|       pos += cipherSuitesLength; | ||||
|  | ||||
|       // Check if we have enough bytes left for compression methods | ||||
|       if (pos + 1 > buffer.length) { | ||||
|         result.error = 'Buffer too small for compression methods length'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse compression methods length (1 byte) | ||||
|       const compressionMethodsLength = buffer[pos]; | ||||
|       log(`Compression methods length: ${compressionMethodsLength}`); | ||||
|       pos += 1; | ||||
|        | ||||
|       // Extract compression methods | ||||
|       if (pos + compressionMethodsLength > buffer.length) { | ||||
|         result.error = 'Buffer too small for compression methods'; | ||||
|         return result; | ||||
|       } | ||||
|        | ||||
|       result.compressionMethods = buffer.slice(pos, pos + compressionMethodsLength); | ||||
|  | ||||
|       // Skip compression methods | ||||
|       pos += compressionMethodsLength; | ||||
|  | ||||
|       // Check if we have enough bytes for extensions length | ||||
|       if (pos + 2 > buffer.length) { | ||||
|         // No extensions present - this is valid for older TLS versions | ||||
|         result.isValid = true; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Parse extensions length (2 bytes, big-endian) | ||||
|       const extensionsLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|       log(`Extensions length: ${extensionsLength}`); | ||||
|       pos += 2; | ||||
|  | ||||
|       // Extensions end position | ||||
|       const extensionsEnd = pos + extensionsLength; | ||||
|  | ||||
|       // Check if extensions length is valid | ||||
|       if (extensionsEnd > buffer.length) { | ||||
|         result.error = 'Extensions length exceeds buffer size'; | ||||
|         return result; | ||||
|       } | ||||
|  | ||||
|       // Iterate through extensions | ||||
|       const serverNames: string[] = []; | ||||
|        | ||||
|       while (pos + 4 <= extensionsEnd) { | ||||
|         // Parse extension type (2 bytes, big-endian) | ||||
|         const extensionType = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|         log(`Extension type: 0x${extensionType.toString(16).padStart(4, '0')}`); | ||||
|         pos += 2; | ||||
|  | ||||
|         // Parse extension length (2 bytes, big-endian) | ||||
|         const extensionLength = (buffer[pos] << 8) + buffer[pos + 1]; | ||||
|         log(`Extension length: ${extensionLength}`); | ||||
|         pos += 2; | ||||
|          | ||||
|         // Extract extension data | ||||
|         if (pos + extensionLength > extensionsEnd) { | ||||
|           result.error = `Extension ${extensionType} data exceeds bounds`; | ||||
|           return result; | ||||
|         } | ||||
|          | ||||
|         const extensionData = buffer.slice(pos, pos + extensionLength); | ||||
|          | ||||
|         // Record all extensions | ||||
|         result.extensions.push({ | ||||
|           type: extensionType, | ||||
|           length: extensionLength, | ||||
|           data: extensionData | ||||
|         }); | ||||
|          | ||||
|         // Track specific extension types | ||||
|         if (extensionType === TlsExtensionType.SERVER_NAME) { | ||||
|           // Server Name Indication (SNI) | ||||
|           this.parseServerNameExtension(extensionData, serverNames, logger); | ||||
|         } else if (extensionType === TlsExtensionType.SESSION_TICKET) { | ||||
|           // Session ticket | ||||
|           result.hasSessionTicket = true; | ||||
|         } else if (extensionType === TlsExtensionType.PRE_SHARED_KEY) { | ||||
|           // TLS 1.3 PSK | ||||
|           result.hasPsk = true; | ||||
|         } else if (extensionType === TlsExtensionType.EARLY_DATA) { | ||||
|           // TLS 1.3 Early Data (0-RTT) | ||||
|           result.hasEarlyData = true; | ||||
|         } | ||||
|          | ||||
|         // Move to next extension | ||||
|         pos += extensionLength; | ||||
|       } | ||||
|        | ||||
|       // Store any server names found | ||||
|       if (serverNames.length > 0) { | ||||
|         result.serverNameList = serverNames; | ||||
|       } | ||||
|        | ||||
|       // Mark as valid if we get here | ||||
|       result.isValid = true; | ||||
|       return result; | ||||
|     } catch (error) { | ||||
|       const errorMessage = error instanceof Error ? error.message : String(error); | ||||
|       log(`Error parsing ClientHello: ${errorMessage}`); | ||||
|       result.error = errorMessage; | ||||
|       return result; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Parses the server name extension data and extracts hostnames | ||||
|    *  | ||||
|    * @param data Extension data buffer | ||||
|    * @param serverNames Array to populate with found server names | ||||
|    * @param logger Optional logging function | ||||
|    * @returns true if parsing succeeded | ||||
|    */ | ||||
|   private static parseServerNameExtension( | ||||
|     data: Buffer, | ||||
|     serverNames: string[], | ||||
|     logger?: LoggerFunction | ||||
|   ): boolean { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Need at least 2 bytes for server name list length | ||||
|       if (data.length < 2) { | ||||
|         log('SNI extension too small for server name list length'); | ||||
|         return false; | ||||
|       } | ||||
|        | ||||
|       // Parse server name list length (2 bytes) | ||||
|       const listLength = (data[0] << 8) + data[1]; | ||||
|        | ||||
|       // Skip to first name entry | ||||
|       let pos = 2; | ||||
|        | ||||
|       // End of list | ||||
|       const listEnd = pos + listLength; | ||||
|        | ||||
|       // Validate length | ||||
|       if (listEnd > data.length) { | ||||
|         log('SNI server name list exceeds extension data'); | ||||
|         return false; | ||||
|       } | ||||
|        | ||||
|       // Process all name entries | ||||
|       while (pos + 3 <= listEnd) { | ||||
|         // Name type (1 byte) | ||||
|         const nameType = data[pos]; | ||||
|         pos += 1; | ||||
|          | ||||
|         // For hostname, type must be 0 | ||||
|         if (nameType !== 0) { | ||||
|           // Skip this entry | ||||
|           if (pos + 2 <= listEnd) { | ||||
|             const nameLength = (data[pos] << 8) + data[pos + 1]; | ||||
|             pos += 2 + nameLength; | ||||
|             continue; | ||||
|           } else { | ||||
|             log('Malformed SNI entry'); | ||||
|             return false; | ||||
|           } | ||||
|         } | ||||
|          | ||||
|         // Parse hostname length (2 bytes) | ||||
|         if (pos + 2 > listEnd) { | ||||
|           log('SNI extension truncated'); | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         const nameLength = (data[pos] << 8) + data[pos + 1]; | ||||
|         pos += 2; | ||||
|          | ||||
|         // Extract hostname | ||||
|         if (pos + nameLength > listEnd) { | ||||
|           log('SNI hostname truncated'); | ||||
|           return false; | ||||
|         } | ||||
|          | ||||
|         // Extract the hostname as UTF-8 | ||||
|         try { | ||||
|           const hostname = data.slice(pos, pos + nameLength).toString('utf8'); | ||||
|           log(`Found SNI hostname: ${hostname}`); | ||||
|           serverNames.push(hostname); | ||||
|         } catch (err) { | ||||
|           log(`Error extracting hostname: ${err}`); | ||||
|         } | ||||
|          | ||||
|         // Move to next entry | ||||
|         pos += nameLength; | ||||
|       } | ||||
|        | ||||
|       return serverNames.length > 0; | ||||
|     } catch (error) { | ||||
|       log(`Error parsing SNI extension: ${error}`); | ||||
|       return false; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Determines if a ClientHello contains session resumption indicators | ||||
|    *  | ||||
|    * @param buffer The ClientHello buffer | ||||
|    * @param logger Optional logging function | ||||
|    * @returns Session resumption result | ||||
|    */ | ||||
|   public static hasSessionResumption( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): SessionResumptionResult { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     if (!TlsUtils.isClientHello(buffer)) { | ||||
|       return { isResumption: false, hasSNI: false }; | ||||
|     } | ||||
|      | ||||
|     const parseResult = this.parseClientHello(buffer, logger); | ||||
|     if (!parseResult.isValid) { | ||||
|       log(`ClientHello parse failed: ${parseResult.error}`); | ||||
|       return { isResumption: false, hasSNI: false }; | ||||
|     } | ||||
|      | ||||
|     // Check resumption indicators | ||||
|     const hasSessionId = parseResult.hasSessionId; | ||||
|     const hasSessionTicket = parseResult.hasSessionTicket; | ||||
|     const hasPsk = parseResult.hasPsk; | ||||
|     const hasEarlyData = parseResult.hasEarlyData; | ||||
|      | ||||
|     // Check for SNI | ||||
|     const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; | ||||
|      | ||||
|     // Consider it a resumption if any resumption mechanism is present | ||||
|     const isResumption = hasSessionTicket || hasPsk || hasEarlyData ||  | ||||
|                          (hasSessionId && !hasPsk); // Legacy resumption | ||||
|      | ||||
|     // Log details | ||||
|     if (isResumption) { | ||||
|       log( | ||||
|         'Session resumption detected: ' + | ||||
|         (hasSessionTicket ? 'session ticket, ' : '') + | ||||
|         (hasPsk ? 'PSK, ' : '') + | ||||
|         (hasEarlyData ? 'early data, ' : '') + | ||||
|         (hasSessionId ? 'session ID' : '') + | ||||
|         (hasSNI ? ', with SNI' : ', without SNI') | ||||
|       ); | ||||
|     } | ||||
|      | ||||
|     return { isResumption, hasSNI }; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Checks if a ClientHello appears to be from a tab reactivation  | ||||
|    *  | ||||
|    * @param buffer The ClientHello buffer  | ||||
|    * @param logger Optional logging function | ||||
|    * @returns true if it appears to be a tab reactivation | ||||
|    */ | ||||
|   public static isTabReactivationHandshake( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): boolean { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     if (!TlsUtils.isClientHello(buffer)) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Parse the ClientHello | ||||
|     const parseResult = this.parseClientHello(buffer, logger); | ||||
|     if (!parseResult.isValid) { | ||||
|       return false; | ||||
|     } | ||||
|      | ||||
|     // Tab reactivation pattern: session identifier + (ticket or PSK) but no SNI | ||||
|     const hasSessionId = parseResult.hasSessionId; | ||||
|     const hasSessionTicket = parseResult.hasSessionTicket; | ||||
|     const hasPsk = parseResult.hasPsk; | ||||
|     const hasSNI = !!parseResult.serverNameList && parseResult.serverNameList.length > 0; | ||||
|      | ||||
|     if ((hasSessionId && (hasSessionTicket || hasPsk)) && !hasSNI) { | ||||
|       log('Detected tab reactivation pattern: session resumption without SNI'); | ||||
|       return true; | ||||
|     } | ||||
|      | ||||
|     return false; | ||||
|   } | ||||
| } | ||||
							
								
								
									
										3
									
								
								ts/tls/sni/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								ts/tls/sni/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| /** | ||||
|  * SNI handling | ||||
|  */ | ||||
							
								
								
									
										353
									
								
								ts/tls/sni/sni-extraction.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								ts/tls/sni/sni-extraction.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,353 @@ | ||||
| import { Buffer } from 'buffer'; | ||||
| import { TlsExtensionType, TlsUtils } from '../utils/tls-utils.js'; | ||||
| import { | ||||
|   ClientHelloParser, | ||||
|   type LoggerFunction | ||||
| } from './client-hello-parser.js'; | ||||
|  | ||||
| /** | ||||
|  * Connection tracking information | ||||
|  */ | ||||
| export interface ConnectionInfo { | ||||
|   sourceIp: string; | ||||
|   sourcePort: number; | ||||
|   destIp: string; | ||||
|   destPort: number; | ||||
|   timestamp?: number; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Utilities for extracting SNI information from TLS handshakes | ||||
|  */ | ||||
| export class SniExtraction { | ||||
|   /** | ||||
|    * Extracts the SNI (Server Name Indication) from a TLS ClientHello message. | ||||
|    *  | ||||
|    * @param buffer The buffer containing the TLS ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns The extracted server name or undefined if not found | ||||
|    */ | ||||
|   public static extractSNI(buffer: Buffer, logger?: LoggerFunction): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Parse the ClientHello | ||||
|       const parseResult = ClientHelloParser.parseClientHello(buffer, logger); | ||||
|       if (!parseResult.isValid) { | ||||
|         log(`Failed to parse ClientHello: ${parseResult.error}`); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Check if ServerName extension was found | ||||
|       if (parseResult.serverNameList && parseResult.serverNameList.length > 0) { | ||||
|         // Use the first hostname (most common case) | ||||
|         const serverName = parseResult.serverNameList[0]; | ||||
|         log(`Found SNI: ${serverName}`); | ||||
|         return serverName; | ||||
|       } | ||||
|        | ||||
|       log('No SNI extension found in ClientHello'); | ||||
|       return undefined; | ||||
|     } catch (error) { | ||||
|       log(`Error extracting SNI: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * 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 | ||||
|    * the server name in the PSK identity hint rather than in the SNI extension. | ||||
|    *  | ||||
|    * @param buffer The buffer containing the TLS ClientHello message | ||||
|    * @param logger Optional logging function | ||||
|    * @returns The extracted server name or undefined if not found | ||||
|    */ | ||||
|   public static extractSNIFromPSKExtension( | ||||
|     buffer: Buffer, | ||||
|     logger?: LoggerFunction | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     try { | ||||
|       // Ensure this is a ClientHello | ||||
|       if (!TlsUtils.isClientHello(buffer)) { | ||||
|         log('Not a ClientHello message'); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Parse the ClientHello to find PSK extension | ||||
|       const parseResult = ClientHelloParser.parseClientHello(buffer, logger); | ||||
|       if (!parseResult.isValid || !parseResult.extensions) { | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Find the PSK extension | ||||
|       const pskExtension = parseResult.extensions.find(ext =>  | ||||
|         ext.type === TlsExtensionType.PRE_SHARED_KEY); | ||||
|        | ||||
|       if (!pskExtension) { | ||||
|         log('No PSK extension found'); | ||||
|         return undefined; | ||||
|       } | ||||
|        | ||||
|       // Parse the PSK extension data | ||||
|       const data = pskExtension.data; | ||||
|        | ||||
|       // PSK extension structure: | ||||
|       // 2 bytes: identities list length | ||||
|       if (data.length < 2) return undefined; | ||||
|        | ||||
|       const identitiesLength = (data[0] << 8) + data[1]; | ||||
|       let pos = 2; | ||||
|        | ||||
|       // End of identities list | ||||
|       const identitiesEnd = pos + identitiesLength; | ||||
|       if (identitiesEnd > data.length) return undefined; | ||||
|        | ||||
|       // Process each PSK identity | ||||
|       while (pos + 2 <= identitiesEnd) { | ||||
|         // Identity length (2 bytes) | ||||
|         if (pos + 2 > identitiesEnd) break; | ||||
|          | ||||
|         const identityLength = (data[pos] << 8) + data[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 = data.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 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('|'); | ||||
|             if (parts.length > 1) { | ||||
|               for (const part of parts) { | ||||
|                 if (part.includes('.') && !part.includes('/')) { | ||||
|                   const possibleDomain = part.trim(); | ||||
|                   if (/^[a-z0-9.-]+$/i.test(possibleDomain)) { | ||||
|                     log(`Found possible domain in PSK delimiter format: ${possibleDomain}`); | ||||
|                     return possibleDomain; | ||||
|                   } | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } catch (e) { | ||||
|             log('Failed to parse PSK identity as UTF-8'); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|        | ||||
|       log('No hostname found in PSK extension'); | ||||
|       return undefined; | ||||
|     } catch (error) { | ||||
|       log(`Error parsing PSK: ${error instanceof Error ? error.message : String(error)}`); | ||||
|       return undefined; | ||||
|     } | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Main entry point for SNI extraction with support for fragmented messages | ||||
|    * and session resumption edge cases. | ||||
|    *  | ||||
|    * @param buffer The buffer containing TLS data | ||||
|    * @param connectionInfo Connection tracking information | ||||
|    * @param logger Optional logging function | ||||
|    * @param cachedSni Optional previously cached SNI value | ||||
|    * @returns The extracted server name or undefined | ||||
|    */ | ||||
|   public static extractSNIWithResumptionSupport( | ||||
|     buffer: Buffer, | ||||
|     connectionInfo?: ConnectionInfo, | ||||
|     logger?: LoggerFunction, | ||||
|     cachedSni?: string | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Log buffer details for debugging | ||||
|     if (logger) { | ||||
|       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 = TlsUtils.createConnectionId(connectionInfo); | ||||
|       const reassembledBuffer = ClientHelloParser.handleFragmentedClientHello( | ||||
|         buffer, | ||||
|         connectionId, | ||||
|         logger | ||||
|       ); | ||||
|        | ||||
|       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, logger); | ||||
|     if (standardSni) { | ||||
|       log(`Found standard SNI: ${standardSni}`); | ||||
|       return standardSni; | ||||
|     } | ||||
|      | ||||
|     // Check for session resumption when standard SNI extraction fails | ||||
|     if (TlsUtils.isClientHello(processBuffer)) { | ||||
|       const resumptionInfo = ClientHelloParser.hasSessionResumption(processBuffer, logger); | ||||
|        | ||||
|       if (resumptionInfo.isResumption) { | ||||
|         log(`Detected session resumption in ClientHello without standard SNI`); | ||||
|          | ||||
|         // Try to extract SNI from PSK extension | ||||
|         const pskSni = this.extractSNIFromPSKExtension(processBuffer, logger); | ||||
|         if (pskSni) { | ||||
|           log(`Extracted SNI from PSK extension: ${pskSni}`); | ||||
|           return pskSni; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // If cached SNI was provided, use it for application data packets | ||||
|     if (cachedSni && TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       log(`Using provided cached SNI for application data: ${cachedSni}`); | ||||
|       return cachedSni; | ||||
|     } | ||||
|      | ||||
|     return undefined; | ||||
|   } | ||||
|    | ||||
|   /** | ||||
|    * Unified method for processing a TLS packet and extracting SNI. | ||||
|    * Main entry point for SNI extraction that handles all edge cases. | ||||
|    *  | ||||
|    * @param buffer The buffer containing TLS data | ||||
|    * @param connectionInfo Connection tracking information | ||||
|    * @param logger Optional logging function | ||||
|    * @param cachedSni Optional previously cached SNI value | ||||
|    * @returns The extracted server name or undefined | ||||
|    */ | ||||
|   public static processTlsPacket( | ||||
|     buffer: Buffer, | ||||
|     connectionInfo: ConnectionInfo, | ||||
|     logger?: LoggerFunction, | ||||
|     cachedSni?: string | ||||
|   ): string | undefined { | ||||
|     const log = logger || (() => {}); | ||||
|      | ||||
|     // Add timestamp if not provided | ||||
|     if (!connectionInfo.timestamp) { | ||||
|       connectionInfo.timestamp = Date.now(); | ||||
|     } | ||||
|      | ||||
|     // Check if this is a TLS handshake or application data | ||||
|     if (!TlsUtils.isTlsHandshake(buffer) && !TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       log('Not a TLS handshake or application data packet'); | ||||
|       return undefined; | ||||
|     } | ||||
|      | ||||
|     // Create connection ID for tracking | ||||
|     const connectionId = TlsUtils.createConnectionId(connectionInfo); | ||||
|     log(`Processing TLS packet for connection ${connectionId}, buffer length: ${buffer.length}`); | ||||
|      | ||||
|     // Handle application data with cached SNI (for connection racing) | ||||
|     if (TlsUtils.isTlsApplicationData(buffer)) { | ||||
|       // If explicit cachedSni was provided, use it | ||||
|       if (cachedSni) { | ||||
|         log(`Using provided cached SNI for application data: ${cachedSni}`); | ||||
|         return cachedSni; | ||||
|       } | ||||
|        | ||||
|       log('Application data packet without cached SNI, cannot determine hostname'); | ||||
|       return undefined; | ||||
|     } | ||||
|      | ||||
|     // Enhanced session resumption detection | ||||
|     if (TlsUtils.isClientHello(buffer)) { | ||||
|       const resumptionInfo = ClientHelloParser.hasSessionResumption(buffer, logger); | ||||
|        | ||||
|       if (resumptionInfo.isResumption) { | ||||
|         log(`Session resumption detected in TLS packet`); | ||||
|          | ||||
|         // Always try standard SNI extraction first | ||||
|         const standardSni = this.extractSNI(buffer, logger); | ||||
|         if (standardSni) { | ||||
|           log(`Found standard SNI in session resumption: ${standardSni}`); | ||||
|           return standardSni; | ||||
|         } | ||||
|          | ||||
|         // Enhanced session resumption SNI extraction | ||||
|         // Try extracting from PSK identity | ||||
|         const pskSni = this.extractSNIFromPSKExtension(buffer, logger); | ||||
|         if (pskSni) { | ||||
|           log(`Extracted SNI from PSK extension: ${pskSni}`); | ||||
|           return pskSni; | ||||
|         } | ||||
|          | ||||
|         log(`Session resumption without extractable SNI`); | ||||
|       } | ||||
|     } | ||||
|      | ||||
|     // For handshake messages, try the full extraction process | ||||
|     const sni = this.extractSNIWithResumptionSupport(buffer, connectionInfo, logger); | ||||
|      | ||||
|     if (sni) { | ||||
|       log(`Successfully extracted SNI: ${sni}`); | ||||
|       return sni; | ||||
|     } | ||||
|      | ||||
|     // If we couldn't extract an SNI, check if this is a valid ClientHello | ||||
|     if (TlsUtils.isClientHello(buffer)) { | ||||
|       log('Valid ClientHello detected, but no SNI extracted - might need more data'); | ||||
|     } | ||||
|      | ||||
|     return undefined; | ||||
|   } | ||||
| } | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user