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