TLS-F New Methods

This commit is contained in:
Alexey 2026-03-14 20:38:24 +03:00
parent 4ddbb97908
commit 4677b43c6e
No known key found for this signature in database
4 changed files with 233 additions and 28 deletions

View File

@ -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(),
};

View File

@ -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<u8> {
// --- 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());
}
}

View File

@ -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<ParsedServerHello> {
})
}
fn derive_behavior_profile(records: &[(u8, Vec<u8>)]) -> 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<ParsedCertificateInfo> {
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::<usize>().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);
}
}

View File

@ -39,6 +39,53 @@ pub struct TlsCertPayload {
pub certificate_message: Vec<u8>,
}
/// 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<usize>,
/// Sizes of small tail ApplicationData records that look like tickets.
#[serde(default)]
pub ticket_record_sizes: Vec<usize>,
/// 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<TlsCertPayload>,
pub app_data_records_sizes: Vec<usize>,
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<usize>,
pub total_app_data_len: usize,
#[serde(default)]
pub behavior_profile: TlsBehaviorProfile,
pub cert_info: Option<ParsedCertificateInfo>,
pub cert_payload: Option<TlsCertPayload>,
}
#[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);
}
}