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] 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); + } }