From 67357310f79b76d70d9986c9d1559380d18f3c52 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:29:18 +0300 Subject: [PATCH] TLS 1.2/1.3 Correctness + Full ServerHello + Rustfmt Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/cli.rs | 1 + src/config/defaults.rs | 4 + src/config/hot_reload.rs | 1 + src/config/types.rs | 8 ++ src/maestro/helpers.rs | 4 +- src/maestro/listeners.rs | 5 +- src/protocol/tests/tls_security_tests.rs | 30 +++++ src/protocol/tls.rs | 122 ++++++++++++++++++ src/proxy/handshake.rs | 24 +++- src/tls_front/emulator.rs | 112 ++++++++++++++-- ...mulator_profile_fidelity_security_tests.rs | 7 + .../tests/emulator_security_tests.rs | 7 + 12 files changed, 301 insertions(+), 24 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 2e24017..bda7d92 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -689,6 +689,7 @@ tls_domain = "{domain}" mask = true mask_port = 443 fake_cert_len = 2048 +serverhello_compact = false tls_full_cert_ttl_secs = 90 [access] diff --git a/src/config/defaults.rs b/src/config/defaults.rs index d1761d1..64fa2ac 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -575,6 +575,10 @@ pub(crate) fn default_tls_new_session_tickets() -> u8 { 0 } +pub(crate) fn default_serverhello_compact() -> bool { + false +} + pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 { 90 } diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index 6337a06..eb84ccd 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -624,6 +624,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b || old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms || old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms || old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets + || old.censorship.serverhello_compact != new.censorship.serverhello_compact || old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs || old.censorship.alpn_enforce != new.censorship.alpn_enforce || old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol diff --git a/src/config/types.rs b/src/config/types.rs index f422e4e..b1260c7 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -1723,9 +1723,16 @@ pub struct AntiCensorshipConfig { #[serde(default = "default_tls_new_session_tickets")] pub tls_new_session_tickets: u8, + /// Enable compact ServerHello payload mode. + /// When false, FakeTLS always uses full ServerHello payload behavior. + /// When true, compact certificate payload mode can be used by TTL policy. + #[serde(default = "default_serverhello_compact")] + pub serverhello_compact: bool, + /// TTL in seconds for sending full certificate payload per client IP. /// First client connection per (SNI domain, client IP) gets full cert payload. /// Subsequent handshakes within TTL use compact cert metadata payload. + /// Applied only when `serverhello_compact` is enabled. #[serde(default = "default_tls_full_cert_ttl_secs")] pub tls_full_cert_ttl_secs: u64, @@ -1820,6 +1827,7 @@ impl Default for AntiCensorshipConfig { server_hello_delay_min_ms: default_server_hello_delay_min_ms(), server_hello_delay_max_ms: default_server_hello_delay_max_ms(), tls_new_session_tickets: default_tls_new_session_tickets(), + serverhello_compact: default_serverhello_compact(), tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), alpn_enforce: default_alpn_enforce(), mask_proxy_protocol: 0, diff --git a/src/maestro/helpers.rs b/src/maestro/helpers.rs index 37b6563..b888fb4 100644 --- a/src/maestro/helpers.rs +++ b/src/maestro/helpers.rs @@ -549,9 +549,7 @@ pub(crate) fn expected_handshake_close_description( std::io::ErrorKind::BrokenPipe => { Some("Peer closed write side before MTProto handshake completed") } - std::io::ErrorKind::NotConnected => { - Some("Handshake socket was already closed by peer") - } + std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"), _ => None, } } diff --git a/src/maestro/listeners.rs b/src/maestro/listeners.rs index b393f3f..84bd0f1 100644 --- a/src/maestro/listeners.rs +++ b/src/maestro/listeners.rs @@ -535,9 +535,8 @@ pub(crate) fn spawn_tcp_accept_loops( } } _ if is_expected_handshake_eof(&e) => { - let reason = handshake_close_reason.unwrap_or( - "Peer closed during initial handshake", - ); + let reason = handshake_close_reason + .unwrap_or("Peer closed during initial handshake"); if let Some(real_peer) = real_peer { info!( peer = %peer_addr, diff --git a/src/protocol/tests/tls_security_tests.rs b/src/protocol/tests/tls_security_tests.rs index 3008e57..1edece4 100644 --- a/src/protocol/tests/tls_security_tests.rs +++ b/src/protocol/tests/tls_security_tests.rs @@ -1383,6 +1383,8 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() { &session_id, &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(b"h2".to_vec()), 0, @@ -1624,6 +1626,34 @@ fn test_extract_alpn_multiple() { assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]); } +#[test] +fn detect_client_hello_tls_version_prefers_supported_versions_tls13() { + let supported_versions = vec![4, 0x03, 0x04, 0x03, 0x03]; + let ch = build_client_hello_with_exts(vec![(0x002b, supported_versions)], "example.com"); + assert_eq!( + detect_client_hello_tls_version(&ch), + Some(ClientHelloTlsVersion::Tls13) + ); +} + +#[test] +fn detect_client_hello_tls_version_falls_back_to_legacy_tls12() { + let ch = build_client_hello_with_exts(Vec::new(), "example.com"); + assert_eq!( + detect_client_hello_tls_version(&ch), + Some(ClientHelloTlsVersion::Tls12) + ); +} + +#[test] +fn detect_client_hello_tls_version_rejects_malformed_supported_versions() { + // list_len=3 is invalid because version vector must contain u16 pairs. + let malformed_supported_versions = vec![3, 0x03, 0x04, 0x03]; + let ch = + build_client_hello_with_exts(vec![(0x002b, malformed_supported_versions)], "example.com"); + assert!(detect_client_hello_tls_version(&ch).is_none()); +} + #[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 613106e..ae8e40a 100644 --- a/src/protocol/tls.rs +++ b/src/protocol/tls.rs @@ -811,6 +811,128 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec> { out } +/// ClientHello TLS generation inferred from handshake fields. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientHelloTlsVersion { + Tls12, + Tls13, +} + +/// Detect TLS generation from a ClientHello. +/// +/// The parser prefers `supported_versions` (0x002b) when present and falls back +/// to `legacy_version` for compatibility with TLS 1.2 style hellos. +pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option { + if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE { + return None; + } + + let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize; + if handshake.len() < 5 + record_len { + return None; + } + + let mut pos = 5; // after record header + if handshake.get(pos) != Some(&0x01) { + return None; // not ClientHello + } + pos += 1; // message type + + if pos + 3 > handshake.len() { + return None; + } + let handshake_len = ((handshake[pos] as usize) << 16) + | ((handshake[pos + 1] as usize) << 8) + | handshake[pos + 2] as usize; + pos += 3; // handshake length bytes + if pos + handshake_len > 5 + record_len { + return None; + } + + if pos + 2 + 32 > handshake.len() { + return None; + } + let legacy_version = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]); + pos += 2 + 32; // version + random + + let session_id_len = *handshake.get(pos)? as usize; + pos += 1 + session_id_len; + if pos + 2 > handshake.len() { + return None; + } + + let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + pos += 2 + cipher_len; + if pos >= handshake.len() { + return None; + } + + let comp_len = *handshake.get(pos)? as usize; + pos += 1 + comp_len; + if pos + 2 > handshake.len() { + return None; + } + + let ext_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize; + pos += 2; + let ext_end = pos + ext_len; + if ext_end > handshake.len() { + return None; + } + + let mut saw_supported_versions = false; + while pos + 4 <= ext_end { + let etype = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]); + let elen = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize; + pos += 4; + if pos + elen > ext_end { + return None; + } + + if etype == extension_type::SUPPORTED_VERSIONS { + saw_supported_versions = true; + if elen < 1 { + return None; + } + let list_len = handshake[pos] as usize; + if list_len == 0 || list_len % 2 != 0 || 1 + list_len > elen { + return None; + } + + let mut has_tls12 = false; + let mut ver_pos = pos + 1; + let ver_end = ver_pos + list_len; + while ver_pos + 1 < ver_end { + let version = u16::from_be_bytes([handshake[ver_pos], handshake[ver_pos + 1]]); + if version == 0x0304 { + return Some(ClientHelloTlsVersion::Tls13); + } + if version == 0x0303 || version == 0x0302 || version == 0x0301 { + has_tls12 = true; + } + ver_pos += 2; + } + + if has_tls12 { + return Some(ClientHelloTlsVersion::Tls12); + } + return None; + } + + pos += elen; + } + + if saw_supported_versions { + return None; + } + + if legacy_version >= 0x0303 { + Some(ClientHelloTlsVersion::Tls12) + } else { + 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 a9ab0ff..cdfd844 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -1119,6 +1119,10 @@ where } else { None }; + // Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous: + // this avoids leaking certificate payload on malformed probes. + let client_tls_version = tls::detect_client_hello_tls_version(handshake) + .unwrap_or(tls::ClientHelloTlsVersion::Tls13); if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { let sni = client_sni.as_deref().unwrap_or_default(); @@ -1439,12 +1443,18 @@ where 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 = cache - .take_full_cert_budget_for_ip( - peer.ip(), - Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), - ) - .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)) } else { None @@ -1465,6 +1475,8 @@ where validation_session_id_slice, &cached_entry, use_full_cert_payload, + config.censorship.serverhello_compact, + client_tls_version, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index af51ca0..609aeaf 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -5,7 +5,9 @@ use crate::protocol::constants::{ MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_VERSION, }; -use crate::protocol::tls::{TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key}; +use crate::protocol::tls::{ + ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key, +}; use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; use crc32fast::Hasher; @@ -190,6 +192,8 @@ pub fn build_emulated_server_hello( session_id: &[u8], cached: &CachedTlsData, use_full_cert_payload: bool, + serverhello_compact: bool, + client_tls_version: ClientHelloTlsVersion, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, @@ -265,20 +269,33 @@ pub fn build_emulated_server_hello( } } }; - let compact_payload = cached - .cert_info - .as_ref() - .and_then(build_compact_cert_info_payload) - .and_then(hash_compact_cert_info_payload); - let selected_payload: Option<&[u8]> = if use_full_cert_payload { + let compact_payload = if serverhello_compact { cached - .cert_payload + .cert_info .as_ref() - .map(|payload| payload.certificate_message.as_slice()) - .filter(|payload| !payload.is_empty()) - .or(compact_payload.as_deref()) + .and_then(build_compact_cert_info_payload) + .and_then(hash_compact_cert_info_payload) } else { - compact_payload.as_deref() + None + }; + let full_payload = cached + .cert_payload + .as_ref() + .map(|payload| payload.certificate_message.as_slice()) + .filter(|payload| !payload.is_empty()); + let selected_payload: Option<&[u8]> = match client_tls_version { + ClientHelloTlsVersion::Tls13 => None, + ClientHelloTlsVersion::Tls12 => { + if serverhello_compact { + if use_full_cert_payload { + full_payload.or(compact_payload.as_deref()) + } else { + compact_payload.as_deref() + } + } else { + full_payload + } + } }; if let Some(payload) = selected_payload { @@ -402,6 +419,7 @@ mod tests { use crate::protocol::constants::{ TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, }; + 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; @@ -448,6 +466,8 @@ mod tests { &[0x22; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -474,6 +494,8 @@ mod tests { &[0x33; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -506,6 +528,8 @@ mod tests { &[0x55; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls12, &rng, None, 0, @@ -529,6 +553,68 @@ mod tests { ); } + #[test] + fn test_build_emulated_server_hello_tls13_never_uses_cert_payload() { + let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd]; + let cached = make_cached(Some(TlsCertPayload { + cert_chain_der: vec![vec![0x30, 0x01, 0x00]], + certificate_message: cert_msg.clone(), + })); + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x56; 32], + &[0x78; 16], + &cached, + true, + true, + ClientHelloTlsVersion::Tls13, + &rng, + None, + 0, + ); + + let payload = first_app_data_payload(&response); + assert!( + !payload.starts_with(&cert_msg), + "TLS 1.3 response path must not expose certificate payload bytes" + ); + } + + #[test] + fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() { + let mut cached = make_cached(None); + cached.cert_info = Some(crate::tls_front::types::ParsedCertificateInfo { + not_after_unix: Some(1_900_000_000), + not_before_unix: Some(1_700_000_000), + issuer_cn: Some("Issuer".to_string()), + subject_cn: Some("example.com".to_string()), + san_names: vec!["example.com".to_string()], + }); + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x90; 32], + &[0x91; 16], + &cached, + false, + false, + ClientHelloTlsVersion::Tls12, + &rng, + Some(b"h2".to_vec()), + 0, + ); + + let payload = first_app_data_payload(&response); + let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2']; + assert!( + payload.starts_with(&expected_alpn_marker), + "when compact mode is disabled and no full cert payload exists, the random/alpn path must be used" + ); + } + #[test] fn test_build_emulated_server_hello_ignores_tail_records_for_profiled_tls() { let mut cached = make_cached(None); @@ -545,6 +631,8 @@ mod tests { &[0x34; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &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 1a40e9b..ba0e137 100644 --- a/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs +++ b/src/tls_front/tests/emulator_profile_fidelity_security_tests.rs @@ -4,6 +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::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, @@ -62,6 +63,8 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit &[0x72; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 0, @@ -84,6 +87,8 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() { &[0x82; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 0, @@ -104,6 +109,8 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() { &[0x92; 16], &cached, false, + true, + ClientHelloTlsVersion::Tls13, &rng, None, 2, diff --git a/src/tls_front/tests/emulator_security_tests.rs b/src/tls_front/tests/emulator_security_tests.rs index 24e04ed..ce493bb 100644 --- a/src/tls_front/tests/emulator_security_tests.rs +++ b/src/tls_front/tests/emulator_security_tests.rs @@ -4,6 +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::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::types::{ CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, @@ -55,6 +56,8 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() { &[0x22; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(oversized_alpn), 0, @@ -91,6 +94,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() { &[0x41; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls13, &rng, Some(b"h2".to_vec()), 0, @@ -119,6 +124,8 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() { &[0x42; 16], &cached, true, + true, + ClientHelloTlsVersion::Tls12, &rng, Some(b"h2".to_vec()), 0,