From ae8124d6c6af5e77a32009dfe62311ad20eeba22 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:12:35 +0300 Subject: [PATCH 1/6] Drafting TLS Certificates in TLS ServerHello --- src/tls_front/cache.rs | 2 + src/tls_front/emulator.rs | 153 ++++++++++++++++++++++++++++++++++++-- src/tls_front/fetcher.rs | 96 +++++++++++++++++++++++- src/tls_front/types.rs | 13 ++++ 4 files changed, 255 insertions(+), 9 deletions(-) diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index 103c2b1..803cdf9 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -31,6 +31,7 @@ impl TlsFrontCache { let default = Arc::new(CachedTlsData { server_hello_template: default_template, cert_info: None, + cert_payload: None, app_data_records_sizes: vec![default_len], total_app_data_len: default_len, fetched_at: SystemTime::now(), @@ -142,6 +143,7 @@ impl TlsFrontCache { 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, fetched_at: SystemTime::now(), diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index 4d3e64d..dc5b726 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -27,6 +27,33 @@ fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { .collect() } +fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec { + if payload_len == 0 { + return sizes; + } + + let mut total = sizes.iter().sum::(); + if total >= payload_len { + return sizes; + } + + if let Some(last) = sizes.last_mut() { + let free = MAX_APP_DATA.saturating_sub(*last); + let grow = free.min(payload_len - total); + *last += grow; + total += grow; + } + + while total < payload_len { + let remaining = payload_len - total; + let chunk = remaining.min(MAX_APP_DATA).max(MIN_APP_DATA); + sizes.push(chunk); + total += chunk; + } + + sizes +} + /// Build a ServerHello + CCS + ApplicationData sequence using cached TLS metadata. pub fn build_emulated_server_hello( secret: &[u8], @@ -109,21 +136,44 @@ pub fn build_emulated_server_hello( if sizes.is_empty() { sizes.push(cached.total_app_data_len.max(1024)); } - let sizes = jitter_and_clamp_sizes(&sizes, rng); + let mut sizes = jitter_and_clamp_sizes(&sizes, rng); + let cert_payload = cached + .cert_payload + .as_ref() + .map(|payload| payload.certificate_message.as_slice()) + .filter(|payload| !payload.is_empty()); + + if let Some(payload) = cert_payload { + sizes = ensure_payload_capacity(sizes, payload.len()); + } let mut app_data = Vec::new(); + let mut payload_offset = 0usize; for size in sizes { let mut rec = Vec::with_capacity(5 + size); rec.push(TLS_RECORD_APPLICATION); rec.extend_from_slice(&TLS_VERSION); rec.extend_from_slice(&(size as u16).to_be_bytes()); - if size > 17 { - let body_len = size - 17; - rec.extend_from_slice(&rng.bytes(body_len)); - rec.push(0x16); // inner content type marker (handshake) - rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag + + if let Some(payload) = cert_payload { + let remaining = payload.len().saturating_sub(payload_offset); + let copy_len = remaining.min(size); + if copy_len > 0 { + rec.extend_from_slice(&payload[payload_offset..payload_offset + copy_len]); + payload_offset += copy_len; + } + if size > copy_len { + rec.extend_from_slice(&rng.bytes(size - copy_len)); + } } else { - rec.extend_from_slice(&rng.bytes(size)); + if size > 17 { + let body_len = size - 17; + rec.extend_from_slice(&rng.bytes(body_len)); + rec.push(0x16); // inner content type marker (handshake) + rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag + } else { + rec.extend_from_slice(&rng.bytes(size)); + } } app_data.extend_from_slice(&rec); } @@ -158,3 +208,92 @@ pub fn build_emulated_server_hello( response } + +#[cfg(test)] +mod tests { + use std::time::SystemTime; + + use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsCertPayload}; + + use super::build_emulated_server_hello; + use crate::crypto::SecureRandom; + use crate::protocol::constants::{ + TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, + }; + + fn first_app_data_payload(response: &[u8]) -> &[u8] { + let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; + let ccs_start = 5 + hello_len; + let ccs_len = u16::from_be_bytes([response[ccs_start + 3], response[ccs_start + 4]]) as usize; + let app_start = ccs_start + 5 + ccs_len; + let app_len = u16::from_be_bytes([response[app_start + 3], response[app_start + 4]]) as usize; + &response[app_start + 5..app_start + 5 + app_len] + } + + fn make_cached(cert_payload: Option) -> CachedTlsData { + CachedTlsData { + server_hello_template: ParsedServerHello { + version: [0x03, 0x03], + random: [0u8; 32], + session_id: Vec::new(), + cipher_suite: [0x13, 0x01], + compression: 0, + extensions: Vec::new(), + }, + cert_info: None, + cert_payload, + app_data_records_sizes: vec![64], + total_app_data_len: 64, + fetched_at: SystemTime::now(), + domain: "example.com".to_string(), + } + } + + #[test] + fn test_build_emulated_server_hello_uses_cached_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", + &[0x11; 32], + &[0x22; 16], + &cached, + &rng, + None, + 0, + ); + + assert_eq!(response[0], TLS_RECORD_HANDSHAKE); + let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; + let ccs_start = 5 + hello_len; + assert_eq!(response[ccs_start], TLS_RECORD_CHANGE_CIPHER); + let app_start = ccs_start + 6; + assert_eq!(response[app_start], TLS_RECORD_APPLICATION); + + let payload = first_app_data_payload(&response); + assert!(payload.starts_with(&cert_msg)); + } + + #[test] + fn test_build_emulated_server_hello_random_fallback_when_no_cert_payload() { + let cached = make_cached(None); + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x22; 32], + &[0x33; 16], + &cached, + &rng, + None, + 0, + ); + + let payload = first_app_data_payload(&response); + assert_eq!(payload.len(), 64); + assert_eq!(payload[payload.len() - 17], 0x16); + } +} diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 217b50d..cf21c49 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -19,7 +19,13 @@ use x509_parser::certificate::X509Certificate; use crate::crypto::SecureRandom; use crate::protocol::constants::{TLS_RECORD_APPLICATION, TLS_RECORD_HANDSHAKE}; -use crate::tls_front::types::{ParsedServerHello, TlsExtension, TlsFetchResult, ParsedCertificateInfo}; +use crate::tls_front::types::{ + ParsedCertificateInfo, + ParsedServerHello, + TlsCertPayload, + TlsExtension, + TlsFetchResult, +}; /// No-op verifier: accept any certificate (we only need lengths and metadata). #[derive(Debug)] @@ -315,6 +321,46 @@ fn parse_cert_info(certs: &[CertificateDer<'static>]) -> Option Option<[u8; 3]> { + if value > 0x00ff_ffff { + return None; + } + Some([ + ((value >> 16) & 0xff) as u8, + ((value >> 8) & 0xff) as u8, + (value & 0xff) as u8, + ]) +} + +fn encode_tls13_certificate_message(cert_chain_der: &[Vec]) -> Option> { + if cert_chain_der.is_empty() { + return None; + } + + let mut certificate_list = Vec::new(); + for cert in cert_chain_der { + if cert.is_empty() { + return None; + } + certificate_list.extend_from_slice(&u24_bytes(cert.len())?); + certificate_list.extend_from_slice(cert); + certificate_list.extend_from_slice(&0u16.to_be_bytes()); // cert_entry extensions + } + + // Certificate = context_len(1) + certificate_list_len(3) + entries + let body_len = 1usize + .checked_add(3)? + .checked_add(certificate_list.len())?; + + let mut message = Vec::with_capacity(4 + body_len); + message.push(0x0b); // HandshakeType::certificate + message.extend_from_slice(&u24_bytes(body_len)?); + message.push(0x00); // certificate_request_context length + message.extend_from_slice(&u24_bytes(certificate_list.len())?); + message.extend_from_slice(&certificate_list); + Some(message) +} + async fn fetch_via_raw_tls( host: &str, port: u16, @@ -368,6 +414,7 @@ async fn fetch_via_raw_tls( }, total_app_data_len, cert_info: None, + cert_payload: None, }) } @@ -429,8 +476,19 @@ pub async fn fetch_real_tls( .peer_certificates() .map(|slice| slice.to_vec()) .unwrap_or_default(); + let cert_chain_der: Vec> = certs.iter().map(|c| c.as_ref().to_vec()).collect(); + let cert_payload = encode_tls13_certificate_message(&cert_chain_der).map(|certificate_message| { + TlsCertPayload { + cert_chain_der: cert_chain_der.clone(), + certificate_message, + } + }); - let total_cert_len: usize = certs.iter().map(|c| c.len()).sum::().max(1024); + let total_cert_len = cert_payload + .as_ref() + .map(|payload| payload.certificate_message.len()) + .unwrap_or_else(|| cert_chain_der.iter().map(Vec::len).sum::()) + .max(1024); let cert_info = parse_cert_info(&certs); // Heuristic: split across two records if large to mimic real servers a bit. @@ -453,6 +511,7 @@ pub async fn fetch_real_tls( sni = %sni, len = total_cert_len, cipher = format!("0x{:04x}", u16::from_be_bytes(cipher_suite)), + has_cert_payload = cert_payload.is_some(), "Fetched TLS metadata via rustls" ); @@ -461,5 +520,38 @@ pub async fn fetch_real_tls( app_data_records_sizes: app_data_records_sizes.clone(), total_app_data_len: app_data_records_sizes.iter().sum(), cert_info, + cert_payload, }) } + +#[cfg(test)] +mod tests { + use super::encode_tls13_certificate_message; + + fn read_u24(bytes: &[u8]) -> usize { + ((bytes[0] as usize) << 16) | ((bytes[1] as usize) << 8) | (bytes[2] as usize) + } + + #[test] + fn test_encode_tls13_certificate_message_single_cert() { + let cert = vec![0x30, 0x03, 0x02, 0x01, 0x01]; + let message = encode_tls13_certificate_message(&[cert.clone()]).expect("message"); + + assert_eq!(message[0], 0x0b); + assert_eq!(read_u24(&message[1..4]), message.len() - 4); + assert_eq!(message[4], 0x00); + + let cert_list_len = read_u24(&message[5..8]); + assert_eq!(cert_list_len, cert.len() + 5); + + let cert_len = read_u24(&message[8..11]); + assert_eq!(cert_len, cert.len()); + assert_eq!(&message[11..11 + cert.len()], cert.as_slice()); + assert_eq!(&message[11 + cert.len()..13 + cert.len()], &[0x00, 0x00]); + } + + #[test] + fn test_encode_tls13_certificate_message_empty_chain() { + assert!(encode_tls13_certificate_message(&[]).is_none()); + } +} diff --git a/src/tls_front/types.rs b/src/tls_front/types.rs index eef1953..c411081 100644 --- a/src/tls_front/types.rs +++ b/src/tls_front/types.rs @@ -29,11 +29,23 @@ pub struct ParsedCertificateInfo { pub san_names: Vec, } +/// TLS certificate payload captured from profiled upstream. +/// +/// `certificate_message` stores an encoded TLS 1.3 Certificate handshake +/// message body that can be replayed as opaque ApplicationData bytes in FakeTLS. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TlsCertPayload { + pub cert_chain_der: Vec>, + pub certificate_message: Vec, +} + /// Cached data per SNI used by the emulator. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedTlsData { pub server_hello_template: ParsedServerHello, pub cert_info: Option, + #[serde(default)] + pub cert_payload: Option, pub app_data_records_sizes: Vec, pub total_app_data_len: usize, #[serde(default = "now_system_time", skip_serializing, skip_deserializing)] @@ -52,4 +64,5 @@ pub struct TlsFetchResult { pub app_data_records_sizes: Vec, pub total_app_data_len: usize, pub cert_info: Option, + pub cert_payload: Option, } From 427d65627cb475f21a49e4a8e2455dbda0664202 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:16:00 +0300 Subject: [PATCH 2/6] Drafting new TLS Fetcher --- src/tls_front/fetcher.rs | 56 ++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index cf21c49..340bbab 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -418,23 +418,14 @@ async fn fetch_via_raw_tls( }) } -/// Fetch real TLS metadata for the given SNI: negotiated cipher and cert lengths. -pub async fn fetch_real_tls( +async fn fetch_via_rustls( host: &str, port: u16, sni: &str, connect_timeout: Duration, upstream: Option>, ) -> Result { - // Preferred path: raw TLS probe for accurate record sizing - match fetch_via_raw_tls(host, port, sni, connect_timeout).await { - Ok(res) => return Ok(res), - Err(e) => { - warn!(sni = %sni, error = %e, "Raw TLS fetch failed, falling back to rustls"); - } - } - - // Fallback: rustls handshake to at least get certificate sizes + // rustls handshake path for certificate and basic negotiated metadata. let stream = if let Some(manager) = upstream { // Resolve host to SocketAddr if let Ok(mut addrs) = tokio::net::lookup_host((host, port)).await { @@ -524,6 +515,49 @@ pub async fn fetch_real_tls( }) } +/// Fetch real TLS metadata for the given SNI. +/// +/// Strategy: +/// 1) Probe raw TLS for realistic ServerHello and ApplicationData record sizes. +/// 2) Fetch certificate chain via rustls to build cert payload. +/// 3) Merge both when possible; otherwise auto-fallback to whichever succeeded. +pub async fn fetch_real_tls( + host: &str, + port: u16, + sni: &str, + connect_timeout: Duration, + upstream: Option>, +) -> Result { + let raw_result = match fetch_via_raw_tls(host, port, sni, connect_timeout).await { + Ok(res) => Some(res), + Err(e) => { + warn!(sni = %sni, error = %e, "Raw TLS fetch failed"); + None + } + }; + + match fetch_via_rustls(host, port, sni, connect_timeout, upstream).await { + Ok(rustls_result) => { + if let Some(mut raw) = raw_result { + raw.cert_info = rustls_result.cert_info; + raw.cert_payload = rustls_result.cert_payload; + debug!(sni = %sni, "Fetched TLS metadata via raw probe + rustls cert chain"); + Ok(raw) + } else { + Ok(rustls_result) + } + } + Err(e) => { + if let Some(raw) = raw_result { + warn!(sni = %sni, error = %e, "Rustls cert fetch failed, using raw TLS metadata only"); + Ok(raw) + } else { + Err(e) + } + } + } +} + #[cfg(test)] mod tests { use super::encode_tls13_certificate_message; From 3e4b98b00253b4117ad6c357d52fc230de5a3055 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:23:28 +0300 Subject: [PATCH 3/6] TLS Emulator tuning --- src/tls_front/emulator.rs | 2 +- src/tls_front/fetcher.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index dc5b726..d2a4697 100644 --- a/src/tls_front/emulator.rs +++ b/src/tls_front/emulator.rs @@ -293,7 +293,7 @@ mod tests { ); let payload = first_app_data_payload(&response); - assert_eq!(payload.len(), 64); + assert!(payload.len() >= 64); assert_eq!(payload[payload.len() - 17], 0x16); } } diff --git a/src/tls_front/fetcher.rs b/src/tls_front/fetcher.rs index 340bbab..4678ea3 100644 --- a/src/tls_front/fetcher.rs +++ b/src/tls_front/fetcher.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::time::Duration; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Result, anyhow}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; From cfe8fc72a57885b5e0e5d9ec34941768dec73182 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:42:07 +0300 Subject: [PATCH 4/6] TLS-F tuning Once - full certificate chain, next - only metadata --- src/proxy/handshake.rs | 18 +++-- src/tls_front/cache.rs | 13 +++- src/tls_front/emulator.rs | 137 +++++++++++++++++++++++++++++++------- 3 files changed, 139 insertions(+), 29 deletions(-) diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index d060dc7..ea36aca 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -108,11 +108,18 @@ where let cached = if config.censorship.tls_emulation { if let Some(cache) = tls_cache.as_ref() { - if let Some(sni) = tls::extract_sni_from_client_hello(handshake) { - Some(cache.get(&sni).await) + let selected_domain = if let Some(sni) = tls::extract_sni_from_client_hello(handshake) { + if cache.contains_domain(&sni).await { + sni + } else { + config.censorship.tls_domain.clone() + } } else { - Some(cache.get(&config.censorship.tls_domain).await) - } + config.censorship.tls_domain.clone() + }; + let cached_entry = cache.get(&selected_domain).await; + let use_full_cert_payload = cache.take_full_cert_budget(&selected_domain).await; + Some((cached_entry, use_full_cert_payload)) } else { None } @@ -137,12 +144,13 @@ where None }; - let response = if let Some(cached_entry) = cached { + let response = if let Some((cached_entry, use_full_cert_payload)) = cached { emulator::build_emulated_server_hello( secret, &validation.digest, &validation.session_id, &cached_entry, + use_full_cert_payload, rng, selected_alpn.clone(), config.censorship.tls_new_session_tickets, diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index 803cdf9..1d3fd88 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{SystemTime, Duration}; @@ -14,6 +14,7 @@ use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsFetchResult}; pub struct TlsFrontCache { memory: RwLock>>, default: Arc, + full_cert_sent: RwLock>, disk_path: PathBuf, } @@ -46,6 +47,7 @@ impl TlsFrontCache { Self { memory: RwLock::new(map), default, + full_cert_sent: RwLock::new(HashSet::new()), disk_path: disk_path.as_ref().to_path_buf(), } } @@ -55,6 +57,15 @@ impl TlsFrontCache { guard.get(sni).cloned().unwrap_or_else(|| self.default.clone()) } + pub async fn contains_domain(&self, domain: &str) -> bool { + self.memory.read().await.contains_key(domain) + } + + /// Returns true only on first request for a domain after process start. + pub async fn take_full_cert_budget(&self, domain: &str) -> bool { + self.full_cert_sent.write().await.insert(domain.to_string()) + } + pub async fn set(&self, domain: &str, data: CachedTlsData) { let mut guard = self.memory.write().await; guard.insert(domain.to_string(), Arc::new(data)); diff --git a/src/tls_front/emulator.rs b/src/tls_front/emulator.rs index d2a4697..25d2a8c 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; +use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo}; const MIN_APP_DATA: usize = 64; const MAX_APP_DATA: usize = 16640; // RFC 8446 ยง5.2 allows up to 2^14 + 256 @@ -27,39 +27,81 @@ fn jitter_and_clamp_sizes(sizes: &[usize], rng: &SecureRandom) -> Vec { .collect() } +fn app_data_body_capacity(sizes: &[usize]) -> usize { + sizes.iter().map(|&size| size.saturating_sub(17)).sum() +} + fn ensure_payload_capacity(mut sizes: Vec, payload_len: usize) -> Vec { if payload_len == 0 { return sizes; } - let mut total = sizes.iter().sum::(); - if total >= payload_len { + let mut body_total = app_data_body_capacity(&sizes); + if body_total >= payload_len { return sizes; } if let Some(last) = sizes.last_mut() { let free = MAX_APP_DATA.saturating_sub(*last); - let grow = free.min(payload_len - total); + let grow = free.min(payload_len - body_total); *last += grow; - total += grow; + body_total += grow; } - while total < payload_len { - let remaining = payload_len - total; - let chunk = remaining.min(MAX_APP_DATA).max(MIN_APP_DATA); + while body_total < payload_len { + let remaining = payload_len - body_total; + let chunk = (remaining + 17).min(MAX_APP_DATA).max(MIN_APP_DATA); sizes.push(chunk); - total += chunk; + body_total += chunk.saturating_sub(17); } sizes } +fn build_compact_cert_info_payload(cert_info: &ParsedCertificateInfo) -> Option> { + let mut fields = Vec::new(); + + if let Some(subject) = cert_info.subject_cn.as_deref() { + fields.push(format!("CN={subject}")); + } + if let Some(issuer) = cert_info.issuer_cn.as_deref() { + fields.push(format!("ISSUER={issuer}")); + } + if let Some(not_before) = cert_info.not_before_unix { + fields.push(format!("NB={not_before}")); + } + if let Some(not_after) = cert_info.not_after_unix { + fields.push(format!("NA={not_after}")); + } + if !cert_info.san_names.is_empty() { + let san = cert_info + .san_names + .iter() + .take(8) + .map(String::as_str) + .collect::>() + .join(","); + fields.push(format!("SAN={san}")); + } + + if fields.is_empty() { + return None; + } + + let mut payload = fields.join(";").into_bytes(); + if payload.len() > 512 { + payload.truncate(512); + } + Some(payload) +} + /// Build a ServerHello + CCS + ApplicationData sequence using cached TLS metadata. pub fn build_emulated_server_hello( secret: &[u8], client_digest: &[u8; TLS_DIGEST_LEN], session_id: &[u8], cached: &CachedTlsData, + use_full_cert_payload: bool, rng: &SecureRandom, alpn: Option>, new_session_tickets: u8, @@ -137,13 +179,22 @@ pub fn build_emulated_server_hello( sizes.push(cached.total_app_data_len.max(1024)); } let mut sizes = jitter_and_clamp_sizes(&sizes, rng); - let cert_payload = cached - .cert_payload + let compact_payload = cached + .cert_info .as_ref() - .map(|payload| payload.certificate_message.as_slice()) - .filter(|payload| !payload.is_empty()); + .and_then(build_compact_cert_info_payload); + let selected_payload: Option<&[u8]> = if use_full_cert_payload { + cached + .cert_payload + .as_ref() + .map(|payload| payload.certificate_message.as_slice()) + .filter(|payload| !payload.is_empty()) + .or_else(|| compact_payload.as_deref()) + } else { + compact_payload.as_deref() + }; - if let Some(payload) = cert_payload { + if let Some(payload) = selected_payload { sizes = ensure_payload_capacity(sizes, payload.len()); } @@ -155,15 +206,22 @@ pub fn build_emulated_server_hello( rec.extend_from_slice(&TLS_VERSION); rec.extend_from_slice(&(size as u16).to_be_bytes()); - if let Some(payload) = cert_payload { - let remaining = payload.len().saturating_sub(payload_offset); - let copy_len = remaining.min(size); - if copy_len > 0 { - rec.extend_from_slice(&payload[payload_offset..payload_offset + copy_len]); - payload_offset += copy_len; - } - if size > copy_len { - rec.extend_from_slice(&rng.bytes(size - copy_len)); + if let Some(payload) = selected_payload { + if size > 17 { + let body_len = size - 17; + let remaining = payload.len().saturating_sub(payload_offset); + let copy_len = remaining.min(body_len); + if copy_len > 0 { + rec.extend_from_slice(&payload[payload_offset..payload_offset + copy_len]); + payload_offset += copy_len; + } + if body_len > copy_len { + rec.extend_from_slice(&rng.bytes(body_len - copy_len)); + } + rec.push(0x16); // inner content type marker (handshake) + rec.extend_from_slice(&rng.bytes(16)); // AEAD-like tag + } else { + rec.extend_from_slice(&rng.bytes(size)); } } else { if size > 17 { @@ -262,6 +320,7 @@ mod tests { &[0x11; 32], &[0x22; 16], &cached, + true, &rng, None, 0, @@ -287,6 +346,7 @@ mod tests { &[0x22; 32], &[0x33; 16], &cached, + true, &rng, None, 0, @@ -296,4 +356,35 @@ mod tests { assert!(payload.len() >= 64); assert_eq!(payload[payload.len() - 17], 0x16); } + + #[test] + fn test_build_emulated_server_hello_uses_compact_payload_after_first() { + let cert_msg = vec![0x0b, 0x00, 0x00, 0x05, 0x00, 0xaa, 0xbb, 0xcc, 0xdd]; + let mut cached = make_cached(Some(TlsCertPayload { + cert_chain_der: vec![vec![0x30, 0x01, 0x00]], + certificate_message: cert_msg, + })); + 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(), "www.example.com".to_string()], + }); + + let rng = SecureRandom::new(); + let response = build_emulated_server_hello( + b"secret", + &[0x44; 32], + &[0x55; 16], + &cached, + false, + &rng, + None, + 0, + ); + + let payload = first_app_data_payload(&response); + assert!(payload.starts_with(b"CN=example.com")); + } } From b5d0564f2adfeb0570423a52b3b8070853b2c5f3 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:47:44 +0300 Subject: [PATCH 5/6] Time-To-Life for TLS Full Certificate --- src/cli.rs | 1 + src/config/defaults.rs | 4 ++ src/config/types.rs | 7 ++++ src/proxy/handshake.rs | 9 +++- src/tls_front/cache.rs | 94 ++++++++++++++++++++++++++++++++++++++---- 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index f737ff9..cf98121 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -229,6 +229,7 @@ tls_domain = "{domain}" mask = true mask_port = 443 fake_cert_len = 2048 +tls_full_cert_ttl_secs = 90 [access] replay_check_len = 65536 diff --git a/src/config/defaults.rs b/src/config/defaults.rs index 2dee3e0..90dd6f9 100644 --- a/src/config/defaults.rs +++ b/src/config/defaults.rs @@ -122,6 +122,10 @@ pub(crate) fn default_tls_new_session_tickets() -> u8 { 0 } +pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 { + 90 +} + pub(crate) fn default_server_hello_delay_min_ms() -> u64 { 0 } diff --git a/src/config/types.rs b/src/config/types.rs index 503bb38..a303db8 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -474,6 +474,12 @@ pub struct AntiCensorshipConfig { #[serde(default = "default_tls_new_session_tickets")] pub tls_new_session_tickets: u8, + /// 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. + #[serde(default = "default_tls_full_cert_ttl_secs")] + pub tls_full_cert_ttl_secs: u64, + /// Enforce ALPN echo of client preference. #[serde(default = "default_alpn_enforce")] pub alpn_enforce: bool, @@ -494,6 +500,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(), + tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), alpn_enforce: default_alpn_enforce(), } } diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index ea36aca..d96a86c 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -2,6 +2,7 @@ use std::net::SocketAddr; use std::sync::Arc; +use std::time::Duration; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn, trace, info}; use zeroize::Zeroize; @@ -118,7 +119,13 @@ where config.censorship.tls_domain.clone() }; let cached_entry = cache.get(&selected_domain).await; - let use_full_cert_payload = cache.take_full_cert_budget(&selected_domain).await; + let use_full_cert_payload = cache + .take_full_cert_budget_for_ip( + &selected_domain, + peer.ip(), + Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), + ) + .await; Some((cached_entry, use_full_cert_payload)) } else { None diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index 1d3fd88..22b8538 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -1,7 +1,8 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::{SystemTime, Duration}; +use std::time::{Duration, Instant, SystemTime}; use tokio::sync::RwLock; use tokio::time::sleep; @@ -14,7 +15,7 @@ use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsFetchResult}; pub struct TlsFrontCache { memory: RwLock>>, default: Arc, - full_cert_sent: RwLock>, + full_cert_sent: RwLock>, disk_path: PathBuf, } @@ -47,7 +48,7 @@ impl TlsFrontCache { Self { memory: RwLock::new(map), default, - full_cert_sent: RwLock::new(HashSet::new()), + full_cert_sent: RwLock::new(HashMap::new()), disk_path: disk_path.as_ref().to_path_buf(), } } @@ -61,9 +62,41 @@ impl TlsFrontCache { self.memory.read().await.contains_key(domain) } - /// Returns true only on first request for a domain after process start. - pub async fn take_full_cert_budget(&self, domain: &str) -> bool { - self.full_cert_sent.write().await.insert(domain.to_string()) + /// Returns true when full cert payload should be sent for (domain, client_ip) + /// according to TTL policy. + pub async fn take_full_cert_budget_for_ip( + &self, + domain: &str, + client_ip: IpAddr, + ttl: Duration, + ) -> bool { + if ttl.is_zero() { + self.full_cert_sent + .write() + .await + .insert((domain.to_string(), client_ip), Instant::now()); + return true; + } + + let now = Instant::now(); + let mut guard = self.full_cert_sent.write().await; + guard.retain(|_, seen_at| now.duration_since(*seen_at) < ttl); + + let key = (domain.to_string(), client_ip); + match guard.get_mut(&key) { + Some(seen_at) => { + if now.duration_since(*seen_at) >= ttl { + *seen_at = now; + true + } else { + false + } + } + None => { + guard.insert(key, now); + true + } + } } pub async fn set(&self, domain: &str, data: CachedTlsData) { @@ -174,3 +207,50 @@ impl TlsFrontCache { &self.disk_path } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_take_full_cert_budget_for_ip_uses_ttl() { + let cache = TlsFrontCache::new( + &["example.com".to_string()], + 1024, + "tlsfront-test-cache", + ); + let ip: IpAddr = "127.0.0.1".parse().expect("ip"); + let ttl = Duration::from_millis(80); + + assert!(cache + .take_full_cert_budget_for_ip("example.com", ip, ttl) + .await); + assert!(!cache + .take_full_cert_budget_for_ip("example.com", ip, ttl) + .await); + + tokio::time::sleep(Duration::from_millis(90)).await; + + assert!(cache + .take_full_cert_budget_for_ip("example.com", ip, ttl) + .await); + } + + #[tokio::test] + async fn test_take_full_cert_budget_for_ip_zero_ttl_always_allows_full_payload() { + let cache = TlsFrontCache::new( + &["example.com".to_string()], + 1024, + "tlsfront-test-cache", + ); + let ip: IpAddr = "127.0.0.1".parse().expect("ip"); + let ttl = Duration::ZERO; + + assert!(cache + .take_full_cert_budget_for_ip("example.com", ip, ttl) + .await); + assert!(cache + .take_full_cert_budget_for_ip("example.com", ip, ttl) + .await); + } +} From 4011812fda5bfbce768c294ad1be5334d9569561 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Mon, 23 Feb 2026 05:48:55 +0300 Subject: [PATCH 6/6] TLS FC TTL Improvements --- src/proxy/handshake.rs | 1 - src/tls_front/cache.rs | 22 ++++++++++------------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/proxy/handshake.rs b/src/proxy/handshake.rs index d96a86c..750d839 100644 --- a/src/proxy/handshake.rs +++ b/src/proxy/handshake.rs @@ -121,7 +121,6 @@ where let cached_entry = cache.get(&selected_domain).await; let use_full_cert_payload = cache .take_full_cert_budget_for_ip( - &selected_domain, peer.ip(), Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), ) diff --git a/src/tls_front/cache.rs b/src/tls_front/cache.rs index 22b8538..15a97af 100644 --- a/src/tls_front/cache.rs +++ b/src/tls_front/cache.rs @@ -15,7 +15,7 @@ use crate::tls_front::types::{CachedTlsData, ParsedServerHello, TlsFetchResult}; pub struct TlsFrontCache { memory: RwLock>>, default: Arc, - full_cert_sent: RwLock>, + full_cert_sent: RwLock>, disk_path: PathBuf, } @@ -62,11 +62,10 @@ impl TlsFrontCache { self.memory.read().await.contains_key(domain) } - /// Returns true when full cert payload should be sent for (domain, client_ip) + /// Returns true when full cert payload should be sent for client_ip /// according to TTL policy. pub async fn take_full_cert_budget_for_ip( &self, - domain: &str, client_ip: IpAddr, ttl: Duration, ) -> bool { @@ -74,7 +73,7 @@ impl TlsFrontCache { self.full_cert_sent .write() .await - .insert((domain.to_string(), client_ip), Instant::now()); + .insert(client_ip, Instant::now()); return true; } @@ -82,8 +81,7 @@ impl TlsFrontCache { let mut guard = self.full_cert_sent.write().await; guard.retain(|_, seen_at| now.duration_since(*seen_at) < ttl); - let key = (domain.to_string(), client_ip); - match guard.get_mut(&key) { + match guard.get_mut(&client_ip) { Some(seen_at) => { if now.duration_since(*seen_at) >= ttl { *seen_at = now; @@ -93,7 +91,7 @@ impl TlsFrontCache { } } None => { - guard.insert(key, now); + guard.insert(client_ip, now); true } } @@ -223,16 +221,16 @@ mod tests { let ttl = Duration::from_millis(80); assert!(cache - .take_full_cert_budget_for_ip("example.com", ip, ttl) + .take_full_cert_budget_for_ip(ip, ttl) .await); assert!(!cache - .take_full_cert_budget_for_ip("example.com", ip, ttl) + .take_full_cert_budget_for_ip(ip, ttl) .await); tokio::time::sleep(Duration::from_millis(90)).await; assert!(cache - .take_full_cert_budget_for_ip("example.com", ip, ttl) + .take_full_cert_budget_for_ip(ip, ttl) .await); } @@ -247,10 +245,10 @@ mod tests { let ttl = Duration::ZERO; assert!(cache - .take_full_cert_budget_for_ip("example.com", ip, ttl) + .take_full_cert_budget_for_ip(ip, ttl) .await); assert!(cache - .take_full_cert_budget_for_ip("example.com", ip, ttl) + .take_full_cert_budget_for_ip(ip, ttl) .await); } }