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] 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, }