Replay-safe TLS-F ServerHello profile consistency

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-06-11 16:11:41 +03:00
parent 409b0ef5ee
commit 6dc9f8c27a
5 changed files with 362 additions and 38 deletions

View File

@@ -3937,10 +3937,20 @@ mod tests {
session_id: Vec::new(),
cipher_suite: [0x13, 0x01],
compression: 0,
extensions: vec![crate::tls_front::types::TlsExtension {
ext_type: 0x0033,
data: vec![0x00, 0x1d, 0x00, 0x20],
}],
extensions: {
let mut key_share = vec![0x00, 0x1d, 0x00, 0x20];
key_share.resize(36, 0x42);
vec![
crate::tls_front::types::TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
crate::tls_front::types::TlsExtension {
ext_type: 0x0033,
data: key_share,
},
]
},
},
cert_info: None,
cert_payload: Some(TlsCertPayload {
@@ -3981,12 +3991,12 @@ mod tests {
);
assert!(
output.contains(
"telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 52"
"telemt_tls_front_profile_server_hello_bytes{domain=\"primary.example\"} 90"
)
);
assert!(
output.contains(
"telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 1"
"telemt_tls_front_profile_server_hello_extensions{domain=\"primary.example\"} 2"
)
);
assert!(

View File

@@ -364,6 +364,9 @@ impl TlsFrontCache {
warn!(domain = %cached.domain, "Skipping stale TLS cache entry (>72h)");
continue;
}
cached
.behavior_profile
.refresh_server_hello_summary(&cached.server_hello_template);
let domain = cached.domain.clone();
self.set(&domain, cached).await;
loaded += 1;

View File

@@ -21,6 +21,13 @@ const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
const EXT_KEY_SHARE: u16 = 0x0033;
const EXT_ALPN: u16 = 0x0010;
#[derive(Clone, Copy)]
enum FallbackShapeFamily {
NginxLike,
BoringSslLike,
RustlsLike,
}
fn parse_profiled_key_share_group(data: &[u8]) -> Option<u16> {
if data.len() < 4 {
return None;
@@ -38,12 +45,26 @@ fn parse_profiled_key_share_group(data: &[u8]) -> Option<u16> {
}
}
/// Return the origin-profiled ServerHello key_share group when it is replay-safe.
pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option<u16> {
if !matches!(
fn effective_profiled_server_hello_record_len(cached: &CachedTlsData) -> usize {
if cached.behavior_profile.server_hello_record_len == 0 {
cached.server_hello_template.record_body_len()
} else {
cached.behavior_profile.server_hello_record_len
}
}
fn should_replay_profiled_server_hello_shape(cached: &CachedTlsData) -> bool {
matches!(
cached.behavior_profile.source,
TlsProfileSource::Raw | TlsProfileSource::Merged
) {
) && cached.server_hello_template.is_replay_safe_tls13_shape(
effective_profiled_server_hello_record_len(cached),
)
}
/// Return the origin-profiled ServerHello key_share group when it is replay-safe.
pub(crate) fn profiled_server_hello_key_share_group(cached: &CachedTlsData) -> Option<u16> {
if !should_replay_profiled_server_hello_shape(cached) {
return None;
}
@@ -105,6 +126,76 @@ fn ensure_payload_capacity(mut sizes: Vec<usize>, payload_len: usize) -> Vec<usi
sizes
}
fn fallback_shape_family(cached: &CachedTlsData) -> FallbackShapeFamily {
match cached.behavior_profile.source {
TlsProfileSource::Rustls => FallbackShapeFamily::RustlsLike,
TlsProfileSource::Default => {
let mut hasher = Hasher::new();
hasher.update(cached.domain.as_bytes());
hasher.update(&cached.total_app_data_len.to_le_bytes());
if hasher.finalize() & 1 == 0 {
FallbackShapeFamily::NginxLike
} else {
FallbackShapeFamily::BoringSslLike
}
}
TlsProfileSource::Raw | TlsProfileSource::Merged => FallbackShapeFamily::NginxLike,
}
}
fn fallback_total_app_data_len(cached: &CachedTlsData) -> usize {
cached
.total_app_data_len
.max(cached.app_data_records_sizes.iter().sum())
.max(1024)
}
fn push_fallback_size(sizes: &mut Vec<usize>, size: usize) {
sizes.push(size.clamp(MIN_APP_DATA, MAX_APP_DATA));
}
fn fallback_family_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
if matches!(cached.behavior_profile.source, TlsProfileSource::Rustls)
&& !cached.app_data_records_sizes.is_empty()
{
return cached.app_data_records_sizes.clone();
}
let family = fallback_shape_family(cached);
let mut remaining = fallback_total_app_data_len(cached);
let preferred_chunk = match family {
FallbackShapeFamily::NginxLike => 2896,
FallbackShapeFamily::BoringSslLike => 1369,
FallbackShapeFamily::RustlsLike => 2048,
};
let split_threshold = match family {
FallbackShapeFamily::NginxLike => 4096,
FallbackShapeFamily::BoringSslLike => 1536,
FallbackShapeFamily::RustlsLike => 3072,
};
if remaining <= split_threshold {
return vec![remaining.clamp(MIN_APP_DATA, MAX_APP_DATA)];
}
let mut sizes: Vec<usize> = Vec::new();
while remaining > 0 {
let chunk = remaining.min(preferred_chunk).min(MAX_APP_DATA);
if chunk < MIN_APP_DATA {
if let Some(last) = sizes.last_mut() {
*last = (*last).saturating_add(chunk).min(MAX_APP_DATA);
} else {
push_fallback_size(&mut sizes, chunk);
}
break;
}
push_fallback_size(&mut sizes, chunk);
remaining = remaining.saturating_sub(chunk);
}
sizes
}
fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
match cached.behavior_profile.source {
TlsProfileSource::Raw | TlsProfileSource::Merged => {
@@ -116,14 +207,10 @@ fn emulated_app_data_sizes(cached: &CachedTlsData) -> Vec<usize> {
}
return vec![cached.total_app_data_len.max(1024)];
}
TlsProfileSource::Default | TlsProfileSource::Rustls => {}
TlsProfileSource::Default | TlsProfileSource::Rustls => {
return fallback_family_app_data_sizes(cached);
}
}
let mut sizes = cached.app_data_records_sizes.clone();
if sizes.is_empty() {
sizes.push(cached.total_app_data_len.max(1024));
}
sizes
}
fn emulated_change_cipher_spec_count(_cached: &CachedTlsData) -> usize {
@@ -151,7 +238,13 @@ fn emulated_ticket_record_sizes(
sizes.extend(profiled_sizes.iter().copied().take(target_count));
while sizes.len() < target_count {
sizes.push(rng.range(48) + 48);
let family = fallback_shape_family(cached);
let base = match family {
FallbackShapeFamily::NginxLike => 96,
FallbackShapeFamily::BoringSslLike => 80,
FallbackShapeFamily::RustlsLike => 112,
};
sizes.push(base + rng.range(64));
}
sizes
@@ -287,22 +380,24 @@ fn build_profiled_server_hello_extensions(
let mut saw_supported_versions = false;
let mut saw_key_share = false;
for ext in &cached.server_hello_template.extensions {
replay_profiled_server_hello_extension(
ext,
&mut extensions,
server_key_share,
&mut saw_supported_versions,
&mut saw_key_share,
);
if should_replay_profiled_server_hello_shape(cached) {
for ext in &cached.server_hello_template.extensions {
replay_profiled_server_hello_extension(
ext,
&mut extensions,
server_key_share,
&mut saw_supported_versions,
&mut saw_key_share,
);
}
}
if !saw_key_share {
push_key_share_extension(&mut extensions, server_key_share);
}
if !saw_supported_versions {
push_supported_versions_extension(&mut extensions);
}
if !saw_key_share {
push_key_share_extension(&mut extensions, server_key_share);
}
extensions
}
@@ -594,10 +689,16 @@ mod tests {
fn profiled_server_hello_key_share_group_reads_raw_x25519_profile() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
}];
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
];
assert_eq!(
profiled_server_hello_key_share_group(&cached),
@@ -711,6 +812,82 @@ mod tests {
);
}
#[test]
fn test_build_emulated_server_hello_replays_safe_raw_extension_order() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x21; 32],
&[0x22; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
assert_eq!(
server_hello_extension_types(&response),
vec![0x0033, 0x002b]
);
}
#[test]
fn test_build_emulated_server_hello_uses_canonical_order_for_unsafe_raw_shape() {
let mut cached = make_cached(None);
cached.behavior_profile.source = TlsProfileSource::Raw;
cached.server_hello_template.extensions = vec![
TlsExtension {
ext_type: 0x0010,
data: vec![0x00, 0x03, 0x02, b'h', b'2'],
},
TlsExtension {
ext_type: 0x0033,
data: server_key_share_extension_data(TLS_NAMED_GROUP_X25519, 32),
},
TlsExtension {
ext_type: 0x002b,
data: vec![0x03, 0x04],
},
];
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x21; 32],
&[0x22; 16],
&cached,
false,
true,
ClientHelloTlsVersion::Tls13,
[0x13, 0x01],
&test_server_key_share(),
&rng,
None,
0,
);
assert_eq!(
server_hello_extension_types(&response),
vec![0x002b, 0x0033]
);
}
#[test]
fn test_build_emulated_server_hello_random_fallback_when_no_cert_payload() {
let cached = make_cached(None);

View File

@@ -1477,7 +1477,8 @@ 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();
raw.behavior_profile
.refresh_server_hello_summary(&raw.server_hello_parsed);
debug!(sni = %sni, "Fetched TLS metadata via adaptive raw probe + rustls cert chain");
Ok(raw)
} else {

View File

@@ -1,7 +1,11 @@
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
const EXT_ALPN: u16 = 0x0010;
const EXT_SUPPORTED_VERSIONS: u16 = 0x002b;
const EXT_KEY_SHARE: u16 = 0x0033;
const TLS_LEGACY_SERVER_HELLO_VERSION: [u8; 2] = [0x03, 0x03];
const TLS_VERSION_13: [u8; 2] = [0x03, 0x04];
const TLS_NAMED_GROUP_X25519: u16 = 0x001d;
const TLS_NAMED_GROUP_X25519MLKEM768: u16 = 0x11ec;
@@ -50,6 +54,50 @@ impl ParsedServerHello {
.find(|extension| extension.ext_type == EXT_KEY_SHARE)
.and_then(|extension| parse_key_share_group(&extension.data))
}
/// Return true when the cached ServerHello can safely drive visible TLS 1.3 replay.
pub(crate) fn is_replay_safe_tls13_shape(&self, record_body_len: usize) -> bool {
if self.version != TLS_LEGACY_SERVER_HELLO_VERSION
|| self.compression != 0
|| self.session_id.len() > 32
|| !is_supported_tls13_cipher_suite(self.cipher_suite)
{
return false;
}
if record_body_len != 0 && record_body_len != self.record_body_len() {
return false;
}
let mut saw_supported_versions = false;
let mut saw_key_share = false;
for extension in &self.extensions {
match extension.ext_type {
EXT_SUPPORTED_VERSIONS => {
if saw_supported_versions || extension.data.as_slice() != TLS_VERSION_13 {
return false;
}
saw_supported_versions = true;
}
EXT_KEY_SHARE => {
if saw_key_share || parse_key_share_group(&extension.data).is_none() {
return false;
}
saw_key_share = true;
}
EXT_ALPN => {
return false;
}
_ => {}
}
}
saw_supported_versions && saw_key_share
}
}
fn is_supported_tls13_cipher_suite(cipher_suite: [u8; 2]) -> bool {
matches!(u16::from_be_bytes(cipher_suite), 0x1301 | 0x1302 | 0x1303)
}
fn parse_key_share_group(data: &[u8]) -> Option<u16> {
@@ -168,25 +216,29 @@ impl Default for TlsBehaviorProfile {
impl TlsBehaviorProfile {
/// Refresh cached visible ServerHello summary fields and quality.
pub(crate) fn refresh_server_hello_summary(&mut self, server_hello: &ParsedServerHello) {
let mut has_replay_safe_server_hello = false;
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();
has_replay_safe_server_hello =
server_hello.is_replay_safe_tls13_shape(self.server_hello_record_len);
} else {
self.server_hello_record_len = 0;
self.server_hello_extension_types.clear();
self.server_hello_key_share_group = None;
}
self.refresh_quality();
self.refresh_quality(has_replay_safe_server_hello);
}
/// 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;
fn refresh_quality(&mut self, has_replay_safe_server_hello: bool) {
let has_raw_server_hello =
matches!(self.source, TlsProfileSource::Raw | TlsProfileSource::Merged)
&& has_replay_safe_server_hello;
self.quality = if has_raw_server_hello && !self.app_data_record_sizes.is_empty() {
TlsProfileQuality::RawStrict
} else if has_raw_server_hello {
@@ -233,6 +285,34 @@ pub struct TlsFetchResult {
mod tests {
use super::*;
fn tls13_key_share_extension() -> TlsExtension {
let mut data = Vec::new();
data.extend_from_slice(&TLS_NAMED_GROUP_X25519.to_be_bytes());
data.extend_from_slice(&32u16.to_be_bytes());
data.resize(36, 0x42);
TlsExtension {
ext_type: EXT_KEY_SHARE,
data,
}
}
fn replay_safe_server_hello() -> ParsedServerHello {
ParsedServerHello {
version: TLS_LEGACY_SERVER_HELLO_VERSION,
random: [0u8; 32],
session_id: vec![0x11; 32],
cipher_suite: [0x13, 0x01],
compression: 0,
extensions: vec![
TlsExtension {
ext_type: EXT_SUPPORTED_VERSIONS,
data: TLS_VERSION_13.to_vec(),
},
tls13_key_share_extension(),
],
}
}
#[test]
fn cached_tls_data_deserializes_without_behavior_profile() {
let json = r#"
@@ -260,4 +340,57 @@ mod tests {
assert_eq!(cached.behavior_profile.source, TlsProfileSource::Default);
assert_eq!(cached.behavior_profile.quality, TlsProfileQuality::Fallback);
}
#[test]
fn replay_safe_raw_server_hello_with_app_data_is_raw_strict() {
let server_hello = replay_safe_server_hello();
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
app_data_record_sizes: vec![1200],
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::RawStrict);
assert_eq!(
behavior.server_hello_extension_types,
vec![EXT_SUPPORTED_VERSIONS, EXT_KEY_SHARE]
);
assert_eq!(
behavior.server_hello_key_share_group,
Some(TLS_NAMED_GROUP_X25519)
);
}
#[test]
fn replay_safe_raw_server_hello_without_app_data_is_raw_partial() {
let server_hello = replay_safe_server_hello();
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::RawPartial);
}
#[test]
fn malformed_raw_server_hello_is_fallback_quality() {
let mut server_hello = replay_safe_server_hello();
server_hello.extensions.push(TlsExtension {
ext_type: EXT_ALPN,
data: vec![0x00, 0x03, 0x02, b'h', b'2'],
});
let mut behavior = TlsBehaviorProfile {
source: TlsProfileSource::Raw,
app_data_record_sizes: vec![1200],
..TlsBehaviorProfile::default()
};
behavior.refresh_server_hello_summary(&server_hello);
assert_eq!(behavior.quality, TlsProfileQuality::Fallback);
}
}