From 2a0fcd6e350dee7677add0a8c40e30ef3b97406b Mon Sep 17 00:00:00 2001 From: Aleksei K Date: Thu, 28 May 2026 16:11:25 +0300 Subject: [PATCH] Align ServerHello cipher and opaque ALPN behavior in TLS-F --- src/protocol/tests/tls_security_tests.rs | 72 ++++- src/protocol/tls.rs | 167 ++++++++++-- src/proxy/handshake.rs | 13 +- src/tls_front/emulator.rs | 252 ++++++++++++++---- ...mulator_profile_fidelity_security_tests.rs | 3 + .../tests/emulator_security_tests.rs | 9 +- 6 files changed, 427 insertions(+), 89 deletions(-) diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 1edece4..b821fc6 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1385,6 +1385,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { false, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, Some(b"h2".to_vec()), 0, @@ -1509,12 +1510,22 @@ fn test_validate_tls_handshake_format() { } fn build_client_hello_with_exts(exts: Vec<(u16, Vec)>, host: &str) -> Vec { + build_client_hello_with_ciphers_and_exts(&[[0x13, 0x01]], exts, host) +} + +fn build_client_hello_with_ciphers_and_exts( + cipher_suites: &[[u8; 2]], + exts: Vec<(u16, Vec)>, + host: &str, +) -> Vec { let mut body = Vec::new(); body.extend_from_slice(&TLS_VERSION); body.extend_from_slice(&[0u8; 32]); body.push(0); - body.extend_from_slice(&2u16.to_be_bytes()); - body.extend_from_slice(&[0x13, 0x01]); + body.extend_from_slice(&((cipher_suites.len() * 2) as u16).to_be_bytes()); + for suite in cipher_suites { + body.extend_from_slice(suite); + } body.push(1); body.push(0); @@ -1654,6 +1665,51 @@ fn detect_client_hello_tls_version_rejects_malformed_supported_versions() { assert!(detect_client_hello_tls_version(&ch).is_none()); } +#[test] +fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() { + let ch = build_client_hello_with_ciphers_and_exts( + &[[0x13, 0x01], [0x13, 0x03]], + Vec::new(), + "example.com", + ); + assert_eq!( + select_server_hello_cipher_suite(&ch, [0x13, 0x03]), + [0x13, 0x03] + ); +} + +#[test] +fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() { + let ch = build_client_hello_with_ciphers_and_exts( + &[[0xc0, 0x2f], [0x13, 0x03]], + Vec::new(), + "example.com", + ); + assert_eq!( + select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]), + [0x13, 0x03] + ); +} + +#[test] +fn select_server_hello_cipher_suite_falls_back_to_offered_tls13_suite() { + let ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com"); + assert_eq!( + select_server_hello_cipher_suite(&ch, [0x13, 0x01]), + [0x13, 0x03] + ); +} + +#[test] +fn select_server_hello_cipher_suite_keeps_preferred_for_malformed_clienthello() { + let mut ch = build_client_hello_with_ciphers_and_exts(&[[0x13, 0x03]], Vec::new(), "example.com"); + ch.truncate(12); + assert_eq!( + select_server_hello_cipher_suite(&ch, [0x13, 0x01]), + [0x13, 0x01] + ); +} + #[test] fn extract_sni_rejects_zero_length_host_name() { let mut sni_ext = Vec::new(); @@ -2179,7 +2235,7 @@ fn light_fuzz_boot_time_timestamp_matrix_with_short_replay_window_obeys_boot_cap } #[test] -fn server_hello_application_data_contains_alpn_marker_when_selected() { +fn server_hello_application_data_omits_alpn_marker_when_selected() { let secret = b"alpn_marker_test"; let client_digest = [0x55u8; TLS_DIGEST_LEN]; let session_id = vec![0xAB; 32]; @@ -2206,8 +2262,8 @@ fn server_hello_application_data_contains_alpn_marker_when_selected() { assert!( app_payload .windows(expected.len()) - .any(|window| window == expected), - "first application payload must carry ALPN marker for selected protocol" + .all(|window| window != expected), + "first application payload must not expose plaintext ALPN marker bytes" ); } @@ -2303,14 +2359,14 @@ fn server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { } #[test] -fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() { +fn server_hello_omits_alpn_marker_even_when_it_would_fit_fake_cert_len() { let secret = b"alpn_exact_fit_test"; let client_digest = [0x58u8; TLS_DIGEST_LEN]; let session_id = vec![0xA5; 32]; let rng = crate::crypto::SecureRandom::new(); let proto = vec![b'z'; 57]; - // marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64 + // marker_len = 4 + (2 + (1 + proto_len)) = 7 + proto_len = 64. let response = build_server_hello( secret, &client_digest, @@ -2336,7 +2392,7 @@ fn server_hello_embeds_full_alpn_marker_when_it_exactly_fits_fake_cert_len() { expected_marker.extend_from_slice(&proto); assert_eq!(app_payload.len(), expected_marker.len()); - assert_eq!(app_payload, expected_marker.as_slice()); + assert_ne!(app_payload, expected_marker.as_slice()); } #[test] diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 5c18135..19cb3aa 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -105,6 +105,8 @@ mod extension_type { /// TLS Cipher Suites mod cipher_suite { pub const TLS_AES_128_GCM_SHA256: [u8; 2] = [0x13, 0x01]; + pub const TLS_AES_256_GCM_SHA384: [u8; 2] = [0x13, 0x02]; + pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03]; } /// TLS Named Curves @@ -241,6 +243,13 @@ impl ServerHelloBuilder { self } + fn with_cipher_suite(mut self, cipher_suite: [u8; 2]) -> Self { + if cipher_suite != [0, 0] { + self.cipher_suite = cipher_suite; + } + self + } + /// Build ServerHello message (without record header) fn build_message(&self) -> Vec { let Ok(session_id_len) = u8::try_from(self.session_id.len()) else { @@ -520,6 +529,33 @@ pub fn build_server_hello( rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, +) -> Vec { + build_server_hello_with_cipher( + secret, + client_digest, + session_id, + fake_cert_len, + rng, + cipher_suite::TLS_AES_128_GCM_SHA256, + alpn, + new_session_tickets, + ) +} + +/// Build TLS ServerHello response with a caller-selected cipher suite. +/// +/// The caller is responsible for selecting a suite that is compatible with the +/// already-authenticated ClientHello. Keeping the selection outside this +/// builder avoids extra ClientHello parsing in the response construction path. +pub(crate) fn build_server_hello_with_cipher( + secret: &[u8], + client_digest: &[u8; TLS_DIGEST_LEN], + session_id: &[u8], + fake_cert_len: usize, + rng: &SecureRandom, + selected_cipher_suite: [u8; 2], + alpn: Option>, + new_session_tickets: u8, ) -> Vec { const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; @@ -528,6 +564,7 @@ pub fn build_server_hello( // Build ServerHello let server_hello = ServerHelloBuilder::new(session_id.to_vec()) + .with_cipher_suite(selected_cipher_suite) .with_x25519_key(&x25519_key) .with_tls13_version() .build_record(); @@ -538,28 +575,14 @@ pub fn build_server_hello( TLS_VERSION[0], TLS_VERSION[1], 0x00, - 0x01, // length = 1 - 0x01, // CCS byte + 0x01, + 0x01, ]; // Build first encrypted flight mimic as opaque ApplicationData bytes. - // Embed a compact EncryptedExtensions-like ALPN block when selected. + // ALPN belongs inside encrypted EncryptedExtensions in real TLS 1.3. let mut fake_cert = Vec::with_capacity(fake_cert_len); - if let Some(proto) = alpn - .as_ref() - .filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize) - { - let proto_list_len = 1usize + proto.len(); - let ext_data_len = 2usize + proto_list_len; - let marker_len = 4usize + ext_data_len; - if marker_len <= fake_cert_len { - fake_cert.extend_from_slice(&0x0010u16.to_be_bytes()); - fake_cert.extend_from_slice(&(ext_data_len as u16).to_be_bytes()); - fake_cert.extend_from_slice(&(proto_list_len as u16).to_be_bytes()); - fake_cert.push(proto.len() as u8); - fake_cert.extend_from_slice(proto); - } - } + let _ = alpn; if fake_cert.len() < fake_cert_len { fake_cert.extend_from_slice(&rng.bytes(fake_cert_len - fake_cert.len())); } else if fake_cert.len() > fake_cert_len { @@ -580,7 +603,7 @@ pub fn build_server_hello( let ticket_count = new_session_tickets.min(4); if ticket_count > 0 { for _ in 0..ticket_count { - let ticket_len: usize = rng.range(48) + 48; // 48-95 bytes + let ticket_len: usize = rng.range(48) + 48; let mut record = Vec::with_capacity(5 + ticket_len); record.push(TLS_RECORD_APPLICATION); record.extend_from_slice(&TLS_VERSION); @@ -927,6 +950,112 @@ pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option Option<(usize, usize)> { + if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE { + return None; + } + + let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize; + let record_end = 5usize.checked_add(record_len)?; + if record_end > handshake.len() { + return None; + } + + let mut pos = 5; + if handshake.get(pos) != Some(&0x01) { + return None; + } + pos += 1; + + if pos + 3 > record_end { + return None; + } + let handshake_len = ((handshake[pos] as usize) << 16) + | ((handshake[pos + 1] as usize) << 8) + | handshake[pos + 2] as usize; + pos += 3; + let handshake_end = pos.checked_add(handshake_len)?; + if handshake_end > record_end { + return None; + } + + if pos + 2 + 32 > handshake_end { + return None; + } + pos += 2 + 32; + + let session_id_len = *handshake.get(pos)? as usize; + pos = pos.checked_add(1)?.checked_add(session_id_len)?; + if pos + 2 > handshake_end { + return None; + } + + let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + if cipher_len == 0 || cipher_len % 2 != 0 { + return None; + } + pos += 2; + let cipher_end = pos.checked_add(cipher_len)?; + if cipher_end > handshake_end { + return None; + } + + Some((pos, cipher_end)) +} + +fn client_hello_offers_cipher_suite( + handshake: &[u8], + range: (usize, usize), + suite: [u8; 2], +) -> bool { + let mut pos = range.0; + while pos + 1 < range.1 { + if handshake[pos] == suite[0] && handshake[pos + 1] == suite[1] { + return true; + } + pos += 2; + } + false +} + +fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool { + suite == cipher_suite::TLS_AES_128_GCM_SHA256 + || suite == cipher_suite::TLS_AES_256_GCM_SHA384 + || suite == cipher_suite::TLS_CHACHA20_POLY1305_SHA256 +} + +/// Select the ServerHello cipher suite from the already-received ClientHello. +/// +/// This is intentionally a borrowed, zero-allocation scan. It runs only for an +/// authenticated success response and keeps malformed or unexpected ClientHello +/// shapes on the previous fallback behavior. +pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8; 2]) -> [u8; 2] { + let preferred = if is_tls13_cipher_suite(preferred) { + preferred + } else { + cipher_suite::TLS_AES_128_GCM_SHA256 + }; + let Some(range) = client_hello_cipher_suites_range(handshake) else { + return preferred; + }; + + if client_hello_offers_cipher_suite(handshake, range, preferred) { + return preferred; + } + + for fallback in [ + cipher_suite::TLS_AES_128_GCM_SHA256, + cipher_suite::TLS_CHACHA20_POLY1305_SHA256, + cipher_suite::TLS_AES_256_GCM_SHA384, + ] { + if client_hello_offers_cipher_suite(handshake, range, fallback) { + return fallback; + } + } + + preferred +} + /// Check if bytes look like a TLS ClientHello pub fn is_tls_handshake(first_bytes: &[u8]) -> bool { if first_bytes.len() < 3 { diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 15b04de..b9342c3 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1504,6 +1504,13 @@ where let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; let response = if let Some((cached_entry, use_full_cert_payload)) = cached { + let preferred_cipher_suite = if cached_entry.server_hello_template.cipher_suite == [0, 0] { + [0x13, 0x01] + } else { + cached_entry.server_hello_template.cipher_suite + }; + let selected_cipher_suite = + tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite); emulator::build_emulated_server_hello( &validated_secret, &validation_digest, @@ -1512,17 +1519,21 @@ where use_full_cert_payload, config.censorship.serverhello_compact, client_tls_version, + selected_cipher_suite, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) } else { - tls::build_server_hello( + let selected_cipher_suite = + tls::select_server_hello_cipher_suite(handshake, [0x13, 0x01]); + tls::build_server_hello_with_cipher( &validated_secret, &validation_digest, validation_session_id_slice, config.censorship.fake_cert_len, rng, + selected_cipher_suite, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 609aeaf..98f0cb9 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -8,12 +8,17 @@ use crate::protocol::constants::{ use crate::protocol::tls::{ ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key, }; -use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; +use crate::tls_front::types::{ + CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource, +}; use crc32fast::Hasher; const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; const MAX_TICKET_RECORDS: usize = 4; +const EXT_SUPPORTED_VERSIONS: u16 = 0x002b; +const EXT_KEY_SHARE: u16 = 0x0033; +const EXT_ALPN: u16 = 0x0010; fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { sizes @@ -185,6 +190,77 @@ fn hash_compact_cert_info_payload(cert_payload: Vec) -> Option> { Some(hashed) } +fn push_supported_versions_extension(extensions: &mut Vec) { + extensions.extend_from_slice(&EXT_SUPPORTED_VERSIONS.to_be_bytes()); + extensions.extend_from_slice(&(2u16).to_be_bytes()); + extensions.extend_from_slice(&0x0304u16.to_be_bytes()); +} + +fn push_key_share_extension(extensions: &mut Vec, rng: &SecureRandom) { + let key = gen_fake_x25519_key(rng); + extensions.extend_from_slice(&EXT_KEY_SHARE.to_be_bytes()); + extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes()); + extensions.extend_from_slice(&0x001du16.to_be_bytes()); + extensions.extend_from_slice(&(32u16).to_be_bytes()); + extensions.extend_from_slice(&key); +} + +fn replay_profiled_server_hello_extension( + ext: &TlsExtension, + extensions: &mut Vec, + rng: &SecureRandom, + saw_supported_versions: &mut bool, + saw_key_share: &mut bool, +) { + match ext.ext_type { + EXT_SUPPORTED_VERSIONS if !*saw_supported_versions => { + push_supported_versions_extension(extensions); + *saw_supported_versions = true; + } + EXT_KEY_SHARE if !*saw_key_share => { + push_key_share_extension(extensions, rng); + *saw_key_share = true; + } + EXT_ALPN => {} + _ => {} + } +} + +fn build_profiled_server_hello_extensions( + cached: &CachedTlsData, + rng: &SecureRandom, +) -> Vec { + let capacity = cached + .server_hello_template + .extensions + .iter() + .map(|ext| 4 + ext.data.len()) + .sum::() + .max(44); + let mut extensions = Vec::with_capacity(capacity); + let mut saw_supported_versions = false; + let mut saw_key_share = false; + + for ext in &cached.server_hello_template.extensions { + replay_profiled_server_hello_extension( + ext, + &mut extensions, + rng, + &mut saw_supported_versions, + &mut saw_key_share, + ); + } + + if !saw_key_share { + push_key_share_extension(&mut extensions, rng); + } + if !saw_supported_versions { + push_supported_versions_extension(&mut extensions); + } + + extensions +} + /// Build a ServerHello + CCS + ApplicationData sequence using cached TLS metadata. pub fn build_emulated_server_hello( secret: &[u8], @@ -194,39 +270,28 @@ pub fn build_emulated_server_hello( use_full_cert_payload: bool, serverhello_compact: bool, client_tls_version: ClientHelloTlsVersion, + selected_cipher_suite: [u8; 2], rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, ) -> Vec { // --- ServerHello --- - let mut extensions = Vec::new(); - let key = gen_fake_x25519_key(rng); - extensions.extend_from_slice(&0x0033u16.to_be_bytes()); - extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes()); - extensions.extend_from_slice(&0x001du16.to_be_bytes()); - extensions.extend_from_slice(&(32u16).to_be_bytes()); - extensions.extend_from_slice(&key); - extensions.extend_from_slice(&0x002bu16.to_be_bytes()); - extensions.extend_from_slice(&(2u16).to_be_bytes()); - extensions.extend_from_slice(&0x0304u16.to_be_bytes()); + let extensions = build_profiled_server_hello_extensions(cached, rng); let extensions_len = extensions.len() as u16; - let body_len = 2 + // version - 32 + // random - 1 + session_id.len() + // session id - 2 + // cipher - 1 + // compression - 2 + extensions.len(); // extensions + let body_len = 2 + 32 + 1 + session_id.len() + 2 + 1 + 2 + extensions.len(); let mut message = Vec::with_capacity(4 + body_len); - message.push(0x02); // ServerHello + message.push(0x02); let len_bytes = (body_len as u32).to_be_bytes(); message.extend_from_slice(&len_bytes[1..4]); - message.extend_from_slice(&cached.server_hello_template.version); // 0x0303 - message.extend_from_slice(&[0u8; 32]); // random placeholder + message.extend_from_slice(&cached.server_hello_template.version); + message.extend_from_slice(&[0u8; 32]); message.push(session_id.len() as u8); message.extend_from_slice(session_id); - let cipher = if cached.server_hello_template.cipher_suite == [0, 0] { + let cipher = if selected_cipher_suite != [0, 0] { + selected_cipher_suite + } else if cached.server_hello_template.cipher_suite == [0, 0] { [0x13, 0x01] } else { cached.server_hello_template.cipher_suite @@ -303,21 +368,10 @@ pub fn build_emulated_server_hello( } let mut app_data = Vec::new(); - let alpn_marker = alpn - .as_ref() - .filter(|p| !p.is_empty() && p.len() <= u8::MAX as usize) - .map(|proto| { - let proto_list_len = 1usize + proto.len(); - let ext_data_len = 2usize + proto_list_len; - let mut marker = Vec::with_capacity(4 + ext_data_len); - marker.extend_from_slice(&0x0010u16.to_be_bytes()); - marker.extend_from_slice(&(ext_data_len as u16).to_be_bytes()); - marker.extend_from_slice(&(proto_list_len as u16).to_be_bytes()); - marker.push(proto.len() as u8); - marker.extend_from_slice(proto); - marker - }); - for (idx, size) in sizes.into_iter().enumerate() { + // ALPN selection is encrypted inside EncryptedExtensions in real TLS 1.3. + // Keeping the FakeTLS record body opaque avoids a stable plaintext marker. + let _ = alpn; + for size in sizes { let mut rec = Vec::with_capacity(5 + size); rec.push(TLS_RECORD_APPLICATION); rec.extend_from_slice(&TLS_VERSION); @@ -334,31 +388,18 @@ pub fn build_emulated_server_hello( if body_len > copy_len { rec.extend_from_slice(&rng.bytes(body_len - copy_len)); } - rec.push(0x16); // inner content type marker (handshake) - rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag + rec.push(0x16); + rec.extend_from_slice(&rng.bytes(16)); } else { rec.extend_from_slice(&rng.bytes(size)); } } else if size > 17 { let body_len = size - 17; let mut body = Vec::with_capacity(body_len); - if idx == 0 - && let Some(marker) = &alpn_marker - { - if marker.len() <= body_len { - body.extend_from_slice(marker); - if body_len > marker.len() { - body.extend_from_slice(&rng.bytes(body_len - marker.len())); - } - } else { - body.extend_from_slice(&rng.bytes(body_len)); - } - } else { - body.extend_from_slice(&rng.bytes(body_len)); - } + body.extend_from_slice(&rng.bytes(body_len)); rec.extend_from_slice(&body); - rec.push(0x16); // inner content type marker (handshake) - rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag + rec.push(0x16); + rec.extend_from_slice(&rng.bytes(16)); } else { rec.extend_from_slice(&rng.bytes(size)); } @@ -408,7 +449,8 @@ mod tests { use std::time::SystemTime; use crate::tls_front::types::{ - CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, + CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsExtension, + TlsProfileSource, }; use super::{ @@ -432,6 +474,38 @@ mod tests { &response[app_start + 5..app_start + 5 + app_len] } + fn server_hello_cipher_suite(response: &[u8]) -> [u8; 2] { + let mut pos = 5 + 4 + 2 + 32; + let session_id_len = response[pos] as usize; + pos += 1 + session_id_len; + [response[pos], response[pos + 1]] + } + + fn server_hello_extension_types(response: &[u8]) -> Vec { + let record_len = u16::from_be_bytes([response[3], response[4]]) as usize; + let handshake_end = 5 + record_len; + let mut pos = 5 + 4 + 2 + 32; + let session_id_len = response[pos] as usize; + pos += 1 + session_id_len + 2 + 1; + let extensions_len = u16::from_be_bytes([response[pos], response[pos + 1]]) as usize; + pos += 2; + let extensions_end = (pos + extensions_len).min(handshake_end); + let mut out = Vec::new(); + + while pos + 4 <= extensions_end { + let ext_type = u16::from_be_bytes([response[pos], response[pos + 1]]); + let ext_len = u16::from_be_bytes([response[pos + 2], response[pos + 3]]) as usize; + pos += 4; + if pos + ext_len > extensions_end { + break; + } + out.push(ext_type); + pos += ext_len; + } + + out + } + fn make_cached(cert_payload: Option) -> CachedTlsData { CachedTlsData { server_hello_template: ParsedServerHello { @@ -468,6 +542,7 @@ mod tests { true, true, ClientHelloTlsVersion::Tls12, + [0x13, 0x01], &rng, None, 0, @@ -484,6 +559,62 @@ mod tests { assert!(payload.starts_with(&cert_msg)); } + #[test] + fn test_build_emulated_server_hello_uses_selected_cipher_suite() { + let cached = make_cached(None); + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x10; 32], + &[0x20; 16], + &cached, + false, + true, + ClientHelloTlsVersion::Tls13, + [0x13, 0x03], + &rng, + None, + 0, + ); + + assert_eq!(server_hello_cipher_suite(&response), [0x13, 0x03]); + } + + #[test] + fn test_build_emulated_server_hello_replays_profiled_safe_extension_order() { + let mut cached = make_cached(None); + cached.server_hello_template.extensions = vec![ + TlsExtension { + ext_type: 0x002b, + data: vec![0x03, 0x04], + }, + TlsExtension { + ext_type: 0x0010, + data: vec![0x00, 0x03, 0x02, b'h', b'2'], + }, + TlsExtension { + ext_type: 0x0033, + data: vec![0; 36], + }, + ]; + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x21; 32], + &[0x22; 16], + &cached, + false, + true, + ClientHelloTlsVersion::Tls13, + [0x13, 0x01], + &rng, + Some(b"h2".to_vec()), + 0, + ); + + assert_eq!(server_hello_extension_types(&response), vec![0x002b, 0x0033]); + } + #[test] fn test_build_emulated_server_hello_random_fallback_when_no_cert_payload() { let cached = make_cached(None); @@ -496,6 +627,7 @@ mod tests { true, true, ClientHelloTlsVersion::Tls12, + [0x13, 0x01], &rng, None, 0, @@ -530,6 +662,7 @@ mod tests { false, true, ClientHelloTlsVersion::Tls12, + [0x13, 0x01], &rng, None, 0, @@ -570,6 +703,7 @@ mod tests { true, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, None, 0, @@ -583,7 +717,7 @@ mod tests { } #[test] - fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() { + fn test_build_emulated_server_hello_keeps_alpn_marker_out_of_random_payload() { let mut cached = make_cached(None); cached.cert_info = Some(crate::tls_front::types::ParsedCertificateInfo { not_after_unix: Some(1_900_000_000), @@ -602,6 +736,7 @@ mod tests { false, false, ClientHelloTlsVersion::Tls12, + [0x13, 0x01], &rng, Some(b"h2".to_vec()), 0, @@ -610,8 +745,8 @@ mod tests { let payload = first_app_data_payload(&response); let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2']; assert!( - payload.starts_with(&expected_alpn_marker), - "when compact mode is disabled and no full cert payload exists, the random/alpn path must be used" + !payload.starts_with(&expected_alpn_marker), + "random fallback payload must not expose plaintext ALPN marker bytes" ); } @@ -633,6 +768,7 @@ mod tests { false, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, None, 0, diff --git a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs index ba0e137..3fbba07 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -65,6 +65,7 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit false, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, None, 0, @@ -89,6 +90,7 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { false, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, None, 0, @@ -111,6 +113,7 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { false, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index ce493bb..c3ef96d 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -58,6 +58,7 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { true, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, Some(oversized_alpn), 0, @@ -84,7 +85,7 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { } #[test] -fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() { +fn emulated_server_hello_keeps_alpn_marker_out_of_appdata() { let cached = make_cached(None); let rng = SecureRandom::new(); @@ -96,6 +97,7 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() { true, true, ClientHelloTlsVersion::Tls13, + [0x13, 0x01], &rng, Some(b"h2".to_vec()), 0, @@ -104,8 +106,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() { let payload = first_app_data_payload(&response); let expected = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2']; assert!( - payload.starts_with(&expected), - "when body has enough capacity, emulated first application record must include full ALPN marker" + !payload.starts_with(&expected), + "emulated ApplicationData must not expose plaintext ALPN marker bytes" ); } @@ -126,6 +128,7 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { true, true, ClientHelloTlsVersion::Tls12, + [0x13, 0x01], &rng, Some(b"h2".to_vec()), 0,