From 4677b43c6e00407bb67c3c1a7884e124fef6ae89 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:38:24 +0300 Subject: [PATCH] TLS-F New Methods --- src/tls_front/cache.rs | 6 ++- src/tls_front/emulator.rs | 67 +++++++++++++++++++----- src/tls_front/fetcher.rs | 105 +++++++++++++++++++++++++++++++++----- src/tls_front/types.rs | 83 ++++++++++++++++++++++++++++++ 4 files changed, 233 insertions(+), 28 deletions(-) diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index 23e60db..0dc2b5d 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -8,7 +8,9 @@ use tokio::sync::RwLock; use tokio::time::sleep; use tracing::{debug, warn, info}; -use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsFetchResult}; +use crate::tls_front::types::{ + CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsFetchResult, +}; /// Lightweight in-memory + optional on-disk cache for TLS fronting data. #[derive(Debug)] @@ -37,6 +39,7 @@ impl TlsFrontCache { cert_payload: None, app_data_records_sizes: vec![default_len], total_app_data_len: default_len, + behavior_profile: TlsBehaviorProfile::default(), fetched_at: SystemTime::now(), domain: "default".to_string(), }); @@ -189,6 +192,7 @@ impl TlsFrontCache { 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, fetched_at: SystemTime::now(), domain: domain.to_string(), }; diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index c8c18ac..3278f63 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -3,7 +3,7 @@ use crate::protocol::constants::{ 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::tls_front::types::{CachedTlsData, ParsedCertificateInfo}; +use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = 16640; // RFC 8446 ยง5.2 allows up to 2^14 + 256 @@ -108,14 +108,12 @@ pub fn build_emulated_server_hello( ) -> Vec { // --- ServerHello --- let mut extensions = Vec::new(); - // KeyShare (x25519) let key = gen_fake_x25519_key(rng); - extensions.extend_from_slice(&0x0033u16.to_be_bytes()); // key_share - extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes()); // len - extensions.extend_from_slice(&0x001du16.to_be_bytes()); // X25519 + extensions.extend_from_slice(&0x0033u16.to_be_bytes()); + extensions.extend_from_slice(&(2 + 2 + 32u16).to_be_bytes()); + extensions.extend_from_slice(&0x001du16.to_be_bytes()); extensions.extend_from_slice(&(32u16).to_be_bytes()); extensions.extend_from_slice(&key); - // supported_versions (TLS1.3) extensions.extend_from_slice(&0x002bu16.to_be_bytes()); extensions.extend_from_slice(&(2u16).to_be_bytes()); extensions.extend_from_slice(&0x0304u16.to_be_bytes()); @@ -128,7 +126,6 @@ pub fn build_emulated_server_hello( extensions.push(alpn_proto.len() as u8); extensions.extend_from_slice(alpn_proto); } - let extensions_len = extensions.len() as u16; let body_len = 2 + // version @@ -173,11 +170,22 @@ pub fn build_emulated_server_hello( ]; // --- ApplicationData (fake encrypted records) --- - // Use the same number and sizes of ApplicationData records as the cached server. - let mut sizes = cached.app_data_records_sizes.clone(); - if sizes.is_empty() { - sizes.push(cached.total_app_data_len.max(1024)); - } + let sizes = match cached.behavior_profile.source { + TlsProfileSource::Raw | TlsProfileSource::Merged => cached + .app_data_records_sizes + .first() + .copied() + .or_else(|| cached.behavior_profile.app_data_record_sizes.first().copied()) + .map(|size| vec![size]) + .unwrap_or_else(|| vec![cached.total_app_data_len.max(1024)]), + _ => { + let mut sizes = cached.app_data_records_sizes.clone(); + if sizes.is_empty() { + sizes.push(cached.total_app_data_len.max(1024)); + } + sizes + } + }; let mut sizes = jitter_and_clamp_sizes(&sizes, rng); let compact_payload = cached .cert_info @@ -269,7 +277,9 @@ pub fn build_emulated_server_hello( mod tests { use std::time::SystemTime; - use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsCertPayload}; + use crate::tls_front::types::{ + CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, + }; use super::build_emulated_server_hello; use crate::crypto::SecureRandom; @@ -300,6 +310,7 @@ mod tests { cert_payload, app_data_records_sizes: vec![64], total_app_data_len: 64, + behavior_profile: TlsBehaviorProfile::default(), fetched_at: SystemTime::now(), domain: "example.com".to_string(), } @@ -385,4 +396,34 @@ mod tests { let payload = first_app_data_payload(&response); assert!(payload.starts_with(b"CN=example.com")); } + + #[test] + fn test_build_emulated_server_hello_ignores_tail_records_for_raw_profile() { + let mut cached = make_cached(None); + cached.app_data_records_sizes = vec![27, 3905, 537, 69]; + cached.total_app_data_len = 4538; + cached.behavior_profile.source = TlsProfileSource::Merged; + cached.behavior_profile.app_data_record_sizes = vec![27, 3905, 537]; + cached.behavior_profile.ticket_record_sizes = vec![69]; + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x12; 32], + &[0x34; 16], + &cached, + false, + &rng, + None, + 0, + ); + + let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; + let ccs_start = 5 + hello_len; + let app_start = ccs_start + 6; + let app_len = u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize; + + assert_eq!(response[app_start], TLS_RECORD_APPLICATION); + assert_eq!(app_start + 5 + app_len, response.len()); + } } diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 4d9067c..38872af 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -21,14 +21,18 @@ use x509_parser::certificate::X509Certificate; use crate::crypto::SecureRandom; use crate::network::dns_overrides::resolve_socket_addr; -use crate::protocol::constants::{TLS_RECORD_APPLICATION, TLS_RECORD_HANDSHAKE}; +use crate::protocol::constants::{ + TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, +}; use crate::transport::proxy_protocol::{ProxyProtocolV1Builder, ProxyProtocolV2Builder}; use crate::tls_front::types::{ ParsedCertificateInfo, ParsedServerHello, + TlsBehaviorProfile, TlsCertPayload, TlsExtension, TlsFetchResult, + TlsProfileSource, }; /// No-op verifier: accept any certificate (we only need lengths and metadata). @@ -282,6 +286,41 @@ fn parse_server_hello(body: &[u8]) -> Option { }) } +fn derive_behavior_profile(records: &[(u8, Vec)]) -> TlsBehaviorProfile { + let mut change_cipher_spec_count = 0u8; + let mut app_data_record_sizes = Vec::new(); + + for (record_type, body) in records { + match *record_type { + TLS_RECORD_CHANGE_CIPHER => { + change_cipher_spec_count = change_cipher_spec_count.saturating_add(1); + } + TLS_RECORD_APPLICATION => { + app_data_record_sizes.push(body.len()); + } + _ => {} + } + } + + let mut ticket_record_sizes = Vec::new(); + while app_data_record_sizes + .last() + .is_some_and(|size| *size <= 256 && ticket_record_sizes.len() < 2) + { + if let Some(size) = app_data_record_sizes.pop() { + ticket_record_sizes.push(size); + } + } + ticket_record_sizes.reverse(); + + TlsBehaviorProfile { + change_cipher_spec_count: change_cipher_spec_count.max(1), + app_data_record_sizes, + ticket_record_sizes, + source: TlsProfileSource::Raw, + } +} + fn parse_cert_info(certs: &[CertificateDer<'static>]) -> Option { let first = certs.first()?; let (_rem, cert) = X509Certificate::from_der(first.as_ref()).ok()?; @@ -443,39 +482,50 @@ where .await??; let mut records = Vec::new(); - // Read up to 4 records: ServerHello, CCS, and up to two ApplicationData. - for _ in 0..4 { + let mut app_records_seen = 0usize; + // Read a bounded encrypted flight: ServerHello, CCS, certificate-like data, + // and a small number of ticket-like tail records. + for _ in 0..8 { match timeout(connect_timeout, read_tls_record(&mut stream)).await { - Ok(Ok(rec)) => records.push(rec), + Ok(Ok(rec)) => { + if rec.0 == TLS_RECORD_APPLICATION { + app_records_seen += 1; + } + records.push(rec); + } Ok(Err(e)) => return Err(e), Err(_) => break, } - if records.len() >= 3 && records.iter().any(|(t, _)| *t == TLS_RECORD_APPLICATION) { + if app_records_seen >= 4 { break; } } - let mut app_sizes = Vec::new(); let mut server_hello = None; for (t, body) in &records { if *t == TLS_RECORD_HANDSHAKE && server_hello.is_none() { server_hello = parse_server_hello(body); - } else if *t == TLS_RECORD_APPLICATION { - app_sizes.push(body.len()); } } let parsed = server_hello.ok_or_else(|| anyhow!("ServerHello not received"))?; + let behavior_profile = derive_behavior_profile(&records); + 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); + let app_data_records_sizes = behavior_profile + .app_data_record_sizes + .first() + .copied() + .or_else(|| behavior_profile.ticket_record_sizes.first().copied()) + .map(|size| vec![size]) + .unwrap_or_else(|| vec![total_app_data_len]); Ok(TlsFetchResult { server_hello_parsed: parsed, - app_data_records_sizes: if app_sizes.is_empty() { - vec![total_app_data_len] - } else { - app_sizes - }, + app_data_records_sizes, total_app_data_len, + behavior_profile, cert_info: None, cert_payload: None, }) @@ -608,6 +658,12 @@ where server_hello_parsed: parsed, app_data_records_sizes: app_data_records_sizes.clone(), total_app_data_len: app_data_records_sizes.iter().sum(), + behavior_profile: TlsBehaviorProfile { + change_cipher_spec_count: 1, + app_data_record_sizes: app_data_records_sizes, + ticket_record_sizes: Vec::new(), + source: TlsProfileSource::Rustls, + }, cert_info, cert_payload, }) @@ -706,6 +762,7 @@ pub async fn fetch_real_tls( if let Some(mut raw) = raw_result { raw.cert_info = rustls_result.cert_info; raw.cert_payload = rustls_result.cert_payload; + raw.behavior_profile.source = TlsProfileSource::Merged; debug!(sni = %sni, "Fetched TLS metadata via raw probe + rustls cert chain"); Ok(raw) } else { @@ -725,7 +782,11 @@ pub async fn fetch_real_tls( #[cfg(test)] mod tests { - use super::encode_tls13_certificate_message; + use super::{derive_behavior_profile, encode_tls13_certificate_message}; + use crate::protocol::constants::{ + TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, + }; + use crate::tls_front::types::TlsProfileSource; fn read_u24(bytes: &[u8]) -> usize { ((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize) @@ -753,4 +814,20 @@ mod tests { fn test_encode_tls13_certificate_message_empty_chain() { assert!(encode_tls13_certificate_message(&[]).is_none()); } + + #[test] + fn test_derive_behavior_profile_splits_ticket_like_tail_records() { + let profile = derive_behavior_profile(&[ + (TLS_RECORD_HANDSHAKE, vec![0u8; 90]), + (TLS_RECORD_CHANGE_CIPHER, vec![0x01]), + (TLS_RECORD_APPLICATION, vec![0u8; 1400]), + (TLS_RECORD_APPLICATION, vec![0u8; 220]), + (TLS_RECORD_APPLICATION, vec![0u8; 180]), + ]); + + assert_eq!(profile.change_cipher_spec_count, 1); + assert_eq!(profile.app_data_record_sizes, vec![1400]); + assert_eq!(profile.ticket_record_sizes, vec![220, 180]); + assert_eq!(profile.source, TlsProfileSource::Raw); + } } diff --git a/src/tls_front/types.rs b/src/tls_front/types.rs index c411081..10aca05 100644 --- a/src/tls_front/types.rs +++ b/src/tls_front/types.rs @@ -39,6 +39,53 @@ pub struct TlsCertPayload { pub certificate_message: Vec, } +/// Provenance of the cached TLS behavior profile. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum TlsProfileSource { + /// Built from hardcoded defaults or legacy cache entries. + #[default] + Default, + /// Derived from raw TLS record capture only. + Raw, + /// Derived from rustls-only metadata fallback. + Rustls, + /// Merged from raw TLS capture and rustls certificate metadata. + Merged, +} + +/// Coarse-grained TLS response behavior captured per SNI. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsBehaviorProfile { + /// Number of ChangeCipherSpec records observed before encrypted flight. + #[serde(default = "default_change_cipher_spec_count")] + pub change_cipher_spec_count: u8, + /// Sizes of the primary encrypted flight records carrying cert-like payload. + #[serde(default)] + pub app_data_record_sizes: Vec, + /// Sizes of small tail ApplicationData records that look like tickets. + #[serde(default)] + pub ticket_record_sizes: Vec, + /// Source of this behavior profile. + #[serde(default)] + pub source: TlsProfileSource, +} + +fn default_change_cipher_spec_count() -> u8 { + 1 +} + +impl Default for TlsBehaviorProfile { + fn default() -> Self { + Self { + change_cipher_spec_count: default_change_cipher_spec_count(), + app_data_record_sizes: Vec::new(), + ticket_record_sizes: Vec::new(), + source: TlsProfileSource::Default, + } + } +} + /// Cached data per SNI used by the emulator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedTlsData { @@ -48,6 +95,8 @@ pub struct CachedTlsData { pub cert_payload: Option, pub app_data_records_sizes: Vec, pub total_app_data_len: usize, + #[serde(default)] + pub behavior_profile: TlsBehaviorProfile, #[serde(default = "now_system_time", skip_serializing, skip_deserializing)] pub fetched_at: SystemTime, pub domain: String, @@ -63,6 +112,40 @@ pub struct TlsFetchResult { pub server_hello_parsed: ParsedServerHello, pub app_data_records_sizes: Vec, pub total_app_data_len: usize, + #[serde(default)] + pub behavior_profile: TlsBehaviorProfile, pub cert_info: Option, pub cert_payload: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cached_tls_data_deserializes_without_behavior_profile() { + let json = r#" + { + "server_hello_template": { + "version": [3, 3], + "random": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + "session_id": [], + "cipher_suite": [19, 1], + "compression": 0, + "extensions": [] + }, + "cert_info": null, + "cert_payload": null, + "app_data_records_sizes": [1024], + "total_app_data_len": 1024, + "domain": "example.com" + } + "#; + + let cached: CachedTlsData = serde_json::from_str(json).unwrap(); + assert_eq!(cached.behavior_profile.change_cipher_spec_count, 1); + 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); + } +}