From cd2bb9c8cdab8c0e02674e06ecfacc7bd38ef929 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:13:17 +0300 Subject: [PATCH 01/10] Alles muss man selber machen Co-Authored-By: Mikhail I. Izmestev <355023+izmmisha@users.noreply.github.com> Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> Co-Authored-By: Dietmar Schreiber <376736+dginorg@users.noreply.github.com> --- src/protocol/tests/tls_security_tests.rs | 189 +++++++++++++- src/protocol/tls.rs | 240 ++++++++++++++++-- src/proxy/handshake.rs | 3 + src/tls_front/emulator.rs | 64 ++++- ...mulator_profile_fidelity_security_tests.rs | 5 +- .../tests/emulator_security_tests.rs | 5 +- 6 files changed, 464 insertions(+), 42 deletions(-) diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index f8b1b2b..c3c934d 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1239,6 +1239,18 @@ fn test_gen_fake_x25519_key() { assert_ne!(key1, key2); } +#[test] +fn test_gen_fake_x25519mlkem768_server_key_share_shape() { + let rng = crate::crypto::SecureRandom::new(); + let key_share = gen_fake_x25519mlkem768_server_key_share(&rng); + + assert_eq!(key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN); + assert!( + key_share.iter().any(|byte| *byte != 0), + "hybrid ServerHello key_share must not collapse to all-zero bytes" + ); +} + #[test] fn test_fake_x25519_key_is_nonzero_and_varies() { let rng = crate::crypto::SecureRandom::new(); @@ -1325,6 +1337,65 @@ fn server_hello_extension_types(record: &[u8]) -> Vec { out } +fn server_hello_key_share(record: &[u8]) -> Option<(u16, usize)> { + if record.len() < 9 || record[0] != TLS_RECORD_HANDSHAKE || record[5] != 0x02 { + return None; + } + + let record_len = u16::from_be_bytes([record[3], record[4]]) as usize; + if record.len() < 5 + record_len { + return None; + } + + let hs_len = u32::from_be_bytes([0, record[6], record[7], record[8]]) as usize; + let hs_start = 5; + let hs_end = hs_start + 4 + hs_len; + if hs_end > record.len() { + return None; + } + + let mut pos = hs_start + 4 + 2 + 32; + if pos >= hs_end { + return None; + } + let sid_len = record[pos] as usize; + pos += 1 + sid_len; + if pos + 2 + 1 + 2 > hs_end { + return None; + } + + pos += 2 + 1; + let ext_len = u16::from_be_bytes([record[pos], record[pos + 1]]) as usize; + pos += 2; + let ext_end = pos + ext_len; + if ext_end > hs_end { + return None; + } + + while pos + 4 <= ext_end { + let etype = u16::from_be_bytes([record[pos], record[pos + 1]]); + let elen = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize; + pos += 4; + if pos + elen > ext_end { + return None; + } + if etype == extension_type::KEY_SHARE { + if elen < 4 { + return None; + } + let group = u16::from_be_bytes([record[pos], record[pos + 1]]); + let key_exchange_len = u16::from_be_bytes([record[pos + 2], record[pos + 3]]) as usize; + if 4 + key_exchange_len != elen { + return None; + } + return Some((group, key_exchange_len)); + } + pos += elen; + } + + None +} + #[test] fn build_server_hello_never_places_alpn_in_server_hello_extensions() { let secret = b"alpn_sh_forbidden"; @@ -1386,6 +1457,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -1402,7 +1474,7 @@ fn test_tls_extension_builder() { let key = [0x42u8; 32]; let mut builder = TlsExtensionBuilder::new(); - builder.add_key_share(&key); + builder.add_key_share(TLS_NAMED_GROUP_X25519, &key); builder.add_supported_versions(0x0304); let result = builder.build(); @@ -1418,7 +1490,7 @@ fn test_server_hello_builder() { let key = [0x55u8; 32]; let builder = ServerHelloBuilder::new(session_id.clone()) - .with_x25519_key(&key) + .with_key_share(TLS_NAMED_GROUP_X25519, &key) .with_tls13_version(); let record = builder.build_record(); @@ -1452,6 +1524,39 @@ fn test_build_server_hello_structure() { let app_start = ccs_start + ccs_len; assert!(response.len() > app_start + 5); assert_eq!(response[app_start], TLS_RECORD_APPLICATION); + + assert_eq!( + server_hello_key_share(&response), + Some(( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_SERVER_KEY_SHARE_LEN + )) + ); +} + +#[test] +fn test_build_server_hello_with_cipher_can_keep_x25519_key_share() { + let secret = b"test secret"; + let client_digest = [0x42u8; 32]; + let session_id = vec![0xAA; 32]; + + let rng = crate::crypto::SecureRandom::new(); + let response = build_server_hello_with_cipher( + secret, + &client_digest, + &session_id, + 2048, + &rng, + [0x13, 0x01], + TLS_NAMED_GROUP_X25519, + None, + 0, + ); + + assert_eq!( + server_hello_key_share(&response), + Some((TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)) + ); } #[test] @@ -1477,7 +1582,7 @@ fn test_server_hello_extensions_length() { let key = [0x55u8; 32]; let builder = ServerHelloBuilder::new(session_id) - .with_x25519_key(&key) + .with_key_share(TLS_NAMED_GROUP_X25519, &key) .with_tls13_version(); let record = builder.build_record(); @@ -1513,6 +1618,23 @@ fn build_client_hello_with_exts(exts: Vec<(u16, Vec)>, host: &str) -> Vec Vec { + let mut shares = Vec::new(); + for (group, key_exchange_len) in entries { + assert!(*key_exchange_len <= u16::MAX as usize); + shares.extend_from_slice(&group.to_be_bytes()); + shares.extend_from_slice(&(*key_exchange_len as u16).to_be_bytes()); + let start = shares.len(); + shares.resize(start + *key_exchange_len, 0x42); + } + + assert!(shares.len() <= u16::MAX as usize); + let mut extension = Vec::new(); + extension.extend_from_slice(&(shares.len() as u16).to_be_bytes()); + extension.extend_from_slice(&shares); + extension +} + fn build_client_hello_with_ciphers_and_exts( cipher_suites: &[[u8; 2]], exts: Vec<(u16, Vec)>, @@ -1711,6 +1833,67 @@ fn select_server_hello_cipher_suite_keeps_preferred_for_malformed_clienthello() ); } +#[test] +fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offered() { + let key_share = client_key_share_extension(&[ + (0x0a0a, 1), + ( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ), + (TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN), + ]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + + assert_eq!( + select_server_hello_key_share_group(&ch), + TLS_NAMED_GROUP_X25519MLKEM768 + ); +} + +#[test] +fn select_server_hello_key_share_group_falls_back_without_hybrid_share() { + let key_share = + client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + + assert_eq!( + select_server_hello_key_share_group(&ch), + TLS_NAMED_GROUP_X25519 + ); +} + +#[test] +fn select_server_hello_key_share_group_falls_back_for_malformed_hybrid_len() { + let key_share = client_key_share_extension(&[( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN - 1, + )]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + + assert_eq!( + select_server_hello_key_share_group(&ch), + TLS_NAMED_GROUP_X25519 + ); +} + +#[test] +fn select_server_hello_key_share_group_falls_back_for_malformed_key_share_tail() { + let mut key_share = client_key_share_extension(&[( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + )]); + let shares_len = u16::from_be_bytes([key_share[0], key_share[1]]) + 1; + key_share[0..2].copy_from_slice(&shares_len.to_be_bytes()); + key_share.push(0); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + + assert_eq!( + select_server_hello_key_share_group(&ch), + TLS_NAMED_GROUP_X25519 + ); +} + #[test] fn extract_sni_rejects_zero_length_host_name() { let mut sni_ext = Vec::new(); diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 19cb3aa..518e2ba 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -109,11 +109,22 @@ mod cipher_suite { pub const TLS_CHACHA20_POLY1305_SHA256: [u8; 2] = [0x13, 0x03]; } -/// TLS Named Curves +/// TLS named groups used in KeyShare extensions. mod named_curve { pub const X25519: u16 = 0x001d; + pub const X25519MLKEM768: u16 = 0x11ec; } +/// TLS X25519 named group. +pub(crate) const TLS_NAMED_GROUP_X25519: u16 = named_curve::X25519; +/// TLS X25519MLKEM768 named group. +pub(crate) const TLS_NAMED_GROUP_X25519MLKEM768: u16 = named_curve::X25519MLKEM768; + +const X25519_KEY_SHARE_LEN: usize = 32; +const X25519MLKEM768_CLIENT_KEY_SHARE_LEN: usize = 1216; +const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120; +const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088; + // ============= TLS Validation Result ============= /// Result of validating TLS handshake @@ -144,26 +155,28 @@ impl TlsExtensionBuilder { } } - /// Add Key Share extension with X25519 key - fn add_key_share(&mut self, public_key: &[u8; 32]) -> &mut Self { + /// Add KeyShare extension with the selected named group. + fn add_key_share(&mut self, group: u16, key_exchange: &[u8]) -> &mut Self { + let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else { + return self; + }; + let Some(entry_len) = key_exchange.len().checked_add(4) else { + return self; + }; + let Ok(entry_len) = u16::try_from(entry_len) else { + return self; + }; + // Extension type: key_share (0x0033) self.extensions .extend_from_slice(&extension_type::KEY_SHARE.to_be_bytes()); - // Key share entry: curve (2) + key_len (2) + key (32) = 36 bytes - // Extension data length - let entry_len: u16 = 2 + 2 + 32; // curve + length + key + // ServerHello key_share data is exactly one KeyShareEntry. self.extensions.extend_from_slice(&entry_len.to_be_bytes()); - - // Named curve: x25519 + self.extensions.extend_from_slice(&group.to_be_bytes()); self.extensions - .extend_from_slice(&named_curve::X25519.to_be_bytes()); - - // Key length - self.extensions.extend_from_slice(&(32u16).to_be_bytes()); - - // Key data - self.extensions.extend_from_slice(public_key); + .extend_from_slice(&key_exchange_len.to_be_bytes()); + self.extensions.extend_from_slice(key_exchange); self } @@ -232,8 +245,8 @@ impl ServerHelloBuilder { } } - fn with_x25519_key(mut self, key: &[u8; 32]) -> Self { - self.extensions.add_key_share(key); + fn with_key_share(mut self, group: u16, key_exchange: &[u8]) -> Self { + self.extensions.add_key_share(group, key_exchange); self } @@ -508,11 +521,22 @@ fn validate_tls_handshake_at_time_with_boot_cap( /// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint, /// yielding distribution-consistent public keys for anti-fingerprinting. pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] { - let mut scalar = [0u8; 32]; - scalar.copy_from_slice(&rng.bytes(32)); + let mut scalar = [0u8; X25519_KEY_SHARE_LEN]; + scalar.copy_from_slice(&rng.bytes(X25519_KEY_SHARE_LEN)); x25519(scalar, X25519_BASEPOINT_BYTES) } +/// Generate a fake X25519MLKEM768 ServerHello key_share payload. +pub(crate) fn gen_fake_x25519mlkem768_server_key_share(rng: &SecureRandom) -> Vec { + let mut key_share = vec![0u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; + // FakeTLS never derives TLS traffic secrets from this payload; only the + // externally visible named group and vector lengths are protocol-facing. + rng.fill(&mut key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN]); + let x25519_key = gen_fake_x25519_key(rng); + key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..].copy_from_slice(&x25519_key); + key_share +} + /// Build TLS ServerHello response /// /// This builds a complete TLS 1.3-like response including: @@ -537,6 +561,7 @@ pub fn build_server_hello( fake_cert_len, rng, cipher_suite::TLS_AES_128_GCM_SHA256, + TLS_NAMED_GROUP_X25519MLKEM768, alpn, new_session_tickets, ) @@ -554,20 +579,30 @@ pub(crate) fn build_server_hello_with_cipher( fake_cert_len: usize, rng: &SecureRandom, selected_cipher_suite: [u8; 2], + selected_key_share_group: u16, alpn: Option>, new_session_tickets: u8, ) -> Vec { const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = MAX_TLS_CIPHERTEXT_SIZE; let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); - let x25519_key = gen_fake_x25519_key(rng); // 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(); + let server_hello = if selected_key_share_group == TLS_NAMED_GROUP_X25519MLKEM768 { + let key_share = gen_fake_x25519mlkem768_server_key_share(rng); + ServerHelloBuilder::new(session_id.to_vec()) + .with_cipher_suite(selected_cipher_suite) + .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key_share) + .with_tls13_version() + .build_record() + } else { + let key_share = gen_fake_x25519_key(rng); + ServerHelloBuilder::new(session_id.to_vec()) + .with_cipher_suite(selected_cipher_suite) + .with_key_share(TLS_NAMED_GROUP_X25519, &key_share) + .with_tls13_version() + .build_record() + }; // Build Change Cipher Spec record let change_cipher_spec = [ @@ -1003,6 +1038,145 @@ fn client_hello_cipher_suites_range(handshake: &[u8]) -> Option<(usize, usize)> Some((pos, cipher_end)) } +fn client_hello_extensions_range(handshake: &[u8]) -> 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; + pos = pos.checked_add(cipher_len)?; + if pos + 1 > handshake_end { + return None; + } + + let compression_len = *handshake.get(pos)? as usize; + pos = pos.checked_add(1)?.checked_add(compression_len)?; + if pos == handshake_end { + return Some((handshake_end, handshake_end)); + } + if pos + 2 > handshake_end { + return None; + } + + let extensions_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + pos += 2; + let extensions_end = pos.checked_add(extensions_len)?; + if extensions_end > handshake_end { + return None; + } + + Some((pos, extensions_end)) +} + +fn key_share_extension_has_group( + data: &[u8], + group: u16, + expected_key_exchange_len: usize, +) -> bool { + if data.len() < 2 { + return false; + } + + let shares_len = u16::from_be_bytes([data[0], data[1]]) as usize; + if shares_len != data.len().saturating_sub(2) { + return false; + } + + let mut pos = 2usize; + let shares_end = 2 + shares_len; + let mut found_group = false; + while pos + 4 <= shares_end { + let entry_group = u16::from_be_bytes([data[pos], data[pos + 1]]); + let key_exchange_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize; + pos += 4; + let Some(key_exchange_end) = pos.checked_add(key_exchange_len) else { + return false; + }; + if key_exchange_end > shares_end { + return false; + } + if entry_group == group && key_exchange_len == expected_key_exchange_len { + found_group = true; + } + pos = key_exchange_end; + } + + found_group && pos == shares_end +} + +fn client_hello_offers_key_share_group( + handshake: &[u8], + group: u16, + expected_key_exchange_len: usize, +) -> bool { + let Some((mut pos, extensions_end)) = client_hello_extensions_range(handshake) else { + return false; + }; + + while pos + 4 <= extensions_end { + let ext_type = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]); + let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize; + pos += 4; + let Some(ext_end) = pos.checked_add(ext_len) else { + return false; + }; + if ext_end > extensions_end { + return false; + } + + if ext_type == extension_type::KEY_SHARE { + return key_share_extension_has_group( + &handshake[pos..ext_end], + group, + expected_key_exchange_len, + ); + } + + pos = ext_end; + } + + false +} + fn client_hello_offers_cipher_suite( handshake: &[u8], range: (usize, usize), @@ -1056,6 +1230,22 @@ pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8; preferred } +/// Select the ServerHello key_share named group from the authenticated ClientHello. +/// +/// Malformed key_share structures intentionally keep the legacy X25519 response +/// to avoid breaking older clients that do not advertise the hybrid group. +pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> u16 { + if client_hello_offers_key_share_group( + handshake, + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ) { + TLS_NAMED_GROUP_X25519MLKEM768 + } else { + TLS_NAMED_GROUP_X25519 + } +} + /// 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 a765575..1d6b4e6 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1502,6 +1502,7 @@ where replay_checker.add_tls_digest(digest_half); let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; + let selected_key_share_group = tls::select_server_hello_key_share_group(handshake); 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] { @@ -1520,6 +1521,7 @@ where config.censorship.serverhello_compact, client_tls_version, selected_cipher_suite, + selected_key_share_group, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, @@ -1533,6 +1535,7 @@ where config.censorship.fake_cert_len, rng, selected_cipher_suite, + selected_key_share_group, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 5bf307c..b0b1205 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -6,7 +6,9 @@ use crate::protocol::constants::{ TLS_RECORD_HANDSHAKE, TLS_VERSION, }; use crate::protocol::tls::{ - ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key, + ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519, + TLS_NAMED_GROUP_X25519MLKEM768, gen_fake_x25519_key, + gen_fake_x25519mlkem768_server_key_share, }; use crate::tls_front::types::{ CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource, @@ -196,19 +198,43 @@ fn push_supported_versions_extension(extensions: &mut Vec) { 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); +fn push_key_share_entry(extensions: &mut Vec, group: u16, key_exchange: &[u8]) { + let Ok(key_exchange_len) = u16::try_from(key_exchange.len()) else { + return; + }; + let Some(entry_len) = key_exchange.len().checked_add(4) else { + return; + }; + let Ok(entry_len) = u16::try_from(entry_len) else { + return; + }; + 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); + extensions.extend_from_slice(&entry_len.to_be_bytes()); + extensions.extend_from_slice(&group.to_be_bytes()); + extensions.extend_from_slice(&key_exchange_len.to_be_bytes()); + extensions.extend_from_slice(key_exchange); +} + +fn push_key_share_extension( + extensions: &mut Vec, + rng: &SecureRandom, + selected_key_share_group: u16, +) { + if selected_key_share_group == TLS_NAMED_GROUP_X25519MLKEM768 { + let key = gen_fake_x25519mlkem768_server_key_share(rng); + push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519MLKEM768, &key); + } else { + let key = gen_fake_x25519_key(rng); + push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519, &key); + } } fn replay_profiled_server_hello_extension( ext: &TlsExtension, extensions: &mut Vec, rng: &SecureRandom, + selected_key_share_group: u16, saw_supported_versions: &mut bool, saw_key_share: &mut bool, ) { @@ -218,7 +244,7 @@ fn replay_profiled_server_hello_extension( *saw_supported_versions = true; } EXT_KEY_SHARE if !*saw_key_share => { - push_key_share_extension(extensions, rng); + push_key_share_extension(extensions, rng, selected_key_share_group); *saw_key_share = true; } EXT_ALPN => {} @@ -226,7 +252,11 @@ fn replay_profiled_server_hello_extension( } } -fn build_profiled_server_hello_extensions(cached: &CachedTlsData, rng: &SecureRandom) -> Vec { +fn build_profiled_server_hello_extensions( + cached: &CachedTlsData, + rng: &SecureRandom, + selected_key_share_group: u16, +) -> Vec { let capacity = cached .server_hello_template .extensions @@ -243,13 +273,14 @@ fn build_profiled_server_hello_extensions(cached: &CachedTlsData, rng: &SecureRa ext, &mut extensions, rng, + selected_key_share_group, &mut saw_supported_versions, &mut saw_key_share, ); } if !saw_key_share { - push_key_share_extension(&mut extensions, rng); + push_key_share_extension(&mut extensions, rng, selected_key_share_group); } if !saw_supported_versions { push_supported_versions_extension(&mut extensions); @@ -268,12 +299,13 @@ pub fn build_emulated_server_hello( serverhello_compact: bool, client_tls_version: ClientHelloTlsVersion, selected_cipher_suite: [u8; 2], + selected_key_share_group: u16, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, ) -> Vec { // --- ServerHello --- - let extensions = build_profiled_server_hello_extensions(cached, rng); + let extensions = build_profiled_server_hello_extensions(cached, rng, selected_key_share_group); let extensions_len = extensions.len() as u16; let body_len = 2 + 32 + 1 + session_id.len() + 2 + 1 + 2 + extensions.len(); @@ -458,7 +490,7 @@ mod tests { use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; - use crate::protocol::tls::ClientHelloTlsVersion; + use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; fn first_app_data_payload(response: &[u8]) -> &[u8] { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; @@ -540,6 +572,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -569,6 +602,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x03], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -604,6 +638,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -628,6 +663,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -663,6 +699,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -704,6 +741,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -737,6 +775,7 @@ mod tests { false, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -769,6 +808,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &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 3fbba07..c87ee89 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -4,7 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::ClientHelloTlsVersion; +use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, @@ -66,6 +66,7 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -91,6 +92,7 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -114,6 +116,7 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index c3ef96d..ea8159d 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -4,7 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::ClientHelloTlsVersion; +use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, @@ -59,6 +59,7 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(oversized_alpn), 0, @@ -98,6 +99,7 @@ fn emulated_server_hello_keeps_alpn_marker_out_of_appdata() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -129,6 +131,7 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, From db7ff8737c3239b2a042a803c986dede6fd8e1a3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:36:37 +0300 Subject: [PATCH 02/10] Add dynamic SNI mask target mode Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/config/hot_reload.rs | 1 + src/config/load.rs | 6 +----- src/config/types.rs | 5 +++++ src/proxy/masking.rs | 43 ++++++++++++++++++++++++++++------------ 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index c4a4f44..841bc1a 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -620,6 +620,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.censorship.tls_domains != new.censorship.tls_domains || old.censorship.tls_fetch_scope != new.censorship.tls_fetch_scope || old.censorship.mask != new.censorship.mask + || old.censorship.mask_dynamic != new.censorship.mask_dynamic || old.censorship.mask_host != new.censorship.mask_host || old.censorship.mask_port != new.censorship.mask_port || old.censorship.exclusive_mask != new.censorship.exclusive_mask diff --git a/src/config/load.rs b/src/config/load.rs index c6ad74e..b428079 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -372,6 +372,7 @@ const CENSORSHIP_CONFIG_KEYS: &[&str] = &[ "tls_fetch_scope", "tls_fetch", "mask", + "mask_dynamic", "mask_host", "mask_port", "exclusive_mask", @@ -2047,11 +2048,6 @@ impl ProxyConfig { *mask_host = normalize_mask_host_to_ascii(mask_host, "censorship.mask_host")?; } - // Default mask_host to tls_domain if not set and no unix socket configured. - if config.censorship.mask_host.is_none() && config.censorship.mask_unix_sock.is_none() { - config.censorship.mask_host = Some(config.censorship.tls_domain.clone()); - } - for (domain, target) in &config.censorship.exclusive_mask { if !is_valid_tls_domain_name(domain) { return Err(ProxyError::Config(format!( diff --git a/src/config/types.rs b/src/config/types.rs index e810240..d007428 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1726,6 +1726,10 @@ pub struct AntiCensorshipConfig { #[serde(default = "default_true")] pub mask: bool, + /// Use the ClientHello SNI as the mask TCP target for configured TLS domains. + #[serde(default = "default_true")] + pub mask_dynamic: bool, + #[serde(default)] pub mask_host: Option, @@ -1861,6 +1865,7 @@ impl Default for AntiCensorshipConfig { tls_fetch_scope: default_tls_fetch_scope(), tls_fetch: TlsFetchConfig::default(), mask: default_true(), + mask_dynamic: default_true(), mask_host: None, mask_port: default_mask_port(), exclusive_mask: HashMap::new(), diff --git a/src/proxy/masking.rs b/src/proxy/masking.rs index e0631c8..7e73eb8 100644 --- a/src/proxy/masking.rs +++ b/src/proxy/masking.rs @@ -385,7 +385,7 @@ mod tls_domain_mask_host_tests { let mut config = ProxyConfig::default(); config.censorship.tls_domain = "a.com".to_string(); config.censorship.tls_domains = vec!["b.com".to_string(), "c.com".to_string()]; - config.censorship.mask_host = Some("a.com".to_string()); + config.censorship.mask_host = None; config } @@ -419,6 +419,15 @@ mod tls_domain_mask_host_tests { assert_eq!(mask_host_for_initial_data(&config, &initial_data), "b.com"); } + #[test] + fn mask_host_uses_primary_domain_when_dynamic_masking_is_disabled() { + let mut config = config_with_tls_domains(); + config.censorship.mask_dynamic = false; + let initial_data = client_hello_with_sni("b.com"); + + assert_eq!(mask_host_for_initial_data(&config, &initial_data), "a.com"); + } + #[test] fn exclusive_mask_target_overrides_only_matching_sni() { let mut config = config_with_tls_domains(); @@ -577,24 +586,32 @@ fn default_mask_tcp_target_for_initial_data<'a>( .as_deref() .unwrap_or(&config.censorship.tls_domain); - if !configured_mask_host.eq_ignore_ascii_case(&config.censorship.tls_domain) { + if config.censorship.mask_host.is_none() && config.censorship.mask_dynamic { + let extracted_sni = if sni.is_none() { + tls::extract_sni_from_client_hello(initial_data) + } else { + None + }; + if let Some(host) = sni + .or(extracted_sni.as_deref()) + .and_then(|sni| matching_tls_domain_for_sni(config, sni)) + { + return MaskTcpTarget { + host, + port: config.censorship.mask_port, + }; + } + } + + if let Some(mask_host) = config.censorship.mask_host.as_deref() { return MaskTcpTarget { - host: configured_mask_host, + host: mask_host, port: config.censorship.mask_port, }; } - let extracted_sni = if sni.is_none() { - tls::extract_sni_from_client_hello(initial_data) - } else { - None - }; - let host = sni - .or(extracted_sni.as_deref()) - .and_then(|sni| matching_tls_domain_for_sni(config, sni)) - .unwrap_or(configured_mask_host); MaskTcpTarget { - host, + host: configured_mask_host, port: config.censorship.mask_port, } } From c4b58ad374fb110a37b5826a817cf7ffb13f70ac Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:07:40 +0300 Subject: [PATCH 03/10] Hardened TLS-F ServerHello selection Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/protocol/tests/tls_security_tests.rs | 70 ++++++++-------- src/protocol/tls.rs | 52 ++++++------ src/proxy/handshake.rs | 79 +++++++++++++------ src/tls_front/emulator.rs | 43 +++------- ...mulator_profile_fidelity_security_tests.rs | 5 +- .../tests/emulator_security_tests.rs | 5 +- 6 files changed, 126 insertions(+), 128 deletions(-) diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index c3c934d..0bde87f 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1457,7 +1457,6 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -1467,14 +1466,21 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { !exts.contains(&0x0010), "ALPN extension must not appear in emulated ServerHello" ); + assert_eq!( + server_hello_key_share(&response), + Some(( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_SERVER_KEY_SHARE_LEN + )) + ); } #[test] fn test_tls_extension_builder() { - let key = [0x42u8; 32]; + let key = vec![0x42u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; let mut builder = TlsExtensionBuilder::new(); - builder.add_key_share(TLS_NAMED_GROUP_X25519, &key); + builder.add_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key); builder.add_supported_versions(0x0304); let result = builder.build(); @@ -1487,10 +1493,10 @@ fn test_tls_extension_builder() { #[test] fn test_server_hello_builder() { let session_id = vec![0x01, 0x02, 0x03, 0x04]; - let key = [0x55u8; 32]; + let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; let builder = ServerHelloBuilder::new(session_id.clone()) - .with_key_share(TLS_NAMED_GROUP_X25519, &key) + .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key) .with_tls13_version(); let record = builder.build_record(); @@ -1535,7 +1541,7 @@ fn test_build_server_hello_structure() { } #[test] -fn test_build_server_hello_with_cipher_can_keep_x25519_key_share() { +fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() { let secret = b"test secret"; let client_digest = [0x42u8; 32]; let session_id = vec![0xAA; 32]; @@ -1548,14 +1554,16 @@ fn test_build_server_hello_with_cipher_can_keep_x25519_key_share() { 2048, &rng, [0x13, 0x01], - TLS_NAMED_GROUP_X25519, None, 0, ); assert_eq!( server_hello_key_share(&response), - Some((TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)) + Some(( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_SERVER_KEY_SHARE_LEN + )) ); } @@ -1579,10 +1587,10 @@ fn test_build_server_hello_digest() { #[test] fn test_server_hello_extensions_length() { let session_id = vec![0x01; 32]; - let key = [0x55u8; 32]; + let key = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; let builder = ServerHelloBuilder::new(session_id) - .with_key_share(TLS_NAMED_GROUP_X25519, &key) + .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key) .with_tls13_version(); let record = builder.build_record(); @@ -1796,7 +1804,7 @@ fn select_server_hello_cipher_suite_keeps_profile_cipher_when_offered() { ); assert_eq!( select_server_hello_cipher_suite(&ch, [0x13, 0x03]), - [0x13, 0x03] + Some([0x13, 0x03]) ); } @@ -1809,7 +1817,16 @@ fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() { ); assert_eq!( select_server_hello_cipher_suite(&ch, [0xc0, 0x2f]), - [0x13, 0x03] + Some([0x13, 0x03]) + ); +} + +#[test] +fn select_server_hello_cipher_suite_rejects_without_offered_tls13_suite() { + let ch = build_client_hello_with_ciphers_and_exts(&[[0xc0, 0x2f]], Vec::new(), "example.com"); + assert_eq!( + select_server_hello_cipher_suite(&ch, [0x13, 0x01]), + None ); } @@ -1818,18 +1835,18 @@ 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] + Some([0x13, 0x03]) ); } #[test] -fn select_server_hello_cipher_suite_keeps_preferred_for_malformed_clienthello() { +fn select_server_hello_cipher_suite_rejects_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] + None ); } @@ -1847,38 +1864,32 @@ fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offere assert_eq!( select_server_hello_key_share_group(&ch), - TLS_NAMED_GROUP_X25519MLKEM768 + Some(TLS_NAMED_GROUP_X25519MLKEM768) ); } #[test] -fn select_server_hello_key_share_group_falls_back_without_hybrid_share() { +fn select_server_hello_key_share_group_rejects_without_hybrid_share() { let key_share = client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]); let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); - assert_eq!( - select_server_hello_key_share_group(&ch), - TLS_NAMED_GROUP_X25519 - ); + assert_eq!(select_server_hello_key_share_group(&ch), None); } #[test] -fn select_server_hello_key_share_group_falls_back_for_malformed_hybrid_len() { +fn select_server_hello_key_share_group_rejects_malformed_hybrid_len() { let key_share = client_key_share_extension(&[( TLS_NAMED_GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_KEY_SHARE_LEN - 1, )]); let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); - assert_eq!( - select_server_hello_key_share_group(&ch), - TLS_NAMED_GROUP_X25519 - ); + assert_eq!(select_server_hello_key_share_group(&ch), None); } #[test] -fn select_server_hello_key_share_group_falls_back_for_malformed_key_share_tail() { +fn select_server_hello_key_share_group_rejects_malformed_key_share_tail() { let mut key_share = client_key_share_extension(&[( TLS_NAMED_GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_KEY_SHARE_LEN, @@ -1888,10 +1899,7 @@ fn select_server_hello_key_share_group_falls_back_for_malformed_key_share_tail() key_share.push(0); let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); - assert_eq!( - select_server_hello_key_share_group(&ch), - TLS_NAMED_GROUP_X25519 - ); + assert_eq!(select_server_hello_key_share_group(&ch), None); } #[test] diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 518e2ba..3d23930 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -561,7 +561,6 @@ pub fn build_server_hello( fake_cert_len, rng, cipher_suite::TLS_AES_128_GCM_SHA256, - TLS_NAMED_GROUP_X25519MLKEM768, alpn, new_session_tickets, ) @@ -579,7 +578,6 @@ pub(crate) fn build_server_hello_with_cipher( fake_cert_len: usize, rng: &SecureRandom, selected_cipher_suite: [u8; 2], - selected_key_share_group: u16, alpn: Option>, new_session_tickets: u8, ) -> Vec { @@ -588,21 +586,12 @@ pub(crate) fn build_server_hello_with_cipher( let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); // Build ServerHello - let server_hello = if selected_key_share_group == TLS_NAMED_GROUP_X25519MLKEM768 { - let key_share = gen_fake_x25519mlkem768_server_key_share(rng); - ServerHelloBuilder::new(session_id.to_vec()) - .with_cipher_suite(selected_cipher_suite) - .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key_share) - .with_tls13_version() - .build_record() - } else { - let key_share = gen_fake_x25519_key(rng); - ServerHelloBuilder::new(session_id.to_vec()) - .with_cipher_suite(selected_cipher_suite) - .with_key_share(TLS_NAMED_GROUP_X25519, &key_share) - .with_tls13_version() - .build_record() - }; + let key_share = gen_fake_x25519mlkem768_server_key_share(rng); + let server_hello = ServerHelloBuilder::new(session_id.to_vec()) + .with_cipher_suite(selected_cipher_suite) + .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key_share) + .with_tls13_version() + .build_record(); // Build Change Cipher Spec record let change_cipher_spec = [ @@ -1201,20 +1190,23 @@ fn is_tls13_cipher_suite(suite: [u8; 2]) -> bool { /// 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] { +/// authenticated success response and fails closed for malformed or unsupported +/// ClientHello shapes that cannot produce a DPI-consistent ServerHello. +pub(crate) fn select_server_hello_cipher_suite( + handshake: &[u8], + preferred: [u8; 2], +) -> Option<[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; + return None; }; if client_hello_offers_cipher_suite(handshake, range, preferred) { - return preferred; + return Some(preferred); } for fallback in [ @@ -1223,26 +1215,26 @@ pub(crate) fn select_server_hello_cipher_suite(handshake: &[u8], preferred: [u8; cipher_suite::TLS_AES_256_GCM_SHA384, ] { if client_hello_offers_cipher_suite(handshake, range, fallback) { - return fallback; + return Some(fallback); } } - preferred + None } -/// Select the ServerHello key_share named group from the authenticated ClientHello. +/// Select the hybrid ServerHello key_share named group from the authenticated ClientHello. /// -/// Malformed key_share structures intentionally keep the legacy X25519 response -/// to avoid breaking older clients that do not advertise the hybrid group. -pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> u16 { +/// Malformed or non-hybrid key_share structures fail closed so authenticated +/// but DPI-inconsistent ClientHellos take the ordinary masking fallback path. +pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option { if client_hello_offers_key_share_group( handshake, TLS_NAMED_GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_KEY_SHARE_LEN, ) { - TLS_NAMED_GROUP_X25519MLKEM768 + Some(TLS_NAMED_GROUP_X25519MLKEM768) } else { - TLS_NAMED_GROUP_X25519 + None } } diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 1d6b4e6..d63fe09 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1473,24 +1473,22 @@ where return HandshakeResult::BadClient { reader, writer }; } - let cached = if config.censorship.tls_emulation { + if tls::select_server_hello_key_share_group(handshake).is_none() { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + "TLS handshake rejected: ClientHello did not offer a valid X25519MLKEM768 key_share" + ); + return HandshakeResult::BadClient { reader, writer }; + } + + let cached_entry = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { let selected_domain = matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); let cached_entry = cache.get(selected_domain).await; - let use_full_cert_payload = if config.censorship.serverhello_compact - && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) - { - cache - .take_full_cert_budget_for_ip( - peer.ip(), - Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), - ) - .await - } else { - true - }; - Some((cached_entry, use_full_cert_payload)) + Some(cached_entry) } else { None } @@ -1498,20 +1496,54 @@ where None }; + let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() { + if cached_entry.server_hello_template.cipher_suite == [0, 0] { + [0x13, 0x01] + } else { + cached_entry.server_hello_template.cipher_suite + } + } else { + [0x13, 0x01] + }; + let Some(selected_cipher_suite) = + tls::select_server_hello_cipher_suite(handshake, preferred_cipher_suite) + else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + "TLS handshake rejected: ClientHello did not offer a supported TLS 1.3 cipher suite" + ); + return HandshakeResult::BadClient { reader, writer }; + }; + + let cached = if let Some(cached_entry) = cached_entry { + let use_full_cert_payload = if config.censorship.serverhello_compact + && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) + { + if let Some(cache) = tls_cache.as_ref() { + cache.take_full_cert_budget_for_ip( + peer.ip(), + Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), + ) + .await + } else { + true + } + } else { + true + }; + Some((cached_entry, use_full_cert_payload)) + } else { + None + }; + // Add replay digest only for policy-valid handshakes. replay_checker.add_tls_digest(digest_half); let validation_session_id_slice = &validation_session_id[..validation_session_id_len]; - let selected_key_share_group = tls::select_server_hello_key_share_group(handshake); 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, @@ -1521,13 +1553,11 @@ where config.censorship.serverhello_compact, client_tls_version, selected_cipher_suite, - selected_key_share_group, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) } else { - let selected_cipher_suite = tls::select_server_hello_cipher_suite(handshake, [0x13, 0x01]); tls::build_server_hello_with_cipher( &validated_secret, &validation_digest, @@ -1535,7 +1565,6 @@ where config.censorship.fake_cert_len, rng, selected_cipher_suite, - selected_key_share_group, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index b0b1205..00f4fdb 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -6,8 +6,7 @@ use crate::protocol::constants::{ TLS_RECORD_HANDSHAKE, TLS_VERSION, }; use crate::protocol::tls::{ - ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519, - TLS_NAMED_GROUP_X25519MLKEM768, gen_fake_x25519_key, + ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519MLKEM768, gen_fake_x25519mlkem768_server_key_share, }; use crate::tls_front::types::{ @@ -216,25 +215,15 @@ fn push_key_share_entry(extensions: &mut Vec, group: u16, key_exchange: &[u8 extensions.extend_from_slice(key_exchange); } -fn push_key_share_extension( - extensions: &mut Vec, - rng: &SecureRandom, - selected_key_share_group: u16, -) { - if selected_key_share_group == TLS_NAMED_GROUP_X25519MLKEM768 { - let key = gen_fake_x25519mlkem768_server_key_share(rng); - push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519MLKEM768, &key); - } else { - let key = gen_fake_x25519_key(rng); - push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519, &key); - } +fn push_key_share_extension(extensions: &mut Vec, rng: &SecureRandom) { + let key = gen_fake_x25519mlkem768_server_key_share(rng); + push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519MLKEM768, &key); } fn replay_profiled_server_hello_extension( ext: &TlsExtension, extensions: &mut Vec, rng: &SecureRandom, - selected_key_share_group: u16, saw_supported_versions: &mut bool, saw_key_share: &mut bool, ) { @@ -244,7 +233,7 @@ fn replay_profiled_server_hello_extension( *saw_supported_versions = true; } EXT_KEY_SHARE if !*saw_key_share => { - push_key_share_extension(extensions, rng, selected_key_share_group); + push_key_share_extension(extensions, rng); *saw_key_share = true; } EXT_ALPN => {} @@ -252,11 +241,7 @@ fn replay_profiled_server_hello_extension( } } -fn build_profiled_server_hello_extensions( - cached: &CachedTlsData, - rng: &SecureRandom, - selected_key_share_group: u16, -) -> Vec { +fn build_profiled_server_hello_extensions(cached: &CachedTlsData, rng: &SecureRandom) -> Vec { let capacity = cached .server_hello_template .extensions @@ -273,14 +258,13 @@ fn build_profiled_server_hello_extensions( ext, &mut extensions, rng, - selected_key_share_group, &mut saw_supported_versions, &mut saw_key_share, ); } if !saw_key_share { - push_key_share_extension(&mut extensions, rng, selected_key_share_group); + push_key_share_extension(&mut extensions, rng); } if !saw_supported_versions { push_supported_versions_extension(&mut extensions); @@ -299,13 +283,12 @@ pub fn build_emulated_server_hello( serverhello_compact: bool, client_tls_version: ClientHelloTlsVersion, selected_cipher_suite: [u8; 2], - selected_key_share_group: u16, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, ) -> Vec { // --- ServerHello --- - let extensions = build_profiled_server_hello_extensions(cached, rng, selected_key_share_group); + let extensions = build_profiled_server_hello_extensions(cached, rng); let extensions_len = extensions.len() as u16; let body_len = 2 + 32 + 1 + session_id.len() + 2 + 1 + 2 + extensions.len(); @@ -490,7 +473,7 @@ mod tests { use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; - use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; + use crate::protocol::tls::ClientHelloTlsVersion; fn first_app_data_payload(response: &[u8]) -> &[u8] { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; @@ -572,7 +555,6 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -602,7 +584,6 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x03], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -638,7 +619,6 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -663,7 +643,6 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -699,7 +678,6 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -741,7 +719,6 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -775,7 +752,6 @@ mod tests { false, ClientHelloTlsVersion::Tls12, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -808,7 +784,6 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &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 c87ee89..3fbba07 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -4,7 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; +use crate::protocol::tls::ClientHelloTlsVersion; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, @@ -66,7 +66,6 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -92,7 +91,6 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 0, @@ -116,7 +114,6 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index ea8159d..c3ef96d 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -4,7 +4,7 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::{ClientHelloTlsVersion, TLS_NAMED_GROUP_X25519MLKEM768}; +use crate::protocol::tls::ClientHelloTlsVersion; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, @@ -59,7 +59,6 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(oversized_alpn), 0, @@ -99,7 +98,6 @@ fn emulated_server_hello_keeps_alpn_marker_out_of_appdata() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, @@ -131,7 +129,6 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], - TLS_NAMED_GROUP_X25519MLKEM768, &rng, Some(b"h2".to_vec()), 0, From eba55e755d61e542b050813ed27ba922d1fa639a Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:51:58 +0300 Subject: [PATCH 04/10] Preserve TLS-F Origin Record Choreography Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/tls_front/emulator.rs | 42 +++++++++---------- src/tls_front/fetcher.rs | 12 +++--- ...mulator_profile_fidelity_security_tests.rs | 4 +- 3 files changed, 28 insertions(+), 30 deletions(-) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 00f4fdb..5eb6a4c 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -74,19 +74,13 @@ fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec Vec { match cached.behavior_profile.source { TlsProfileSource::Raw | TlsProfileSource::Merged => { - return cached - .app_data_records_sizes - .first() - .copied() - .or_else(|| { - cached - .behavior_profile - .app_data_record_sizes - .first() - .copied() - }) - .map(|size| vec![size]) - .unwrap_or_else(|| vec![cached.total_app_data_len.max(1024)]); + if !cached.behavior_profile.app_data_record_sizes.is_empty() { + return cached.behavior_profile.app_data_record_sizes.clone(); + } + if !cached.app_data_records_sizes.is_empty() { + return cached.app_data_records_sizes.clone(); + } + return vec![cached.total_app_data_len.max(1024)]; } TlsProfileSource::Default | TlsProfileSource::Rustls => {} } @@ -383,6 +377,7 @@ pub fn build_emulated_server_hello( // ALPN selection is encrypted inside EncryptedExtensions in real TLS 1.3. // Keeping the FakeTLS record body opaque avoids a stable plaintext marker. let _ = alpn; + let mut payload_offset = 0usize; for size in sizes { let mut rec = Vec::with_capacity(5 + size); rec.push(TLS_RECORD_APPLICATION); @@ -392,10 +387,11 @@ pub fn build_emulated_server_hello( if let Some(payload) = selected_payload { if size > 17 { let body_len = size - 17; - let remaining = payload.len(); + let remaining = payload.len().saturating_sub(payload_offset); let copy_len = remaining.min(body_len); if copy_len > 0 { - rec.extend_from_slice(&payload[..copy_len]); + rec.extend_from_slice(&payload[payload_offset..payload_offset + copy_len]); + payload_offset += copy_len; } if body_len > copy_len { rec.extend_from_slice(&rng.bytes(body_len - copy_len)); @@ -791,11 +787,15 @@ mod tests { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; let ccs_start = 5 + hello_len; - let app_start = ccs_start + 6; - let app_len = - u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize; - assert_eq!(response[app_start], TLS_RECORD_APPLICATION); - assert_eq!(app_len, 64); - assert_eq!(app_start + 5 + app_len, response.len()); + let mut pos = ccs_start + 6; + let mut app_lens = Vec::new(); + while pos + 5 <= response.len() { + let record_len = u16::from_be_bytes([response[pos + 3], response[pos + 4]]) as usize; + assert_eq!(response[pos], TLS_RECORD_APPLICATION); + app_lens.push(record_len); + pos += 5 + record_len; + } + assert_eq!(app_lens, vec![64, 3905, 537]); + assert_eq!(pos, response.len()); } } diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 3d85d89..10f069d 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -1036,13 +1036,11 @@ where let mut app_sizes = behavior_profile.app_data_record_sizes.clone(); app_sizes.extend_from_slice(&behavior_profile.ticket_record_sizes); let total_app_data_len = app_sizes.iter().sum::().max(1024); - let app_data_records_sizes = behavior_profile - .app_data_record_sizes - .first() - .copied() - .or_else(|| behavior_profile.ticket_record_sizes.first().copied()) - .map(|size| vec![size]) - .unwrap_or_else(|| vec![total_app_data_len]); + let app_data_records_sizes = if app_sizes.is_empty() { + vec![total_app_data_len] + } else { + app_sizes + }; Ok(TlsFetchResult { server_hello_parsed: parsed, 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 3fbba07..f70f5cb 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -97,7 +97,7 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { ); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); - assert_eq!(app_records, vec![1200]); + assert_eq!(app_records, vec![1200, 900]); } #[test] @@ -120,5 +120,5 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { ); let app_records = record_lengths_by_type(&response, TLS_RECORD_APPLICATION); - assert_eq!(app_records, vec![1200, 220, 180]); + assert_eq!(app_records, vec![1200, 900, 220, 180]); } From 62af5155049e91638e1fb95e0e10478692b51779 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:14:09 +0300 Subject: [PATCH 05/10] Generate Valid X25519MLKEM768 ServerHello key shares Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- Cargo.lock | 179 ++++++++++++++++-- Cargo.toml | 1 + src/protocol/tests/tls_security_tests.rs | 77 ++++++++ src/protocol/tls.rs | 111 ++++++++--- src/proxy/handshake.rs | 8 +- src/tls_front/emulator.rs | 38 +++- src/tls_front/fetcher.rs | 119 ++++++++++-- ...mulator_profile_fidelity_security_tests.rs | 7 + .../tests/emulator_security_tests.rs | 7 + 9 files changed, 477 insertions(+), 70 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a7e52c8..f892e96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -249,6 +249,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -397,7 +406,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -436,6 +445,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "combine" version = "4.6.7" @@ -452,6 +467,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -611,6 +632,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.9.2" @@ -620,6 +651,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -672,7 +712,17 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", "zeroize", ] @@ -705,11 +755,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -753,7 +813,7 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "signature", ] @@ -1135,7 +1195,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1183,6 +1243,17 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "ctutils", + "typenum", + "zeroize", +] + [[package]] name = "hyper" version = "1.10.0" @@ -1532,6 +1603,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1634,7 +1725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1670,6 +1761,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "sha3", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "moka" version = "0.12.15" @@ -1888,8 +2006,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -2280,7 +2408,7 @@ dependencies = [ "aead", "ed25519", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "ring", ] @@ -2567,7 +2695,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2578,7 +2706,17 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", ] [[package]] @@ -2724,7 +2862,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -2816,6 +2964,7 @@ dependencies = [ "libc", "lru", "md-5", + "ml-kem", "nix", "notify", "num-bigint", @@ -3259,7 +3408,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] diff --git a/Cargo.toml b/Cargo.toml index d33815d..c3ba7ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ crc32c = "0.6" zeroize = { version = "1.8", features = ["derive"] } subtle = "2.6" static_assertions = "1.1" +ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize"] } # Network socket2 = { version = "0.6", features = ["all"] } diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 0bde87f..185eac7 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1457,6 +1457,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &vec![0x42; X25519MLKEM768_SERVER_KEY_SHARE_LEN], &rng, Some(b"h2".to_vec()), 0, @@ -1545,6 +1546,7 @@ fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() { let secret = b"test secret"; let client_digest = [0x42u8; 32]; let session_id = vec![0xAA; 32]; + let key_share = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; let rng = crate::crypto::SecureRandom::new(); let response = build_server_hello_with_cipher( @@ -1554,6 +1556,7 @@ fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() { 2048, &rng, [0x13, 0x01], + &key_share, None, 0, ); @@ -1643,6 +1646,22 @@ fn client_key_share_extension(entries: &[(u16, usize)]) -> Vec { extension } +fn client_key_share_extension_with_payloads(entries: &[(u16, &[u8])]) -> Vec { + let mut shares = Vec::new(); + for (group, key_exchange) in entries { + assert!(key_exchange.len() <= u16::MAX as usize); + shares.extend_from_slice(&group.to_be_bytes()); + shares.extend_from_slice(&(key_exchange.len() as u16).to_be_bytes()); + shares.extend_from_slice(key_exchange); + } + + assert!(shares.len() <= u16::MAX as usize); + let mut extension = Vec::new(); + extension.extend_from_slice(&(shares.len() as u16).to_be_bytes()); + extension.extend_from_slice(&shares); + extension +} + fn build_client_hello_with_ciphers_and_exts( cipher_suites: &[[u8; 2]], exts: Vec<(u16, Vec)>, @@ -1868,6 +1887,64 @@ fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offere ); } +#[test] +fn build_x25519mlkem768_server_key_share_accepts_tdesktop_canonical_share() { + let key_share = client_key_share_extension(&[ + ( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ), + (TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN), + ]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + let server_key_share = build_x25519mlkem768_server_key_share(&ch, &rng) + .expect("tdesktop-like canonical share must build a ServerHello share"); + + assert_eq!(server_key_share.len(), X25519MLKEM768_SERVER_KEY_SHARE_LEN); + assert!( + server_key_share[..MLKEM768_SERVER_CIPHERTEXT_LEN] + .iter() + .any(|byte| *byte != 0), + "ML-KEM ciphertext must not be all zero" + ); + assert!( + server_key_share[MLKEM768_SERVER_CIPHERTEXT_LEN..] + .iter() + .any(|byte| *byte != 0), + "X25519 server share must not be all zero" + ); +} + +#[test] +fn build_x25519mlkem768_server_key_share_rejects_noncanonical_mlkem_key() { + let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN]; + key_exchange[..3].copy_from_slice(&[0xff, 0xff, 0xff]); + let key_share = client_key_share_extension_with_payloads(&[( + TLS_NAMED_GROUP_X25519MLKEM768, + &key_exchange, + )]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none()); +} + +#[test] +fn build_x25519mlkem768_server_key_share_rejects_all_zero_x25519_share() { + let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN]; + key_exchange[MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..].fill(0); + let key_share = client_key_share_extension_with_payloads(&[( + TLS_NAMED_GROUP_X25519MLKEM768, + &key_exchange, + )]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + assert!(build_x25519mlkem768_server_key_share(&ch, &rng).is_none()); +} + #[test] fn select_server_hello_key_share_group_rejects_without_hybrid_share() { let key_share = diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 3d23930..0872b21 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -63,6 +63,7 @@ use super::constants::*; use crate::crypto::{SecureRandom, sha256_hmac}; +use ml_kem::{B32, EncapsulationKey as MlKemEncapsulationKey, Key as MlKemKey, MlKem768}; #[cfg(test)] use crate::error::ProxyError; use std::time::{SystemTime, UNIX_EPOCH}; @@ -123,6 +124,7 @@ pub(crate) const TLS_NAMED_GROUP_X25519MLKEM768: u16 = named_curve::X25519MLKEM7 const X25519_KEY_SHARE_LEN: usize = 32; const X25519MLKEM768_CLIENT_KEY_SHARE_LEN: usize = 1216; const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120; +const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184; const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088; // ============= TLS Validation Result ============= @@ -521,9 +523,15 @@ fn validate_tls_handshake_at_time_with_boot_cap( /// Uses RFC 7748 X25519 scalar multiplication over the canonical basepoint, /// yielding distribution-consistent public keys for anti-fingerprinting. pub fn gen_fake_x25519_key(rng: &SecureRandom) -> [u8; 32] { + let (_scalar, public_key) = gen_x25519_key_pair(rng); + public_key +} + +fn gen_x25519_key_pair(rng: &SecureRandom) -> ([u8; 32], [u8; 32]) { let mut scalar = [0u8; X25519_KEY_SHARE_LEN]; - scalar.copy_from_slice(&rng.bytes(X25519_KEY_SHARE_LEN)); - x25519(scalar, X25519_BASEPOINT_BYTES) + rng.fill(&mut scalar); + let public_key = x25519(scalar, X25519_BASEPOINT_BYTES); + (scalar, public_key) } /// Generate a fake X25519MLKEM768 ServerHello key_share payload. @@ -537,6 +545,49 @@ pub(crate) fn gen_fake_x25519mlkem768_server_key_share(rng: &SecureRandom) -> Ve key_share } +fn mlkem768_encapsulate_to_client(client_key: &[u8], rng: &SecureRandom) -> Option> { + let key_bytes = MlKemKey::>::try_from(client_key).ok()?; + let encapsulation_key = MlKemEncapsulationKey::::new(&key_bytes).ok()?; + let mut randomness = [0u8; 32]; + rng.fill(&mut randomness); + let randomness = B32::try_from(randomness.as_slice()).ok()?; + let (ciphertext, _shared_key) = encapsulation_key.encapsulate_deterministic(&randomness); + let ciphertext = ciphertext.as_slice().to_vec(); + if ciphertext.len() == MLKEM768_SERVER_CIPHERTEXT_LEN { + Some(ciphertext) + } else { + None + } +} + +/// Build a valid X25519MLKEM768 ServerHello key_share for the authenticated ClientHello. +pub(crate) fn build_x25519mlkem768_server_key_share( + handshake: &[u8], + rng: &SecureRandom, +) -> Option> { + let client_key_exchange = client_hello_key_share_group_entry( + handshake, + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + )?; + let client_mlkem_key = client_key_exchange.get(..MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN)?; + let client_x25519_key = client_key_exchange.get(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN..)?; + let mlkem_ciphertext = mlkem768_encapsulate_to_client(client_mlkem_key, rng)?; + + let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN]; + client_x25519.copy_from_slice(client_x25519_key); + let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng); + let x25519_shared = x25519(server_x25519_scalar, client_x25519); + if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) { + return None; + } + + let mut key_share = Vec::with_capacity(X25519MLKEM768_SERVER_KEY_SHARE_LEN); + key_share.extend_from_slice(&mlkem_ciphertext); + key_share.extend_from_slice(&server_x25519_key); + Some(key_share) +} + /// Build TLS ServerHello response /// /// This builds a complete TLS 1.3-like response including: @@ -561,6 +612,7 @@ pub fn build_server_hello( fake_cert_len, rng, cipher_suite::TLS_AES_128_GCM_SHA256, + &gen_fake_x25519mlkem768_server_key_share(rng), alpn, new_session_tickets, ) @@ -578,6 +630,7 @@ pub(crate) fn build_server_hello_with_cipher( fake_cert_len: usize, rng: &SecureRandom, selected_cipher_suite: [u8; 2], + server_key_share: &[u8], alpn: Option>, new_session_tickets: u8, ) -> Vec { @@ -586,10 +639,9 @@ pub(crate) fn build_server_hello_with_cipher( let fake_cert_len = fake_cert_len.clamp(MIN_APP_DATA, MAX_APP_DATA); // Build ServerHello - let key_share = gen_fake_x25519mlkem768_server_key_share(rng); let server_hello = ServerHelloBuilder::new(session_id.to_vec()) .with_cipher_suite(selected_cipher_suite) - .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, &key_share) + .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, server_key_share) .with_tls13_version() .build_record(); @@ -1096,49 +1148,56 @@ fn client_hello_extensions_range(handshake: &[u8]) -> Option<(usize, usize)> { Some((pos, extensions_end)) } -fn key_share_extension_has_group( - data: &[u8], +fn key_share_extension_group_entry<'a>( + data: &'a [u8], group: u16, expected_key_exchange_len: usize, -) -> bool { +) -> Option<&'a [u8]> { if data.len() < 2 { - return false; + return None; } let shares_len = u16::from_be_bytes([data[0], data[1]]) as usize; if shares_len != data.len().saturating_sub(2) { - return false; + return None; } let mut pos = 2usize; let shares_end = 2 + shares_len; - let mut found_group = false; + let mut found_group = None; while pos + 4 <= shares_end { let entry_group = u16::from_be_bytes([data[pos], data[pos + 1]]); let key_exchange_len = u16::from_be_bytes([data[pos + 2], data[pos + 3]]) as usize; pos += 4; let Some(key_exchange_end) = pos.checked_add(key_exchange_len) else { - return false; + return None; }; if key_exchange_end > shares_end { - return false; + return None; } - if entry_group == group && key_exchange_len == expected_key_exchange_len { - found_group = true; + if entry_group == group { + if key_exchange_len != expected_key_exchange_len || found_group.is_some() { + return None; + } + found_group = Some(&data[pos..key_exchange_end]); } pos = key_exchange_end; } - found_group && pos == shares_end + if pos == shares_end { + found_group + } else { + None + } } -fn client_hello_offers_key_share_group( - handshake: &[u8], +fn client_hello_key_share_group_entry<'a>( + handshake: &'a [u8], group: u16, expected_key_exchange_len: usize, -) -> bool { +) -> Option<&'a [u8]> { let Some((mut pos, extensions_end)) = client_hello_extensions_range(handshake) else { - return false; + return None; }; while pos + 4 <= extensions_end { @@ -1146,14 +1205,14 @@ fn client_hello_offers_key_share_group( let ext_len = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize; pos += 4; let Some(ext_end) = pos.checked_add(ext_len) else { - return false; + return None; }; if ext_end > extensions_end { - return false; + return None; } if ext_type == extension_type::KEY_SHARE { - return key_share_extension_has_group( + return key_share_extension_group_entry( &handshake[pos..ext_end], group, expected_key_exchange_len, @@ -1163,7 +1222,7 @@ fn client_hello_offers_key_share_group( pos = ext_end; } - false + None } fn client_hello_offers_cipher_suite( @@ -1227,11 +1286,13 @@ pub(crate) fn select_server_hello_cipher_suite( /// Malformed or non-hybrid key_share structures fail closed so authenticated /// but DPI-inconsistent ClientHellos take the ordinary masking fallback path. pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option { - if client_hello_offers_key_share_group( + if client_hello_key_share_group_entry( handshake, TLS_NAMED_GROUP_X25519MLKEM768, X25519MLKEM768_CLIENT_KEY_SHARE_LEN, - ) { + ) + .is_some() + { Some(TLS_NAMED_GROUP_X25519MLKEM768) } else { None diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index d63fe09..6db4ce4 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1473,15 +1473,15 @@ where return HandshakeResult::BadClient { reader, writer }; } - if tls::select_server_hello_key_share_group(handshake).is_none() { + let Some(server_key_share) = tls::build_x25519mlkem768_server_key_share(handshake, rng) else { auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); maybe_apply_server_hello_delay(config).await; debug!( peer = %peer, - "TLS handshake rejected: ClientHello did not offer a valid X25519MLKEM768 key_share" + "TLS handshake rejected: ClientHello did not offer a usable X25519MLKEM768 key_share" ); return HandshakeResult::BadClient { reader, writer }; - } + }; let cached_entry = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { @@ -1553,6 +1553,7 @@ where config.censorship.serverhello_compact, client_tls_version, selected_cipher_suite, + &server_key_share, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, @@ -1565,6 +1566,7 @@ where config.censorship.fake_cert_len, rng, selected_cipher_suite, + &server_key_share, selected_alpn.clone(), config.censorship.tls_new_session_tickets, ) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 5eb6a4c..4f93f77 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -7,7 +7,6 @@ use crate::protocol::constants::{ }; use crate::protocol::tls::{ ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519MLKEM768, - gen_fake_x25519mlkem768_server_key_share, }; use crate::tls_front::types::{ CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource, @@ -209,15 +208,18 @@ fn push_key_share_entry(extensions: &mut Vec, group: u16, key_exchange: &[u8 extensions.extend_from_slice(key_exchange); } -fn push_key_share_extension(extensions: &mut Vec, rng: &SecureRandom) { - let key = gen_fake_x25519mlkem768_server_key_share(rng); - push_key_share_entry(extensions, TLS_NAMED_GROUP_X25519MLKEM768, &key); +fn push_key_share_extension(extensions: &mut Vec, server_key_share: &[u8]) { + push_key_share_entry( + extensions, + TLS_NAMED_GROUP_X25519MLKEM768, + server_key_share, + ); } fn replay_profiled_server_hello_extension( ext: &TlsExtension, extensions: &mut Vec, - rng: &SecureRandom, + server_key_share: &[u8], saw_supported_versions: &mut bool, saw_key_share: &mut bool, ) { @@ -227,7 +229,7 @@ fn replay_profiled_server_hello_extension( *saw_supported_versions = true; } EXT_KEY_SHARE if !*saw_key_share => { - push_key_share_extension(extensions, rng); + push_key_share_extension(extensions, server_key_share); *saw_key_share = true; } EXT_ALPN => {} @@ -235,7 +237,10 @@ fn replay_profiled_server_hello_extension( } } -fn build_profiled_server_hello_extensions(cached: &CachedTlsData, rng: &SecureRandom) -> Vec { +fn build_profiled_server_hello_extensions( + cached: &CachedTlsData, + server_key_share: &[u8], +) -> Vec { let capacity = cached .server_hello_template .extensions @@ -251,14 +256,14 @@ fn build_profiled_server_hello_extensions(cached: &CachedTlsData, rng: &SecureRa replay_profiled_server_hello_extension( ext, &mut extensions, - rng, + server_key_share, &mut saw_supported_versions, &mut saw_key_share, ); } if !saw_key_share { - push_key_share_extension(&mut extensions, rng); + push_key_share_extension(&mut extensions, server_key_share); } if !saw_supported_versions { push_supported_versions_extension(&mut extensions); @@ -277,12 +282,13 @@ pub fn build_emulated_server_hello( serverhello_compact: bool, client_tls_version: ClientHelloTlsVersion, selected_cipher_suite: [u8; 2], + server_key_share: &[u8], rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, ) -> Vec { // --- ServerHello --- - let extensions = build_profiled_server_hello_extensions(cached, rng); + let extensions = build_profiled_server_hello_extensions(cached, server_key_share); let extensions_len = extensions.len() as u16; let body_len = 2 + 32 + 1 + session_id.len() + 2 + 1 + 2 + extensions.len(); @@ -534,6 +540,10 @@ mod tests { } } + fn test_server_key_share() -> Vec { + vec![0x42; 1120] + } + #[test] fn test_build_emulated_server_hello_uses_cached_cert_payload() { let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd]; @@ -551,6 +561,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -580,6 +591,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x03], + &test_server_key_share(), &rng, None, 0, @@ -615,6 +627,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, Some(b"h2".to_vec()), 0, @@ -639,6 +652,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -674,6 +688,7 @@ mod tests { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -715,6 +730,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -748,6 +764,7 @@ mod tests { false, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + &test_server_key_share(), &rng, Some(b"h2".to_vec()), 0, @@ -780,6 +797,7 @@ mod tests { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 10f069d..fdcb8c1 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -9,6 +9,9 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use anyhow::{Result, anyhow}; +use ml_kem::{ + DecapsulationKey as MlKemDecapsulationKey, KeyExport, MlKem768, Seed as MlKemSeed, +}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; #[cfg(unix)] @@ -33,6 +36,7 @@ use crate::network::dns_overrides::resolve_socket_addr; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; +use crate::protocol::tls::{TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768}; use crate::tls_front::types::{ ParsedCertificateInfo, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsExtension, TlsFetchResult, TlsProfileSource, @@ -40,6 +44,10 @@ use crate::tls_front::types::{ use crate::transport::UpstreamStream; use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder}; +#[cfg(test)] +const X25519_KEY_SHARE_LEN: usize = 32; +const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184; + /// No-op verifier: accept any certificate (we only need lengths and metadata). #[derive(Debug)] struct NoVerify; @@ -393,8 +401,13 @@ fn profile_cipher_suites(profile: TlsFetchProfile) -> &'static [u16] { } fn profile_groups(profile: TlsFetchProfile) -> &'static [u16] { - const MODERN: &[u16] = &[0x001d, 0x0017, 0x0018]; // x25519, secp256r1, secp384r1 - const COMPAT: &[u16] = &[0x001d, 0x0017]; + const MODERN: &[u16] = &[ + TLS_NAMED_GROUP_X25519MLKEM768, + TLS_NAMED_GROUP_X25519, + 0x0017, + 0x0018, + ]; + const COMPAT: &[u16] = &[TLS_NAMED_GROUP_X25519, 0x0017]; const LEGACY: &[u16] = &[0x0017]; match profile { @@ -475,6 +488,50 @@ fn grease_value(rng: &SecureRandom, deterministic: bool, seed: &str) -> u16 { } } +fn gen_mlkem768_client_encapsulation_key( + rng: &SecureRandom, + deterministic: bool, + seed: &str, +) -> Option> { + let seed_bytes = if deterministic { + deterministic_bytes(seed, 64) + } else { + rng.bytes(64) + }; + let seed = MlKemSeed::try_from(seed_bytes.as_slice()).ok()?; + let decapsulation_key = MlKemDecapsulationKey::::from_seed(seed); + let encapsulation_key = decapsulation_key.encapsulation_key().to_bytes(); + let bytes = encapsulation_key.as_slice(); + if bytes.len() == MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN { + Some(bytes.to_vec()) + } else { + None + } +} + +fn gen_x25519mlkem768_client_key_share( + rng: &SecureRandom, + deterministic: bool, + seed: &str, +) -> Option> { + let mlkem_key = gen_mlkem768_client_encapsulation_key( + rng, + deterministic, + &format!("{seed}:mlkem768"), + )?; + let x25519_key = gen_key_share(rng, deterministic, &format!("{seed}:x25519")); + let mut key_share = Vec::with_capacity(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + x25519_key.len()); + key_share.extend_from_slice(&mlkem_key); + key_share.extend_from_slice(&x25519_key); + Some(key_share) +} + +fn push_client_key_share_entry(keyshare: &mut Vec, group: u16, key: &[u8]) { + keyshare.extend_from_slice(&group.to_be_bytes()); + keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes()); + keyshare.extend_from_slice(key); +} + fn build_client_hello( sni: &str, rng: &SecureRandom, @@ -597,16 +654,21 @@ fn build_client_hello( push_extension(0x002d, &[0x01, 0x01]); } - // key_share (x25519) - let key = gen_key_share( - rng, - deterministic, - &format!("tls-fetch-keyshare:{sni}:{}", profile.as_str()), - ); - let mut keyshare = Vec::with_capacity(4 + key.len()); - keyshare.extend_from_slice(&0x001du16.to_be_bytes()); - keyshare.extend_from_slice(&(key.len() as u16).to_be_bytes()); - keyshare.extend_from_slice(&key); + // key_share + let key_share_seed = format!("tls-fetch-keyshare:{sni}:{}", profile.as_str()); + let mut keyshare = Vec::new(); + if matches!( + profile, + TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike + ) { + if let Some(key) = + gen_x25519mlkem768_client_key_share(rng, deterministic, &key_share_seed) + { + push_client_key_share_entry(&mut keyshare, TLS_NAMED_GROUP_X25519MLKEM768, &key); + } + } + let key = gen_key_share(rng, deterministic, &key_share_seed); + push_client_key_share_entry(&mut keyshare, TLS_NAMED_GROUP_X25519, &key); let mut keyshare_ext = Vec::with_capacity(2 + keyshare.len()); keyshare_ext.extend_from_slice(&(keyshare.len() as u16).to_be_bytes()); keyshare_ext.extend_from_slice(&keyshare); @@ -1788,11 +1850,34 @@ mod tests { key_share_data.len() - 2, "key_share list length mismatch" ); - let group = u16::from_be_bytes([key_share_data[2], key_share_data[3]]); - let key_len = u16::from_be_bytes([key_share_data[4], key_share_data[5]]) as usize; - let key = &key_share_data[6..6 + key_len]; - assert_eq!(group, 0x001d, "key_share group must be x25519"); - assert_eq!(key_len, 32, "x25519 key length must be 32"); + let mut pos = 2usize; + let hybrid_group = u16::from_be_bytes([key_share_data[pos], key_share_data[pos + 1]]); + let hybrid_len = + u16::from_be_bytes([key_share_data[pos + 2], key_share_data[pos + 3]]) as usize; + pos += 4; + let hybrid_key = &key_share_data[pos..pos + hybrid_len]; + pos += hybrid_len; + assert_eq!( + hybrid_group, TLS_NAMED_GROUP_X25519MLKEM768, + "first key_share group must be X25519MLKEM768" + ); + assert_eq!( + hybrid_len, + MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + X25519_KEY_SHARE_LEN, + "hybrid key length must match X25519MLKEM768" + ); + assert!( + hybrid_key.iter().any(|b| *b != 0), + "hybrid key must not be all zero" + ); + + let group = u16::from_be_bytes([key_share_data[pos], key_share_data[pos + 1]]); + let key_len = + u16::from_be_bytes([key_share_data[pos + 2], key_share_data[pos + 3]]) as usize; + pos += 4; + let key = &key_share_data[pos..pos + key_len]; + assert_eq!(group, TLS_NAMED_GROUP_X25519, "second key_share group must be x25519"); + assert_eq!(key_len, X25519_KEY_SHARE_LEN, "x25519 key length must be 32"); assert!( key.iter().any(|b| *b != 0), "x25519 key must not be all zero" 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 f70f5cb..d1e6a1d 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -52,6 +52,10 @@ fn record_lengths_by_type(response: &[u8], wanted_type: u8) -> Vec { out } +fn test_server_key_share() -> Vec { + vec![0x42; 1120] +} + #[test] fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibility() { let cached = make_cached(); @@ -66,6 +70,7 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -91,6 +96,7 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, None, 0, @@ -114,6 +120,7 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index c3ef96d..3b6c428 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -44,6 +44,10 @@ fn first_app_data_payload(response: &[u8]) -> &[u8] { &response[app_start + 5..app_start + 5 + app_len] } +fn test_server_key_share() -> Vec { + vec![0x42; 1120] +} + #[test] fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { let cached = make_cached(None); @@ -59,6 +63,7 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, Some(oversized_alpn), 0, @@ -98,6 +103,7 @@ fn emulated_server_hello_keeps_alpn_marker_out_of_appdata() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], + &test_server_key_share(), &rng, Some(b"h2".to_vec()), 0, @@ -129,6 +135,7 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { true, ClientHelloTlsVersion::Tls12, [0x13, 0x01], + &test_server_key_share(), &rng, Some(b"h2".to_vec()), 0, From 3d0560d583855ec4eb58a6654928583c8381a8d9 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:43:03 +0300 Subject: [PATCH 06/10] Select ServerHello key share from TLS Fetcher Profile Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/protocol/tests/tls_security_tests.rs | 105 ++++++++++++-- src/protocol/tls.rs | 137 +++++++++++++++--- src/proxy/handshake.rs | 25 ++-- src/tls_front/emulator.rs | 94 ++++++++++-- ...mulator_profile_fidelity_security_tests.rs | 8 +- .../tests/emulator_security_tests.rs | 8 +- 6 files changed, 323 insertions(+), 54 deletions(-) diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 185eac7..23132bf 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1396,6 +1396,10 @@ fn server_hello_key_share(record: &[u8]) -> Option<(u16, usize)> { None } +fn test_server_key_share(group: u16, len: usize) -> ServerHelloKeyShare { + ServerHelloKeyShare::new(group, vec![0x42; len]) +} + #[test] fn build_server_hello_never_places_alpn_in_server_hello_extensions() { let secret = b"alpn_sh_forbidden"; @@ -1457,7 +1461,10 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { true, ClientHelloTlsVersion::Tls13, [0x13, 0x01], - &vec![0x42; X25519MLKEM768_SERVER_KEY_SHARE_LEN], + &test_server_key_share( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_SERVER_KEY_SHARE_LEN, + ), &rng, Some(b"h2".to_vec()), 0, @@ -1542,11 +1549,12 @@ fn test_build_server_hello_structure() { } #[test] -fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() { +fn test_build_server_hello_with_cipher_uses_selected_key_share_group() { let secret = b"test secret"; let client_digest = [0x42u8; 32]; let session_id = vec![0xAA; 32]; - let key_share = vec![0x55u8; X25519MLKEM768_SERVER_KEY_SHARE_LEN]; + let key_share = + ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519, vec![0x55u8; X25519_KEY_SHARE_LEN]); let rng = crate::crypto::SecureRandom::new(); let response = build_server_hello_with_cipher( @@ -1563,10 +1571,7 @@ fn test_build_server_hello_with_cipher_always_uses_hybrid_key_share() { assert_eq!( server_hello_key_share(&response), - Some(( - TLS_NAMED_GROUP_X25519MLKEM768, - X25519MLKEM768_SERVER_KEY_SHARE_LEN - )) + Some((TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)) ); } @@ -1887,6 +1892,23 @@ fn select_server_hello_key_share_group_prefers_hybrid_when_valid_share_is_offere ); } +#[test] +fn select_server_hello_key_share_group_prefers_profiled_x25519_when_valid_share_is_offered() { + let key_share = client_key_share_extension(&[ + ( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ), + (TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN), + ]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + + assert_eq!( + select_server_hello_key_share_group_with_preference(&ch, Some(TLS_NAMED_GROUP_X25519)), + Some(TLS_NAMED_GROUP_X25519) + ); +} + #[test] fn build_x25519mlkem768_server_key_share_accepts_tdesktop_canonical_share() { let key_share = client_key_share_extension(&[ @@ -1917,6 +1939,68 @@ fn build_x25519mlkem768_server_key_share_accepts_tdesktop_canonical_share() { ); } +#[test] +fn build_x25519_server_key_share_accepts_tdesktop_fallback_share() { + let key_share = client_key_share_extension(&[ + ( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ), + (TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN), + ]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + let server_key_share = build_x25519_server_key_share(&ch, &rng) + .expect("tdesktop-like X25519 share must build a ServerHello share"); + + assert_eq!(server_key_share.len(), X25519_KEY_SHARE_LEN); + assert!( + server_key_share.iter().any(|byte| *byte != 0), + "X25519 server share must not be all zero" + ); +} + +#[test] +fn build_server_hello_key_share_prefers_profiled_x25519() { + let key_share = client_key_share_extension(&[ + ( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + ), + (TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN), + ]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + let server_key_share = + build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) + .expect("profiled X25519 share must be selected when client offers it"); + + assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519); + assert_eq!(server_key_share.key_exchange().len(), X25519_KEY_SHARE_LEN); +} + +#[test] +fn build_server_hello_key_share_falls_back_from_bad_profiled_x25519_to_hybrid() { + let key_share = client_key_share_extension(&[( + TLS_NAMED_GROUP_X25519MLKEM768, + X25519MLKEM768_CLIENT_KEY_SHARE_LEN, + )]); + let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); + let rng = crate::crypto::SecureRandom::new(); + + let server_key_share = + build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) + .expect("hybrid share must be selected when profiled X25519 is unavailable"); + + assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519MLKEM768); + assert_eq!( + server_key_share.key_exchange().len(), + X25519MLKEM768_SERVER_KEY_SHARE_LEN + ); +} + #[test] fn build_x25519mlkem768_server_key_share_rejects_noncanonical_mlkem_key() { let mut key_exchange = vec![0x42; X25519MLKEM768_CLIENT_KEY_SHARE_LEN]; @@ -1946,12 +2030,15 @@ fn build_x25519mlkem768_server_key_share_rejects_all_zero_x25519_share() { } #[test] -fn select_server_hello_key_share_group_rejects_without_hybrid_share() { +fn select_server_hello_key_share_group_accepts_x25519_when_hybrid_is_absent() { let key_share = client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]); let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); - assert_eq!(select_server_hello_key_share_group(&ch), None); + assert_eq!( + select_server_hello_key_share_group(&ch), + Some(TLS_NAMED_GROUP_X25519) + ); } #[test] diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index 0872b21..c19d8be 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -127,6 +127,30 @@ const X25519MLKEM768_SERVER_KEY_SHARE_LEN: usize = 1120; const MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN: usize = 1184; const MLKEM768_SERVER_CIPHERTEXT_LEN: usize = 1088; +/// ServerHello key_share selected for the authenticated ClientHello. +#[derive(Clone, Debug)] +pub(crate) struct ServerHelloKeyShare { + group: u16, + key_exchange: Vec, +} + +impl ServerHelloKeyShare { + pub(crate) fn new(group: u16, key_exchange: Vec) -> Self { + Self { + group, + key_exchange, + } + } + + pub(crate) fn group(&self) -> u16 { + self.group + } + + pub(crate) fn key_exchange(&self) -> &[u8] { + &self.key_exchange + } +} + // ============= TLS Validation Result ============= /// Result of validating TLS handshake @@ -588,6 +612,65 @@ pub(crate) fn build_x25519mlkem768_server_key_share( Some(key_share) } +/// Build a valid X25519 ServerHello key_share for the authenticated ClientHello. +pub(crate) fn build_x25519_server_key_share( + handshake: &[u8], + rng: &SecureRandom, +) -> Option> { + let client_key_exchange = + client_hello_key_share_group_entry(handshake, TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)?; + let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN]; + client_x25519.copy_from_slice(client_key_exchange); + let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng); + let x25519_shared = x25519(server_x25519_scalar, client_x25519); + if bool::from(x25519_shared.ct_eq(&[0u8; X25519_KEY_SHARE_LEN])) { + return None; + } + + Some(server_x25519_key.to_vec()) +} + +fn build_server_hello_key_share_for_group( + handshake: &[u8], + group: u16, + rng: &SecureRandom, +) -> Option { + match group { + TLS_NAMED_GROUP_X25519MLKEM768 => { + let key_exchange = build_x25519mlkem768_server_key_share(handshake, rng)?; + Some(ServerHelloKeyShare::new(group, key_exchange)) + } + TLS_NAMED_GROUP_X25519 => { + let key_exchange = build_x25519_server_key_share(handshake, rng)?; + Some(ServerHelloKeyShare::new(group, key_exchange)) + } + _ => None, + } +} + +fn server_hello_key_share_candidate_order(preferred_group: Option) -> [u16; 2] { + if preferred_group == Some(TLS_NAMED_GROUP_X25519) { + [TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768] + } else { + [TLS_NAMED_GROUP_X25519MLKEM768, TLS_NAMED_GROUP_X25519] + } +} + +/// Build a ServerHello key_share using a profile-preferred group when possible. +pub(crate) fn build_server_hello_key_share( + handshake: &[u8], + preferred_group: Option, + rng: &SecureRandom, +) -> Option { + for group in server_hello_key_share_candidate_order(preferred_group) { + if let Some(key_share) = build_server_hello_key_share_for_group(handshake, group, rng) { + return Some(key_share); + } + } + + None +} + /// Build TLS ServerHello response /// /// This builds a complete TLS 1.3-like response including: @@ -605,6 +688,10 @@ pub fn build_server_hello( alpn: Option>, new_session_tickets: u8, ) -> Vec { + let server_key_share = ServerHelloKeyShare::new( + TLS_NAMED_GROUP_X25519MLKEM768, + gen_fake_x25519mlkem768_server_key_share(rng), + ); build_server_hello_with_cipher( secret, client_digest, @@ -612,7 +699,7 @@ pub fn build_server_hello( fake_cert_len, rng, cipher_suite::TLS_AES_128_GCM_SHA256, - &gen_fake_x25519mlkem768_server_key_share(rng), + &server_key_share, alpn, new_session_tickets, ) @@ -630,7 +717,7 @@ pub(crate) fn build_server_hello_with_cipher( fake_cert_len: usize, rng: &SecureRandom, selected_cipher_suite: [u8; 2], - server_key_share: &[u8], + server_key_share: &ServerHelloKeyShare, alpn: Option>, new_session_tickets: u8, ) -> Vec { @@ -641,7 +728,7 @@ pub(crate) fn build_server_hello_with_cipher( // Build ServerHello let server_hello = ServerHelloBuilder::new(session_id.to_vec()) .with_cipher_suite(selected_cipher_suite) - .with_key_share(TLS_NAMED_GROUP_X25519MLKEM768, server_key_share) + .with_key_share(server_key_share.group(), server_key_share.key_exchange()) .with_tls13_version() .build_record(); @@ -1281,24 +1368,38 @@ pub(crate) fn select_server_hello_cipher_suite( None } -/// Select the hybrid ServerHello key_share named group from the authenticated ClientHello. -/// -/// Malformed or non-hybrid key_share structures fail closed so authenticated -/// but DPI-inconsistent ClientHellos take the ordinary masking fallback path. -pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option { - if client_hello_key_share_group_entry( - handshake, - TLS_NAMED_GROUP_X25519MLKEM768, - X25519MLKEM768_CLIENT_KEY_SHARE_LEN, - ) - .is_some() - { - Some(TLS_NAMED_GROUP_X25519MLKEM768) - } else { - None +fn client_hello_key_share_group_len(group: u16) -> Option { + match group { + TLS_NAMED_GROUP_X25519MLKEM768 => Some(X25519MLKEM768_CLIENT_KEY_SHARE_LEN), + TLS_NAMED_GROUP_X25519 => Some(X25519_KEY_SHARE_LEN), + _ => None, } } +/// Select the ServerHello key_share named group from the authenticated ClientHello. +/// +/// Malformed key_share structures fail closed so authenticated but +/// DPI-inconsistent ClientHellos take the ordinary masking fallback path. +pub(crate) fn select_server_hello_key_share_group(handshake: &[u8]) -> Option { + select_server_hello_key_share_group_with_preference(handshake, None) +} + +/// Select the ServerHello key_share named group with an origin-profile preference. +pub(crate) fn select_server_hello_key_share_group_with_preference( + handshake: &[u8], + preferred_group: Option, +) -> Option { + for group in server_hello_key_share_candidate_order(preferred_group) { + let expected_key_exchange_len = client_hello_key_share_group_len(group)?; + if client_hello_key_share_group_entry(handshake, group, expected_key_exchange_len).is_some() + { + return Some(group); + } + } + + None +} + /// 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 6db4ce4..40d465d 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1473,16 +1473,6 @@ where return HandshakeResult::BadClient { reader, writer }; } - let Some(server_key_share) = tls::build_x25519mlkem768_server_key_share(handshake, rng) else { - auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); - maybe_apply_server_hello_delay(config).await; - debug!( - peer = %peer, - "TLS handshake rejected: ClientHello did not offer a usable X25519MLKEM768 key_share" - ); - return HandshakeResult::BadClient { reader, writer }; - }; - let cached_entry = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { let selected_domain = @@ -1496,6 +1486,21 @@ where None }; + let preferred_key_share_group = cached_entry + .as_ref() + .and_then(|cached_entry| emulator::profiled_server_hello_key_share_group(cached_entry)); + let Some(server_key_share) = + tls::build_server_hello_key_share(handshake, preferred_key_share_group, rng) + else { + auth_probe_record_failure_in(shared, peer.ip(), Instant::now()); + maybe_apply_server_hello_delay(config).await; + debug!( + peer = %peer, + "TLS handshake rejected: ClientHello did not offer a usable TLS 1.3 key_share" + ); + return HandshakeResult::BadClient { reader, writer }; + }; + let preferred_cipher_suite = if let Some(cached_entry) = cached_entry.as_ref() { if cached_entry.server_hello_template.cipher_suite == [0, 0] { [0x13, 0x01] diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 4f93f77..d3fdf46 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -6,7 +6,8 @@ use crate::protocol::constants::{ TLS_RECORD_HANDSHAKE, TLS_VERSION, }; use crate::protocol::tls::{ - ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, TLS_NAMED_GROUP_X25519MLKEM768, + ClientHelloTlsVersion, ServerHelloKeyShare, TLS_DIGEST_LEN, TLS_DIGEST_POS, + TLS_NAMED_GROUP_X25519, TLS_NAMED_GROUP_X25519MLKEM768, }; use crate::tls_front::types::{ CachedTlsData, ParsedCertificateInfo, TlsExtension, TlsProfileSource, @@ -20,6 +21,40 @@ const EXT_SUPPORTED_VERSIONS: u16 = 0x002b; const EXT_KEY_SHARE: u16 = 0x0033; const EXT_ALPN: u16 = 0x0010; +fn parse_profiled_key_share_group(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + + let group = u16::from_be_bytes([data[0], data[1]]); + let key_exchange_len = u16::from_be_bytes([data[2], data[3]]) as usize; + if data.len() != 4 + key_exchange_len { + return None; + } + + match group { + TLS_NAMED_GROUP_X25519 | TLS_NAMED_GROUP_X25519MLKEM768 => Some(group), + _ => None, + } +} + +/// Return the origin-profiled ServerHello key_share group when it is replay-safe. +pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option { + if !matches!( + cached.behavior_profile.source, + TlsProfileSource::Raw | TlsProfileSource::Merged + ) { + return None; + } + + cached + .server_hello_template + .extensions + .iter() + .find(|ext| ext.ext_type == EXT_KEY_SHARE) + .and_then(|ext| parse_profiled_key_share_group(&ext.data)) +} + fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { sizes .iter() @@ -208,18 +243,18 @@ fn push_key_share_entry(extensions: &mut Vec, group: u16, key_exchange: &[u8 extensions.extend_from_slice(key_exchange); } -fn push_key_share_extension(extensions: &mut Vec, server_key_share: &[u8]) { +fn push_key_share_extension(extensions: &mut Vec, server_key_share: &ServerHelloKeyShare) { push_key_share_entry( extensions, - TLS_NAMED_GROUP_X25519MLKEM768, - server_key_share, + server_key_share.group(), + server_key_share.key_exchange(), ); } fn replay_profiled_server_hello_extension( ext: &TlsExtension, extensions: &mut Vec, - server_key_share: &[u8], + server_key_share: &ServerHelloKeyShare, saw_supported_versions: &mut bool, saw_key_share: &mut bool, ) { @@ -239,7 +274,7 @@ fn replay_profiled_server_hello_extension( fn build_profiled_server_hello_extensions( cached: &CachedTlsData, - server_key_share: &[u8], + server_key_share: &ServerHelloKeyShare, ) -> Vec { let capacity = cached .server_hello_template @@ -282,7 +317,7 @@ pub fn build_emulated_server_hello( serverhello_compact: bool, client_tls_version: ClientHelloTlsVersion, selected_cipher_suite: [u8; 2], - server_key_share: &[u8], + server_key_share: &ServerHelloKeyShare, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, @@ -469,13 +504,16 @@ mod tests { use super::{ build_compact_cert_info_payload, build_emulated_server_hello, - hash_compact_cert_info_payload, + hash_compact_cert_info_payload, profiled_server_hello_key_share_group, }; use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; - use crate::protocol::tls::ClientHelloTlsVersion; + use crate::protocol::tls::{ + ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519, + TLS_NAMED_GROUP_X25519MLKEM768, + }; fn first_app_data_payload(response: &[u8]) -> &[u8] { let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; @@ -540,8 +578,42 @@ mod tests { } } - fn test_server_key_share() -> Vec { - vec![0x42; 1120] + fn test_server_key_share() -> ServerHelloKeyShare { + ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120]) + } + + fn server_key_share_extension_data(group: u16, len: usize) -> Vec { + let mut data = Vec::new(); + data.extend_from_slice(&group.to_be_bytes()); + data.extend_from_slice(&(len as u16).to_be_bytes()); + data.resize(4 + len, 0x42); + data + } + + #[test] + fn profiled_server_hello_key_share_group_reads_raw_x25519_profile() { + let mut cached = make_cached(None); + cached.behavior_profile.source = TlsProfileSource::Raw; + cached.server_hello_template.extensions = vec![TlsExtension { + ext_type: 0x0033, + data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), + }]; + + assert_eq!( + profiled_server_hello_key_share_group(&cached), + Some(TLS_NAMED_GROUP_X25519) + ); + } + + #[test] + fn profiled_server_hello_key_share_group_ignores_default_profile() { + let mut cached = make_cached(None); + cached.server_hello_template.extensions = vec![TlsExtension { + ext_type: 0x0033, + data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), + }]; + + assert_eq!(profiled_server_hello_key_share_group(&cached), None); } #[test] 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 d1e6a1d..b93a746 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -4,7 +4,9 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::ClientHelloTlsVersion; +use crate::protocol::tls::{ + ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519MLKEM768, +}; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, @@ -52,8 +54,8 @@ fn record_lengths_by_type(response: &[u8], wanted_type: u8) -> Vec { out } -fn test_server_key_share() -> Vec { - vec![0x42; 1120] +fn test_server_key_share() -> ServerHelloKeyShare { + ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120]) } #[test] diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index 3b6c428..db867b9 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -4,7 +4,9 @@ use crate::crypto::SecureRandom; use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; -use crate::protocol::tls::ClientHelloTlsVersion; +use crate::protocol::tls::{ + ClientHelloTlsVersion, ServerHelloKeyShare, TLS_NAMED_GROUP_X25519MLKEM768, +}; use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, @@ -44,8 +46,8 @@ fn first_app_data_payload(response: &[u8]) -> &[u8] { &response[app_start + 5..app_start + 5 + app_len] } -fn test_server_key_share() -> Vec { - vec![0x42; 1120] +fn test_server_key_share() -> ServerHelloKeyShare { + ServerHelloKeyShare::new(TLS_NAMED_GROUP_X25519MLKEM768, vec![0x42; 1120]) } #[test] From 409b0ef5ee4517b58e6e5c507ab8a841f428870e Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:53:21 +0300 Subject: [PATCH 07/10] Expose TLS Fetcher Profile Quality for ServerHello fidelity Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/metrics.rs | 61 +++++++++- src/protocol/tests/tls_security_tests.rs | 1 + src/tls_front/cache.rs | 64 ++++++++-- src/tls_front/fetcher.rs | 9 +- ...mulator_profile_fidelity_security_tests.rs | 1 + .../tests/emulator_security_tests.rs | 1 + src/tls_front/types.rs | 112 ++++++++++++++++++ 7 files changed, 238 insertions(+), 11 deletions(-) diff --git a/src/metrics.rs b/src/metrics.rs index b4baad0..c78d0e0 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -381,11 +381,32 @@ async fn render_tls_front_profile_health( "# HELP telemt_tls_front_profile_info TLS front profile source and feature flags per configured domain" ); let _ = writeln!(out, "# TYPE telemt_tls_front_profile_info gauge"); + let _ = writeln!( + out, + "# HELP telemt_tls_front_profile_quality_info TLS front profile quality and key-share group per configured domain" + ); + let _ = writeln!(out, "# TYPE telemt_tls_front_profile_quality_info gauge"); let _ = writeln!( out, "# HELP telemt_tls_front_profile_age_seconds Age of cached TLS front profile data per configured domain" ); let _ = writeln!(out, "# TYPE telemt_tls_front_profile_age_seconds gauge"); + let _ = writeln!( + out, + "# HELP telemt_tls_front_profile_server_hello_bytes TLS front cached ServerHello record body bytes per configured domain" + ); + let _ = writeln!( + out, + "# TYPE telemt_tls_front_profile_server_hello_bytes gauge" + ); + let _ = writeln!( + out, + "# HELP telemt_tls_front_profile_server_hello_extensions TLS front cached visible ServerHello extension count per configured domain" + ); + let _ = writeln!( + out, + "# TYPE telemt_tls_front_profile_server_hello_extensions gauge" + ); let _ = writeln!( out, "# HELP telemt_tls_front_profile_app_data_records TLS front cached app-data record count per configured domain" @@ -420,11 +441,26 @@ async fn render_tls_front_profile_health( "telemt_tls_front_profile_info{{domain=\"{}\",source=\"{}\",is_default=\"{}\",has_cert_info=\"{}\",has_cert_payload=\"{}\"}} 1", domain, item.source, item.is_default, item.has_cert_info, item.has_cert_payload ); + let _ = writeln!( + out, + "telemt_tls_front_profile_quality_info{{domain=\"{}\",quality=\"{}\",key_share_group=\"{}\"}} 1", + domain, item.quality, item.key_share_group + ); let _ = writeln!( out, "telemt_tls_front_profile_age_seconds{{domain=\"{}\"}} {}", domain, item.age_seconds ); + let _ = writeln!( + out, + "telemt_tls_front_profile_server_hello_bytes{{domain=\"{}\"}} {}", + domain, item.server_hello_record_len + ); + let _ = writeln!( + out, + "telemt_tls_front_profile_server_hello_extensions{{domain=\"{}\"}} {}", + domain, item.server_hello_extensions + ); let _ = writeln!( out, "telemt_tls_front_profile_app_data_records{{domain=\"{}\"}} {}", @@ -3901,7 +3937,10 @@ mod tests { session_id: Vec::new(), cipher_suite: [0x13, 0x01], compression: 0, - extensions: Vec::new(), + extensions: vec![crate::tls_front::types::TlsExtension { + ext_type: 0x0033, + data: vec![0x00, 0x1d, 0x00, 0x20], + }], }, cert_info: None, cert_payload: Some(TlsCertPayload { @@ -3915,6 +3954,7 @@ mod tests { app_data_record_sizes: vec![1024, 512], ticket_record_sizes: vec![69], source: TlsProfileSource::Merged, + ..TlsBehaviorProfile::default() }, fetched_at: SystemTime::now(), domain: "primary.example".to_string(), @@ -3933,6 +3973,22 @@ mod tests { assert!( output.contains("telemt_tls_front_profile_info{domain=\"fallback.example\",source=\"default\",is_default=\"true\",has_cert_info=\"false\",has_cert_payload=\"false\"} 1") ); + assert!( + output.contains("telemt_tls_front_profile_quality_info{domain=\"primary.example\",quality=\"raw_strict\",key_share_group=\"x25519\"} 1") + ); + assert!( + output.contains("telemt_tls_front_profile_quality_info{domain=\"fallback.example\",quality=\"fallback\",key_share_group=\"none\"} 1") + ); + assert!( + output.contains( + "telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 52" + ) + ); + assert!( + output.contains( + "telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 1" + ) + ); assert!( output.contains( "telemt_tls_front_profile_app_data_records{domain=\"primary.example\"} 2" @@ -4045,7 +4101,10 @@ mod tests { ); assert!(output.contains("# TYPE telemt_tls_front_profile_domains gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_info gauge")); + assert!(output.contains("# TYPE telemt_tls_front_profile_quality_info gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_age_seconds gauge")); + assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_bytes gauge")); + assert!(output.contains("# TYPE telemt_tls_front_profile_server_hello_extensions gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_app_data_records gauge")); assert!(output.contains("# TYPE telemt_tls_front_profile_ticket_records gauge")); assert!( diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 23132bf..da9679a 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1447,6 +1447,7 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { app_data_record_sizes: vec![1024], ticket_record_sizes: Vec::new(), source: TlsProfileSource::Default, + ..TlsBehaviorProfile::default() }, fetched_at: SystemTime::now(), domain: "example.com".to_string(), diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index bce33c4..b878ae9 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -12,7 +12,8 @@ use tokio::time::sleep; use tracing::{debug, info, warn}; use crate::tls_front::types::{ - CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileSource, + CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, TlsProfileQuality, + TlsProfileSource, }; const FULL_CERT_SENT_SWEEP_INTERVAL_SECS: u64 = 30; @@ -47,10 +48,14 @@ pub struct TlsFrontCache { pub(crate) struct TlsFrontProfileHealth { pub(crate) domain: String, pub(crate) source: &'static str, + pub(crate) quality: &'static str, + pub(crate) key_share_group: &'static str, pub(crate) age_seconds: u64, pub(crate) is_default: bool, pub(crate) has_cert_info: bool, pub(crate) has_cert_payload: bool, + pub(crate) server_hello_record_len: usize, + pub(crate) server_hello_extensions: usize, pub(crate) app_data_records: usize, pub(crate) ticket_records: usize, pub(crate) change_cipher_spec_count: u8, @@ -66,6 +71,23 @@ fn profile_source_label(source: TlsProfileSource) -> &'static str { } } +fn profile_quality_label(quality: TlsProfileQuality) -> &'static str { + match quality { + TlsProfileQuality::Fallback => "fallback", + TlsProfileQuality::RawPartial => "raw_partial", + TlsProfileQuality::RawStrict => "raw_strict", + } +} + +fn key_share_group_label(group: Option) -> &'static str { + match group { + Some(0x001d) => "x25519", + Some(0x11ec) => "x25519mlkem768", + Some(_) => "other", + None => "none", + } +} + #[allow(dead_code)] impl TlsFrontCache { pub fn new(domains: &[String], default_len: usize, disk_path: impl AsRef) -> Self { @@ -137,7 +159,8 @@ impl TlsFrontCache { .get(domain) .cloned() .unwrap_or_else(|| self.default.clone()); - let behavior = &cached.behavior_profile; + let mut behavior = cached.behavior_profile.clone(); + behavior.refresh_server_hello_summary(&cached.server_hello_template); let age_seconds = now .duration_since(cached.fetched_at) .map(|duration| duration.as_secs()) @@ -146,10 +169,14 @@ impl TlsFrontCache { snapshot.push(TlsFrontProfileHealth { domain: domain.clone(), source: profile_source_label(behavior.source), + quality: profile_quality_label(behavior.quality), + key_share_group: key_share_group_label(behavior.server_hello_key_share_group), age_seconds, is_default: cached.domain == "default", has_cert_info: cached.cert_info.is_some(), has_cert_payload: cached.cert_payload.is_some(), + server_hello_record_len: behavior.server_hello_record_len, + server_hello_extensions: behavior.server_hello_extension_types.len(), app_data_records: cached .app_data_records_sizes .len() @@ -378,20 +405,39 @@ impl TlsFrontCache { /// Replace cached entry from a fetch result. pub async fn update_from_fetch(&self, domain: &str, fetched: TlsFetchResult) { + let TlsFetchResult { + server_hello_parsed, + app_data_records_sizes, + total_app_data_len, + mut behavior_profile, + cert_info, + cert_payload, + } = fetched; + behavior_profile.refresh_server_hello_summary(&server_hello_parsed); + let quality = behavior_profile.quality; let data = CachedTlsData { - server_hello_template: fetched.server_hello_parsed, - cert_info: fetched.cert_info, - cert_payload: fetched.cert_payload, - app_data_records_sizes: fetched.app_data_records_sizes.clone(), - total_app_data_len: fetched.total_app_data_len, - behavior_profile: fetched.behavior_profile, + server_hello_template: server_hello_parsed, + cert_info, + cert_payload, + app_data_records_sizes: app_data_records_sizes.clone(), + total_app_data_len, + behavior_profile, fetched_at: SystemTime::now(), domain: domain.to_string(), }; self.set(domain, data.clone()).await; self.persist(domain, &data).await; - debug!(domain = %domain, len = fetched.total_app_data_len, "TLS cache updated"); + if quality == TlsProfileQuality::RawStrict { + debug!(domain = %domain, len = total_app_data_len, "TLS cache updated"); + } else { + warn!( + domain = %domain, + quality = profile_quality_label(quality), + len = total_app_data_len, + "TLS cache updated with non-strict front profile" + ); + } } pub fn default_entry(&self) -> Arc { diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index fdcb8c1..a70ce12 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -838,6 +838,7 @@ fn derive_behavior_profile(records: &[(u8, Vec)]) -> TlsBehaviorProfile { app_data_record_sizes, ticket_record_sizes, source: TlsProfileSource::Raw, + ..TlsBehaviorProfile::default() } } @@ -1087,14 +1088,18 @@ where } let mut server_hello = None; + let mut server_hello_record_len = 0usize; for (t, body) in &records { if *t == TLS_RECORD_HANDSHAKE && server_hello.is_none() { server_hello = parse_server_hello(body); + server_hello_record_len = body.len(); } } let parsed = server_hello.ok_or_else(|| anyhow!("ServerHello not received"))?; - let behavior_profile = derive_behavior_profile(&records); + let mut behavior_profile = derive_behavior_profile(&records); + behavior_profile.server_hello_record_len = server_hello_record_len; + behavior_profile.refresh_server_hello_summary(&parsed); let mut app_sizes = behavior_profile.app_data_record_sizes.clone(); app_sizes.extend_from_slice(&behavior_profile.ticket_record_sizes); let total_app_data_len = app_sizes.iter().sum::().max(1024); @@ -1272,6 +1277,7 @@ where app_data_record_sizes: app_data_records_sizes, ticket_record_sizes: Vec::new(), source: TlsProfileSource::Rustls, + ..TlsBehaviorProfile::default() }, cert_info, cert_payload, @@ -1471,6 +1477,7 @@ pub async fn fetch_real_tls_with_strategy( raw.cert_info = rustls.cert_info; raw.cert_payload = rustls.cert_payload; raw.behavior_profile.source = TlsProfileSource::Merged; + raw.behavior_profile.refresh_quality(); debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain"); Ok(raw) } else { 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 b93a746..50a78fc 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -31,6 +31,7 @@ fn make_cached() -> CachedTlsData { app_data_record_sizes: vec![1200, 900], ticket_record_sizes: vec![220, 180], source: TlsProfileSource::Merged, + ..TlsBehaviorProfile::default() }, fetched_at: SystemTime::now(), domain: "example.com".to_string(), diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index db867b9..0a8cdcb 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -31,6 +31,7 @@ fn make_cached(cert_payload: Option) -> app_data_record_sizes: vec![64], ticket_record_sizes: Vec::new(), source: TlsProfileSource::Default, + ..TlsBehaviorProfile::default() }, fetched_at: SystemTime::now(), domain: "example.com".to_string(), diff --git a/src/tls_front/types.rs b/src/tls_front/types.rs index 48e44f3..e85b571 100644 --- a/src/tls_front/types.rs +++ b/src/tls_front/types.rs @@ -1,6 +1,10 @@ use serde::{Deserialize, Serialize}; use std::time::SystemTime; +const EXT_KEY_SHARE: u16 = 0x0033; +const TLS_NAMED_GROUP_X25519: u16 = 0x001d; +const TLS_NAMED_GROUP_X25519MLKEM768: u16 = 0x11ec; + /// Parsed representation of an unencrypted TLS ServerHello. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParsedServerHello { @@ -19,6 +23,52 @@ pub struct TlsExtension { pub data: Vec, } +impl ParsedServerHello { + /// Return the TLS record body length that would contain this ServerHello. + pub(crate) fn record_body_len(&self) -> usize { + let extensions_len = self + .extensions + .iter() + .map(|extension| 4 + extension.data.len()) + .sum::(); + + 4 + 2 + 32 + 1 + self.session_id.len() + 2 + 1 + 2 + extensions_len + } + + /// Return visible ServerHello extension types in wire order. + pub(crate) fn extension_types(&self) -> Vec { + self.extensions + .iter() + .map(|extension| extension.ext_type) + .collect() + } + + /// Return a replay-safe ServerHello key_share group when the extension is well-formed. + pub(crate) fn key_share_group(&self) -> Option { + self.extensions + .iter() + .find(|extension| extension.ext_type == EXT_KEY_SHARE) + .and_then(|extension| parse_key_share_group(&extension.data)) + } +} + +fn parse_key_share_group(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + + let group = u16::from_be_bytes([data[0], data[1]]); + let key_exchange_len = u16::from_be_bytes([data[2], data[3]]) as usize; + if data.len() != 4 + key_exchange_len { + return None; + } + + match group { + TLS_NAMED_GROUP_X25519 | TLS_NAMED_GROUP_X25519MLKEM768 => Some(group), + _ => None, + } +} + /// Basic certificate metadata (optional, informative). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ParsedCertificateInfo { @@ -54,6 +104,19 @@ pub enum TlsProfileSource { Merged, } +/// DPI-facing quality class of a cached TLS front profile. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TlsProfileQuality { + /// No raw origin ServerHello shape is available. + #[default] + Fallback, + /// Raw origin ServerHello was captured, but encrypted flight shape is incomplete. + RawPartial, + /// Raw origin ServerHello and encrypted flight record sizes were captured. + RawStrict, +} + /// Coarse-grained TLS response behavior captured per SNI. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TlsBehaviorProfile { @@ -69,6 +132,18 @@ pub struct TlsBehaviorProfile { /// Source of this behavior profile. #[serde(default)] pub source: TlsProfileSource, + /// DPI-facing quality of this profile. + #[serde(default)] + pub quality: TlsProfileQuality, + /// Captured ServerHello TLS record body length. + #[serde(default)] + pub server_hello_record_len: usize, + /// Captured visible ServerHello extension types in wire order. + #[serde(default)] + pub server_hello_extension_types: Vec, + /// Captured ServerHello key_share group when replay-safe. + #[serde(default)] + pub server_hello_key_share_group: Option, } fn default_change_cipher_spec_count() -> u8 { @@ -82,10 +157,46 @@ impl Default for TlsBehaviorProfile { app_data_record_sizes: Vec::new(), ticket_record_sizes: Vec::new(), source: TlsProfileSource::Default, + quality: TlsProfileQuality::Fallback, + server_hello_record_len: 0, + server_hello_extension_types: Vec::new(), + server_hello_key_share_group: None, } } } +impl TlsBehaviorProfile { + /// Refresh cached visible ServerHello summary fields and quality. + pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) { + if matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) { + if self.server_hello_record_len == 0 { + self.server_hello_record_len = server_hello.record_body_len(); + } + self.server_hello_extension_types = server_hello.extension_types(); + self.server_hello_key_share_group = server_hello.key_share_group(); + } else { + self.server_hello_record_len = 0; + self.server_hello_extension_types.clear(); + self.server_hello_key_share_group = None; + } + + self.refresh_quality(); + } + + /// Recompute the profile quality from current source and record-size evidence. + pub(crate) fn refresh_quality(&mut self) { + let has_raw_server_hello = matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) + && self.server_hello_record_len > 0; + self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() { + TlsProfileQuality::RawStrict + } else if has_raw_server_hello { + TlsProfileQuality::RawPartial + } else { + TlsProfileQuality::Fallback + }; + } +} + /// Cached data per SNI used by the emulator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedTlsData { @@ -147,5 +258,6 @@ mod tests { assert!(cached.behavior_profile.app_data_record_sizes.is_empty()); assert!(cached.behavior_profile.ticket_record_sizes.is_empty()); assert_eq!(cached.behavior_profile.source, TlsProfileSource::Default); + assert_eq!(cached.behavior_profile.quality, TlsProfileQuality::Fallback); } } From 6dc9f8c27af9a837c4127e04b1e13331df7daddd Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:11:41 +0300 Subject: [PATCH 08/10] Replay-safe TLS-F ServerHello profile consistency Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/metrics.rs | 22 +++- src/tls_front/cache.rs | 3 + src/tls_front/emulator.rs | 231 +++++++++++++++++++++++++++++++++----- src/tls_front/fetcher.rs | 3 +- src/tls_front/types.rs | 141 ++++++++++++++++++++++- 5 files changed, 362 insertions(+), 38 deletions(-) diff --git a/src/metrics.rs b/src/metrics.rs index c78d0e0..3c42508 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -3937,10 +3937,20 @@ mod tests { session_id: Vec::new(), cipher_suite: [0x13, 0x01], compression: 0, - extensions: vec![crate::tls_front::types::TlsExtension { - ext_type: 0x0033, - data: vec![0x00, 0x1d, 0x00, 0x20], - }], + extensions: { + let mut key_share = vec![0x00, 0x1d, 0x00, 0x20]; + key_share.resize(36, 0x42); + vec![ + crate::tls_front::types::TlsExtension { + ext_type: 0x002b, + data: vec![0x03, 0x04], + }, + crate::tls_front::types::TlsExtension { + ext_type: 0x0033, + data: key_share, + }, + ] + }, }, cert_info: None, cert_payload: Some(TlsCertPayload { @@ -3981,12 +3991,12 @@ mod tests { ); assert!( output.contains( - "telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 52" + "telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 90" ) ); assert!( output.contains( - "telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 1" + "telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 2" ) ); assert!( diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index b878ae9..c63a539 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -364,6 +364,9 @@ impl TlsFrontCache { warn!(domain = %cached.domain, "Skipping stale TLS cache entry (>72h)"); continue; } + cached + .behavior_profile + .refresh_server_hello_summary(&cached.server_hello_template); let domain = cached.domain.clone(); self.set(&domain, cached).await; loaded += 1; diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index d3fdf46..9a5ee49 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -21,6 +21,13 @@ const EXT_SUPPORTED_VERSIONS: u16 = 0x002b; const EXT_KEY_SHARE: u16 = 0x0033; const EXT_ALPN: u16 = 0x0010; +#[derive(Clone, Copy)] +enum FallbackShapeFamily { + NginxLike, + BoringSslLike, + RustlsLike, +} + fn parse_profiled_key_share_group(data: &[u8]) -> Option { if data.len() < 4 { return None; @@ -38,12 +45,26 @@ fn parse_profiled_key_share_group(data: &[u8]) -> Option { } } -/// Return the origin-profiled ServerHello key_share group when it is replay-safe. -pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option { - if !matches!( +fn effective_profiled_server_hello_record_len(cached: &CachedTlsData) -> usize { + if cached.behavior_profile.server_hello_record_len == 0 { + cached.server_hello_template.record_body_len() + } else { + cached.behavior_profile.server_hello_record_len + } +} + +fn should_replay_profiled_server_hello_shape(cached: &CachedTlsData) -> bool { + matches!( cached.behavior_profile.source, TlsProfileSource::Raw | TlsProfileSource::Merged - ) { + ) && cached.server_hello_template.is_replay_safe_tls13_shape( + effective_profiled_server_hello_record_len(cached), + ) +} + +/// Return the origin-profiled ServerHello key_share group when it is replay-safe. +pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option { + if !should_replay_profiled_server_hello_shape(cached) { return None; } @@ -105,6 +126,76 @@ fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec FallbackShapeFamily { + match cached.behavior_profile.source { + TlsProfileSource::Rustls => FallbackShapeFamily::RustlsLike, + TlsProfileSource::Default => { + let mut hasher = Hasher::new(); + hasher.update(cached.domain.as_bytes()); + hasher.update(&cached.total_app_data_len.to_le_bytes()); + if hasher.finalize() & 1 == 0 { + FallbackShapeFamily::NginxLike + } else { + FallbackShapeFamily::BoringSslLike + } + } + TlsProfileSource::Raw | TlsProfileSource::Merged => FallbackShapeFamily::NginxLike, + } +} + +fn fallback_total_app_data_len(cached: &CachedTlsData) -> usize { + cached + .total_app_data_len + .max(cached.app_data_records_sizes.iter().sum()) + .max(1024) +} + +fn push_fallback_size(sizes: &mut Vec, size: usize) { + sizes.push(size.clamp(MIN_APP_DATA, MAX_APP_DATA)); +} + +fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec { + if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls) + && !cached.app_data_records_sizes.is_empty() + { + return cached.app_data_records_sizes.clone(); + } + + let family = fallback_shape_family(cached); + let mut remaining = fallback_total_app_data_len(cached); + let preferred_chunk = match family { + FallbackShapeFamily::NginxLike => 2896, + FallbackShapeFamily::BoringSslLike => 1369, + FallbackShapeFamily::RustlsLike => 2048, + }; + let split_threshold = match family { + FallbackShapeFamily::NginxLike => 4096, + FallbackShapeFamily::BoringSslLike => 1536, + FallbackShapeFamily::RustlsLike => 3072, + }; + + if remaining <= split_threshold { + return vec![remaining.clamp(MIN_APP_DATA, MAX_APP_DATA)]; + } + + let mut sizes: Vec = Vec::new(); + while remaining > 0 { + let chunk = remaining.min(preferred_chunk).min(MAX_APP_DATA); + if chunk < MIN_APP_DATA { + if let Some(last) = sizes.last_mut() { + *last = (*last).saturating_add(chunk).min(MAX_APP_DATA); + } else { + push_fallback_size(&mut sizes, chunk); + } + break; + } + push_fallback_size(&mut sizes, chunk); + remaining = remaining.saturating_sub(chunk); + } + + sizes +} + fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec { match cached.behavior_profile.source { TlsProfileSource::Raw | TlsProfileSource::Merged => { @@ -116,14 +207,10 @@ fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec { } return vec![cached.total_app_data_len.max(1024)]; } - TlsProfileSource::Default | TlsProfileSource::Rustls => {} + TlsProfileSource::Default | TlsProfileSource::Rustls => { + return fallback_family_app_data_sizes(cached); + } } - - let mut sizes = cached.app_data_records_sizes.clone(); - if sizes.is_empty() { - sizes.push(cached.total_app_data_len.max(1024)); - } - sizes } fn emulated_change_cipher_spec_count(_cached: &CachedTlsData) -> usize { @@ -151,7 +238,13 @@ fn emulated_ticket_record_sizes( sizes.extend(profiled_sizes.iter().copied().take(target_count)); while sizes.len() < target_count { - sizes.push(rng.range(48) + 48); + let family = fallback_shape_family(cached); + let base = match family { + FallbackShapeFamily::NginxLike => 96, + FallbackShapeFamily::BoringSslLike => 80, + FallbackShapeFamily::RustlsLike => 112, + }; + sizes.push(base + rng.range(64)); } sizes @@ -287,22 +380,24 @@ fn build_profiled_server_hello_extensions( 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, - server_key_share, - &mut saw_supported_versions, - &mut saw_key_share, - ); + if should_replay_profiled_server_hello_shape(cached) { + for ext in &cached.server_hello_template.extensions { + replay_profiled_server_hello_extension( + ext, + &mut extensions, + server_key_share, + &mut saw_supported_versions, + &mut saw_key_share, + ); + } } - if !saw_key_share { - push_key_share_extension(&mut extensions, server_key_share); - } if !saw_supported_versions { push_supported_versions_extension(&mut extensions); } + if !saw_key_share { + push_key_share_extension(&mut extensions, server_key_share); + } extensions } @@ -594,10 +689,16 @@ mod tests { fn profiled_server_hello_key_share_group_reads_raw_x25519_profile() { let mut cached = make_cached(None); cached.behavior_profile.source = TlsProfileSource::Raw; - cached.server_hello_template.extensions = vec![TlsExtension { - ext_type: 0x0033, - data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), - }]; + cached.server_hello_template.extensions = vec![ + TlsExtension { + ext_type: 0x002b, + data: vec![0x03, 0x04], + }, + TlsExtension { + ext_type: 0x0033, + data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), + }, + ]; assert_eq!( profiled_server_hello_key_share_group(&cached), @@ -711,6 +812,82 @@ mod tests { ); } + #[test] + fn test_build_emulated_server_hello_replays_safe_raw_extension_order() { + let mut cached = make_cached(None); + cached.behavior_profile.source = TlsProfileSource::Raw; + cached.server_hello_template.extensions = vec![ + TlsExtension { + ext_type: 0x0033, + data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), + }, + TlsExtension { + ext_type: 0x002b, + data: vec![0x03, 0x04], + }, + ]; + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x21; 32], + &[0x22; 16], + &cached, + false, + true, + ClientHelloTlsVersion::Tls13, + [0x13, 0x01], + &test_server_key_share(), + &rng, + None, + 0, + ); + + assert_eq!( + server_hello_extension_types(&response), + vec![0x0033, 0x002b] + ); + } + + #[test] + fn test_build_emulated_server_hello_uses_canonical_order_for_unsafe_raw_shape() { + let mut cached = make_cached(None); + cached.behavior_profile.source = TlsProfileSource::Raw; + cached.server_hello_template.extensions = vec![ + TlsExtension { + ext_type: 0x0010, + data: vec![0x00, 0x03, 0x02, b'h', b'2'], + }, + TlsExtension { + ext_type: 0x0033, + data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32), + }, + TlsExtension { + ext_type: 0x002b, + data: vec![0x03, 0x04], + }, + ]; + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x21; 32], + &[0x22; 16], + &cached, + false, + true, + ClientHelloTlsVersion::Tls13, + [0x13, 0x01], + &test_server_key_share(), + &rng, + None, + 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); diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index a70ce12..e219113 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -1477,7 +1477,8 @@ pub async fn fetch_real_tls_with_strategy( raw.cert_info = rustls.cert_info; raw.cert_payload = rustls.cert_payload; raw.behavior_profile.source = TlsProfileSource::Merged; - raw.behavior_profile.refresh_quality(); + raw.behavior_profile + .refresh_server_hello_summary(&raw.server_hello_parsed); debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain"); Ok(raw) } else { diff --git a/src/tls_front/types.rs b/src/tls_front/types.rs index e85b571..e03a2ba 100644 --- a/src/tls_front/types.rs +++ b/src/tls_front/types.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use std::time::SystemTime; +const EXT_ALPN: u16 = 0x0010; +const EXT_SUPPORTED_VERSIONS: u16 = 0x002b; const EXT_KEY_SHARE: u16 = 0x0033; +const TLS_LEGACY_SERVER_HELLO_VERSION: [u8; 2] = [0x03, 0x03]; +const TLS_VERSION_13: [u8; 2] = [0x03, 0x04]; const TLS_NAMED_GROUP_X25519: u16 = 0x001d; const TLS_NAMED_GROUP_X25519MLKEM768: u16 = 0x11ec; @@ -50,6 +54,50 @@ impl ParsedServerHello { .find(|extension| extension.ext_type == EXT_KEY_SHARE) .and_then(|extension| parse_key_share_group(&extension.data)) } + + /// Return true when the cached ServerHello can safely drive visible TLS 1.3 replay. + pub(crate) fn is_replay_safe_tls13_shape(&self, record_body_len: usize) -> bool { + if self.version != TLS_LEGACY_SERVER_HELLO_VERSION + || self.compression != 0 + || self.session_id.len() > 32 + || !is_supported_tls13_cipher_suite(self.cipher_suite) + { + return false; + } + + if record_body_len != 0 && record_body_len != self.record_body_len() { + return false; + } + + let mut saw_supported_versions = false; + let mut saw_key_share = false; + for extension in &self.extensions { + match extension.ext_type { + EXT_SUPPORTED_VERSIONS => { + if saw_supported_versions || extension.data.as_slice() != TLS_VERSION_13 { + return false; + } + saw_supported_versions = true; + } + EXT_KEY_SHARE => { + if saw_key_share || parse_key_share_group(&extension.data).is_none() { + return false; + } + saw_key_share = true; + } + EXT_ALPN => { + return false; + } + _ => {} + } + } + + saw_supported_versions && saw_key_share + } +} + +fn is_supported_tls13_cipher_suite(cipher_suite: [u8; 2]) -> bool { + matches!(u16::from_be_bytes(cipher_suite), 0x1301 | 0x1302 | 0x1303) } fn parse_key_share_group(data: &[u8]) -> Option { @@ -168,25 +216,29 @@ impl Default for TlsBehaviorProfile { impl TlsBehaviorProfile { /// Refresh cached visible ServerHello summary fields and quality. pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) { + let mut has_replay_safe_server_hello = false; if matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) { if self.server_hello_record_len == 0 { self.server_hello_record_len = server_hello.record_body_len(); } self.server_hello_extension_types = server_hello.extension_types(); self.server_hello_key_share_group = server_hello.key_share_group(); + has_replay_safe_server_hello = + server_hello.is_replay_safe_tls13_shape(self.server_hello_record_len); } else { self.server_hello_record_len = 0; self.server_hello_extension_types.clear(); self.server_hello_key_share_group = None; } - self.refresh_quality(); + self.refresh_quality(has_replay_safe_server_hello); } /// Recompute the profile quality from current source and record-size evidence. - pub(crate) fn refresh_quality(&mut self) { - let has_raw_server_hello = matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) - && self.server_hello_record_len > 0; + fn refresh_quality(&mut self, has_replay_safe_server_hello: bool) { + let has_raw_server_hello = + matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) + && has_replay_safe_server_hello; self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() { TlsProfileQuality::RawStrict } else if has_raw_server_hello { @@ -233,6 +285,34 @@ pub struct TlsFetchResult { mod tests { use super::*; + fn tls13_key_share_extension() -> TlsExtension { + let mut data = Vec::new(); + data.extend_from_slice(&TLS_NAMED_GROUP_X25519.to_be_bytes()); + data.extend_from_slice(&32u16.to_be_bytes()); + data.resize(36, 0x42); + TlsExtension { + ext_type: EXT_KEY_SHARE, + data, + } + } + + fn replay_safe_server_hello() -> ParsedServerHello { + ParsedServerHello { + version: TLS_LEGACY_SERVER_HELLO_VERSION, + random: [0u8; 32], + session_id: vec![0x11; 32], + cipher_suite: [0x13, 0x01], + compression: 0, + extensions: vec![ + TlsExtension { + ext_type: EXT_SUPPORTED_VERSIONS, + data: TLS_VERSION_13.to_vec(), + }, + tls13_key_share_extension(), + ], + } + } + #[test] fn cached_tls_data_deserializes_without_behavior_profile() { let json = r#" @@ -260,4 +340,57 @@ mod tests { assert_eq!(cached.behavior_profile.source, TlsProfileSource::Default); assert_eq!(cached.behavior_profile.quality, TlsProfileQuality::Fallback); } + + #[test] + fn replay_safe_raw_server_hello_with_app_data_is_raw_strict() { + let server_hello = replay_safe_server_hello(); + let mut behavior = TlsBehaviorProfile { + source: TlsProfileSource::Raw, + app_data_record_sizes: vec![1200], + ..TlsBehaviorProfile::default() + }; + + behavior.refresh_server_hello_summary(&server_hello); + + assert_eq!(behavior.quality, TlsProfileQuality::RawStrict); + assert_eq!( + behavior.server_hello_extension_types, + vec![EXT_SUPPORTED_VERSIONS, EXT_KEY_SHARE] + ); + assert_eq!( + behavior.server_hello_key_share_group, + Some(TLS_NAMED_GROUP_X25519) + ); + } + + #[test] + fn replay_safe_raw_server_hello_without_app_data_is_raw_partial() { + let server_hello = replay_safe_server_hello(); + let mut behavior = TlsBehaviorProfile { + source: TlsProfileSource::Raw, + ..TlsBehaviorProfile::default() + }; + + behavior.refresh_server_hello_summary(&server_hello); + + assert_eq!(behavior.quality, TlsProfileQuality::RawPartial); + } + + #[test] + fn malformed_raw_server_hello_is_fallback_quality() { + let mut server_hello = replay_safe_server_hello(); + server_hello.extensions.push(TlsExtension { + ext_type: EXT_ALPN, + data: vec![0x00, 0x03, 0x02, b'h', b'2'], + }); + let mut behavior = TlsBehaviorProfile { + source: TlsProfileSource::Raw, + app_data_record_sizes: vec![1200], + ..TlsBehaviorProfile::default() + }; + + behavior.refresh_server_hello_summary(&server_hello); + + assert_eq!(behavior.quality, TlsProfileQuality::Fallback); + } } From a808dc2815bf62c3c55f2c78dfa7eebce14a3a2b Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:34:58 +0300 Subject: [PATCH 09/10] Fix TLS fetch test constants scope Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/tls_front/fetcher.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index e219113..00e943e 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -1530,9 +1530,11 @@ mod tests { use std::time::{Duration, Instant}; use super::{ - ProfileCacheValue, TlsFetchStrategy, build_client_hello, build_tls_fetch_proxy_header, - derive_behavior_profile, encode_tls13_certificate_message, fetch_via_rustls_stream, - order_profiles, profile_alpn, profile_cache, profile_cache_key, + MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN, ProfileCacheValue, TLS_NAMED_GROUP_X25519, + TLS_NAMED_GROUP_X25519MLKEM768, TlsFetchStrategy, X25519_KEY_SHARE_LEN, + build_client_hello, build_tls_fetch_proxy_header, derive_behavior_profile, + encode_tls13_certificate_message, fetch_via_rustls_stream, order_profiles, profile_alpn, + profile_cache, profile_cache_key, }; use crate::config::TlsFetchProfile; use crate::crypto::SecureRandom; From 1edd63bfb13eed13bcd2f070810896a80add4a51 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:36:33 +0300 Subject: [PATCH 10/10] Rustfmt + Bump --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/protocol/tests/tls_security_tests.rs | 23 +++++----------- src/protocol/tls.rs | 15 +++++------ src/proxy/handshake.rs | 11 ++++---- src/tls_front/emulator.rs | 6 ++--- src/tls_front/fetcher.rs | 34 ++++++++++++------------ src/tls_front/types.rs | 12 ++++++--- 8 files changed, 50 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f892e96..7ec5f97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2938,7 +2938,7 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "telemt" -version = "3.4.15" +version = "3.4.16" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index c3ba7ec..1d09431 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.4.15" +version = "3.4.16" edition = "2024" [features] diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index da9679a..834837d 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1849,10 +1849,7 @@ fn select_server_hello_cipher_suite_ignores_profile_tls12_cipher() { #[test] fn select_server_hello_cipher_suite_rejects_without_offered_tls13_suite() { let ch = build_client_hello_with_ciphers_and_exts(&[[0xc0, 0x2f]], Vec::new(), "example.com"); - assert_eq!( - select_server_hello_cipher_suite(&ch, [0x13, 0x01]), - None - ); + assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None); } #[test] @@ -1869,10 +1866,7 @@ fn select_server_hello_cipher_suite_rejects_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]), - None - ); + assert_eq!(select_server_hello_cipher_suite(&ch, [0x13, 0x01]), None); } #[test] @@ -1974,9 +1968,8 @@ fn build_server_hello_key_share_prefers_profiled_x25519() { let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); let rng = crate::crypto::SecureRandom::new(); - let server_key_share = - build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) - .expect("profiled X25519 share must be selected when client offers it"); + let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) + .expect("profiled X25519 share must be selected when client offers it"); assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519); assert_eq!(server_key_share.key_exchange().len(), X25519_KEY_SHARE_LEN); @@ -1991,9 +1984,8 @@ fn build_server_hello_key_share_falls_back_from_bad_profiled_x25519_to_hybrid() let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); let rng = crate::crypto::SecureRandom::new(); - let server_key_share = - build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) - .expect("hybrid share must be selected when profiled X25519 is unavailable"); + let server_key_share = build_server_hello_key_share(&ch, Some(TLS_NAMED_GROUP_X25519), &rng) + .expect("hybrid share must be selected when profiled X25519 is unavailable"); assert_eq!(server_key_share.group(), TLS_NAMED_GROUP_X25519MLKEM768); assert_eq!( @@ -2032,8 +2024,7 @@ fn build_x25519mlkem768_server_key_share_rejects_all_zero_x25519_share() { #[test] fn select_server_hello_key_share_group_accepts_x25519_when_hybrid_is_absent() { - let key_share = - client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]); + let key_share = client_key_share_extension(&[(TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)]); let ch = build_client_hello_with_exts(vec![(0x0033, key_share)], "example.com"); assert_eq!( diff --git a/src/protocol/tls.rs b/src/protocol/tls.rs index c19d8be..5119805 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -63,9 +63,9 @@ use super::constants::*; use crate::crypto::{SecureRandom, sha256_hmac}; -use ml_kem::{B32, EncapsulationKey as MlKemEncapsulationKey, Key as MlKemKey, MlKem768}; #[cfg(test)] use crate::error::ProxyError; +use ml_kem::{B32, EncapsulationKey as MlKemEncapsulationKey, Key as MlKemKey, MlKem768}; use std::time::{SystemTime, UNIX_EPOCH}; use subtle::ConstantTimeEq; use x25519_dalek::{X25519_BASEPOINT_BYTES, x25519}; @@ -617,8 +617,11 @@ pub(crate) fn build_x25519_server_key_share( handshake: &[u8], rng: &SecureRandom, ) -> Option> { - let client_key_exchange = - client_hello_key_share_group_entry(handshake, TLS_NAMED_GROUP_X25519, X25519_KEY_SHARE_LEN)?; + let client_key_exchange = client_hello_key_share_group_entry( + handshake, + TLS_NAMED_GROUP_X25519, + X25519_KEY_SHARE_LEN, + )?; let mut client_x25519 = [0u8; X25519_KEY_SHARE_LEN]; client_x25519.copy_from_slice(client_key_exchange); let (server_x25519_scalar, server_x25519_key) = gen_x25519_key_pair(rng); @@ -1271,11 +1274,7 @@ fn key_share_extension_group_entry<'a>( pos = key_exchange_end; } - if pos == shares_end { - found_group - } else { - None - } + if pos == shares_end { found_group } else { None } } fn client_hello_key_share_group_entry<'a>( diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index 40d465d..084fadc 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1527,11 +1527,12 @@ where && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12) { if let Some(cache) = tls_cache.as_ref() { - cache.take_full_cert_budget_for_ip( - peer.ip(), - Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), - ) - .await + cache + .take_full_cert_budget_for_ip( + peer.ip(), + Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), + ) + .await } else { true } diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 9a5ee49..9e39bef 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -57,9 +57,9 @@ fn should_replay_profiled_server_hello_shape(cached: &CachedTlsData) -> bool { matches!( cached.behavior_profile.source, TlsProfileSource::Raw | TlsProfileSource::Merged - ) && cached.server_hello_template.is_replay_safe_tls13_shape( - effective_profiled_server_hello_record_len(cached), - ) + ) && cached + .server_hello_template + .is_replay_safe_tls13_shape(effective_profiled_server_hello_record_len(cached)) } /// Return the origin-profiled ServerHello key_share group when it is replay-safe. diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 00e943e..8c3cb6c 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -9,9 +9,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use anyhow::{Result, anyhow}; -use ml_kem::{ - DecapsulationKey as MlKemDecapsulationKey, KeyExport, MlKem768, Seed as MlKemSeed, -}; +use ml_kem::{DecapsulationKey as MlKemDecapsulationKey, KeyExport, MlKem768, Seed as MlKemSeed}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::net::TcpStream; #[cfg(unix)] @@ -514,13 +512,11 @@ fn gen_x25519mlkem768_client_key_share( deterministic: bool, seed: &str, ) -> Option> { - let mlkem_key = gen_mlkem768_client_encapsulation_key( - rng, - deterministic, - &format!("{seed}:mlkem768"), - )?; + let mlkem_key = + gen_mlkem768_client_encapsulation_key(rng, deterministic, &format!("{seed}:mlkem768"))?; let x25519_key = gen_key_share(rng, deterministic, &format!("{seed}:x25519")); - let mut key_share = Vec::with_capacity(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + x25519_key.len()); + let mut key_share = + Vec::with_capacity(MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN + x25519_key.len()); key_share.extend_from_slice(&mlkem_key); key_share.extend_from_slice(&x25519_key); Some(key_share) @@ -661,8 +657,7 @@ fn build_client_hello( profile, TlsFetchProfile::ModernChromeLike | TlsFetchProfile::ModernFirefoxLike ) { - if let Some(key) = - gen_x25519mlkem768_client_key_share(rng, deterministic, &key_share_seed) + if let Some(key) = gen_x25519mlkem768_client_key_share(rng, deterministic, &key_share_seed) { push_client_key_share_entry(&mut keyshare, TLS_NAMED_GROUP_X25519MLKEM768, &key); } @@ -1531,10 +1526,9 @@ mod tests { use super::{ MLKEM768_CLIENT_ENCAPSULATION_KEY_LEN, ProfileCacheValue, TLS_NAMED_GROUP_X25519, - TLS_NAMED_GROUP_X25519MLKEM768, TlsFetchStrategy, X25519_KEY_SHARE_LEN, - build_client_hello, build_tls_fetch_proxy_header, derive_behavior_profile, - encode_tls13_certificate_message, fetch_via_rustls_stream, order_profiles, profile_alpn, - profile_cache, profile_cache_key, + TLS_NAMED_GROUP_X25519MLKEM768, TlsFetchStrategy, X25519_KEY_SHARE_LEN, build_client_hello, + build_tls_fetch_proxy_header, derive_behavior_profile, encode_tls13_certificate_message, + fetch_via_rustls_stream, order_profiles, profile_alpn, profile_cache, profile_cache_key, }; use crate::config::TlsFetchProfile; use crate::crypto::SecureRandom; @@ -1886,8 +1880,14 @@ mod tests { u16::from_be_bytes([key_share_data[pos + 2], key_share_data[pos + 3]]) as usize; pos += 4; let key = &key_share_data[pos..pos + key_len]; - assert_eq!(group, TLS_NAMED_GROUP_X25519, "second key_share group must be x25519"); - assert_eq!(key_len, X25519_KEY_SHARE_LEN, "x25519 key length must be 32"); + assert_eq!( + group, TLS_NAMED_GROUP_X25519, + "second key_share group must be x25519" + ); + assert_eq!( + key_len, X25519_KEY_SHARE_LEN, + "x25519 key length must be 32" + ); assert!( key.iter().any(|b| *b != 0), "x25519 key must not be all zero" diff --git a/src/tls_front/types.rs b/src/tls_front/types.rs index e03a2ba..e1ef0a7 100644 --- a/src/tls_front/types.rs +++ b/src/tls_front/types.rs @@ -217,7 +217,10 @@ impl TlsBehaviorProfile { /// Refresh cached visible ServerHello summary fields and quality. pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) { let mut has_replay_safe_server_hello = false; - if matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) { + if matches!( + self.source, + TlsProfileSource::Raw | TlsProfileSource::Merged + ) { if self.server_hello_record_len == 0 { self.server_hello_record_len = server_hello.record_body_len(); } @@ -236,9 +239,10 @@ impl TlsBehaviorProfile { /// Recompute the profile quality from current source and record-size evidence. fn refresh_quality(&mut self, has_replay_safe_server_hello: bool) { - let has_raw_server_hello = - matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged) - && has_replay_safe_server_hello; + let has_raw_server_hello = matches!( + self.source, + TlsProfileSource::Raw | TlsProfileSource::Merged + ) && has_replay_safe_server_hello; self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() { TlsProfileQuality::RawStrict } else if has_raw_server_hello {