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,