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]