TLS 1.2/1.3 Correctness + Full ServerHello + Rustfmt

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-04-23 21:29:18 +03:00
parent 8684378030
commit 67357310f7
12 changed files with 301 additions and 24 deletions

View File

@@ -689,6 +689,7 @@ tls_domain = "{domain}"
mask = true mask = true
mask_port = 443 mask_port = 443
fake_cert_len = 2048 fake_cert_len = 2048
serverhello_compact = false
tls_full_cert_ttl_secs = 90 tls_full_cert_ttl_secs = 90
[access] [access]

View File

@@ -575,6 +575,10 @@ pub(crate) fn default_tls_new_session_tickets() -> u8 {
0 0
} }
pub(crate) fn default_serverhello_compact() -> bool {
false
}
pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 { pub(crate) fn default_tls_full_cert_ttl_secs() -> u64 {
90 90
} }

View File

@@ -624,6 +624,7 @@ fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot_changed: b
|| old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms || old.censorship.server_hello_delay_min_ms != new.censorship.server_hello_delay_min_ms
|| old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms || old.censorship.server_hello_delay_max_ms != new.censorship.server_hello_delay_max_ms
|| old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets || old.censorship.tls_new_session_tickets != new.censorship.tls_new_session_tickets
|| old.censorship.serverhello_compact != new.censorship.serverhello_compact
|| old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs || old.censorship.tls_full_cert_ttl_secs != new.censorship.tls_full_cert_ttl_secs
|| old.censorship.alpn_enforce != new.censorship.alpn_enforce || old.censorship.alpn_enforce != new.censorship.alpn_enforce
|| old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol || old.censorship.mask_proxy_protocol != new.censorship.mask_proxy_protocol

View File

@@ -1723,9 +1723,16 @@ pub struct AntiCensorshipConfig {
#[serde(default = "default_tls_new_session_tickets")] #[serde(default = "default_tls_new_session_tickets")]
pub tls_new_session_tickets: u8, pub tls_new_session_tickets: u8,
/// Enable compact ServerHello payload mode.
/// When false, FakeTLS always uses full ServerHello payload behavior.
/// When true, compact certificate payload mode can be used by TTL policy.
#[serde(default = "default_serverhello_compact")]
pub serverhello_compact: bool,
/// TTL in seconds for sending full certificate payload per client IP. /// TTL in seconds for sending full certificate payload per client IP.
/// First client connection per (SNI domain, client IP) gets full cert payload. /// First client connection per (SNI domain, client IP) gets full cert payload.
/// Subsequent handshakes within TTL use compact cert metadata payload. /// Subsequent handshakes within TTL use compact cert metadata payload.
/// Applied only when `serverhello_compact` is enabled.
#[serde(default = "default_tls_full_cert_ttl_secs")] #[serde(default = "default_tls_full_cert_ttl_secs")]
pub tls_full_cert_ttl_secs: u64, pub tls_full_cert_ttl_secs: u64,
@@ -1820,6 +1827,7 @@ impl Default for AntiCensorshipConfig {
server_hello_delay_min_ms: default_server_hello_delay_min_ms(), server_hello_delay_min_ms: default_server_hello_delay_min_ms(),
server_hello_delay_max_ms: default_server_hello_delay_max_ms(), server_hello_delay_max_ms: default_server_hello_delay_max_ms(),
tls_new_session_tickets: default_tls_new_session_tickets(), tls_new_session_tickets: default_tls_new_session_tickets(),
serverhello_compact: default_serverhello_compact(),
tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(), tls_full_cert_ttl_secs: default_tls_full_cert_ttl_secs(),
alpn_enforce: default_alpn_enforce(), alpn_enforce: default_alpn_enforce(),
mask_proxy_protocol: 0, mask_proxy_protocol: 0,

View File

@@ -549,9 +549,7 @@ pub(crate) fn expected_handshake_close_description(
std::io::ErrorKind::BrokenPipe => { std::io::ErrorKind::BrokenPipe => {
Some("Peer closed write side before MTProto handshake completed") Some("Peer closed write side before MTProto handshake completed")
} }
std::io::ErrorKind::NotConnected => { std::io::ErrorKind::NotConnected => Some("Handshake socket was already closed by peer"),
Some("Handshake socket was already closed by peer")
}
_ => None, _ => None,
} }
} }

View File

@@ -535,9 +535,8 @@ pub(crate) fn spawn_tcp_accept_loops(
} }
} }
_ if is_expected_handshake_eof(&e) => { _ if is_expected_handshake_eof(&e) => {
let reason = handshake_close_reason.unwrap_or( let reason = handshake_close_reason
"Peer closed during initial handshake", .unwrap_or("Peer closed during initial handshake");
);
if let Some(real_peer) = real_peer { if let Some(real_peer) = real_peer {
info!( info!(
peer = %peer_addr, peer = %peer_addr,

View File

@@ -1383,6 +1383,8 @@ fn emulated_server_hello_never_places_alpn_in_server_hello_extensions() {
&session_id, &session_id,
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
Some(b"h2".to_vec()), Some(b"h2".to_vec()),
0, 0,
@@ -1624,6 +1626,34 @@ fn test_extract_alpn_multiple() {
assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]); assert_eq!(alpn_str, vec!["h2", "spdy", "h3"]);
} }
#[test]
fn detect_client_hello_tls_version_prefers_supported_versions_tls13() {
let supported_versions = vec![4, 0x03, 0x04, 0x03, 0x03];
let ch = build_client_hello_with_exts(vec![(0x002b, supported_versions)], "example.com");
assert_eq!(
detect_client_hello_tls_version(&ch),
Some(ClientHelloTlsVersion::Tls13)
);
}
#[test]
fn detect_client_hello_tls_version_falls_back_to_legacy_tls12() {
let ch = build_client_hello_with_exts(Vec::new(), "example.com");
assert_eq!(
detect_client_hello_tls_version(&ch),
Some(ClientHelloTlsVersion::Tls12)
);
}
#[test]
fn detect_client_hello_tls_version_rejects_malformed_supported_versions() {
// list_len=3 is invalid because version vector must contain u16 pairs.
let malformed_supported_versions = vec![3, 0x03, 0x04, 0x03];
let ch =
build_client_hello_with_exts(vec![(0x002b, malformed_supported_versions)], "example.com");
assert!(detect_client_hello_tls_version(&ch).is_none());
}
#[test] #[test]
fn extract_sni_rejects_zero_length_host_name() { fn extract_sni_rejects_zero_length_host_name() {
let mut sni_ext = Vec::new(); let mut sni_ext = Vec::new();

View File

@@ -811,6 +811,128 @@ pub fn extract_alpn_from_client_hello(handshake: &[u8]) -> Vec<Vec<u8>> {
out out
} }
/// ClientHello TLS generation inferred from handshake fields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientHelloTlsVersion {
Tls12,
Tls13,
}
/// Detect TLS generation from a ClientHello.
///
/// The parser prefers `supported_versions` (0x002b) when present and falls back
/// to `legacy_version` for compatibility with TLS 1.2 style hellos.
pub fn detect_client_hello_tls_version(handshake: &[u8]) -> Option<ClientHelloTlsVersion> {
if handshake.len() < 5 || handshake[0] != TLS_RECORD_HANDSHAKE {
return None;
}
let record_len = u16::from_be_bytes([handshake[3], handshake[4]]) as usize;
if handshake.len() < 5 + record_len {
return None;
}
let mut pos = 5; // after record header
if handshake.get(pos) != Some(&0x01) {
return None; // not ClientHello
}
pos += 1; // message type
if pos + 3 > handshake.len() {
return None;
}
let handshake_len = ((handshake[pos] as usize) << 16)
| ((handshake[pos + 1] as usize) << 8)
| handshake[pos + 2] as usize;
pos += 3; // handshake length bytes
if pos + handshake_len > 5 + record_len {
return None;
}
if pos + 2 + 32 > handshake.len() {
return None;
}
let legacy_version = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
pos += 2 + 32; // version + random
let session_id_len = *handshake.get(pos)? as usize;
pos += 1 + session_id_len;
if pos + 2 > handshake.len() {
return None;
}
let cipher_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
pos += 2 + cipher_len;
if pos >= handshake.len() {
return None;
}
let comp_len = *handshake.get(pos)? as usize;
pos += 1 + comp_len;
if pos + 2 > handshake.len() {
return None;
}
let ext_len = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]) as usize;
pos += 2;
let ext_end = pos + ext_len;
if ext_end > handshake.len() {
return None;
}
let mut saw_supported_versions = false;
while pos + 4 <= ext_end {
let etype = u16::from_be_bytes([handshake[pos], handshake[pos + 1]]);
let elen = u16::from_be_bytes([handshake[pos + 2], handshake[pos + 3]]) as usize;
pos += 4;
if pos + elen > ext_end {
return None;
}
if etype == extension_type::SUPPORTED_VERSIONS {
saw_supported_versions = true;
if elen < 1 {
return None;
}
let list_len = handshake[pos] as usize;
if list_len == 0 || list_len % 2 != 0 || 1 + list_len > elen {
return None;
}
let mut has_tls12 = false;
let mut ver_pos = pos + 1;
let ver_end = ver_pos + list_len;
while ver_pos + 1 < ver_end {
let version = u16::from_be_bytes([handshake[ver_pos], handshake[ver_pos + 1]]);
if version == 0x0304 {
return Some(ClientHelloTlsVersion::Tls13);
}
if version == 0x0303 || version == 0x0302 || version == 0x0301 {
has_tls12 = true;
}
ver_pos += 2;
}
if has_tls12 {
return Some(ClientHelloTlsVersion::Tls12);
}
return None;
}
pos += elen;
}
if saw_supported_versions {
return None;
}
if legacy_version >= 0x0303 {
Some(ClientHelloTlsVersion::Tls12)
} else {
None
}
}
/// Check if bytes look like a TLS ClientHello /// Check if bytes look like a TLS ClientHello
pub fn is_tls_handshake(first_bytes: &[u8]) -> bool { pub fn is_tls_handshake(first_bytes: &[u8]) -> bool {
if first_bytes.len() < 3 { if first_bytes.len() < 3 {

View File

@@ -1119,6 +1119,10 @@ where
} else { } else {
None None
}; };
// Fail-closed to TLS 1.3 semantics when ClientHello version is ambiguous:
// this avoids leaking certificate payload on malformed probes.
let client_tls_version = tls::detect_client_hello_tls_version(handshake)
.unwrap_or(tls::ClientHelloTlsVersion::Tls13);
if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() { if client_sni.is_some() && matched_tls_domain.is_none() && preferred_user_hint.is_none() {
let sni = client_sni.as_deref().unwrap_or_default(); let sni = client_sni.as_deref().unwrap_or_default();
@@ -1439,12 +1443,18 @@ where
let selected_domain = let selected_domain =
matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str()); matched_tls_domain.unwrap_or(config.censorship.tls_domain.as_str());
let cached_entry = cache.get(selected_domain).await; let cached_entry = cache.get(selected_domain).await;
let use_full_cert_payload = cache let use_full_cert_payload = if config.censorship.serverhello_compact
.take_full_cert_budget_for_ip( && matches!(client_tls_version, tls::ClientHelloTlsVersion::Tls12)
peer.ip(), {
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs), cache
) .take_full_cert_budget_for_ip(
.await; peer.ip(),
Duration::from_secs(config.censorship.tls_full_cert_ttl_secs),
)
.await
} else {
true
};
Some((cached_entry, use_full_cert_payload)) Some((cached_entry, use_full_cert_payload))
} else { } else {
None None
@@ -1465,6 +1475,8 @@ where
validation_session_id_slice, validation_session_id_slice,
&cached_entry, &cached_entry,
use_full_cert_payload, use_full_cert_payload,
config.censorship.serverhello_compact,
client_tls_version,
rng, rng,
selected_alpn.clone(), selected_alpn.clone(),
config.censorship.tls_new_session_tickets, config.censorship.tls_new_session_tickets,

View File

@@ -5,7 +5,9 @@ use crate::protocol::constants::{
MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, MAX_TLS_CIPHERTEXT_SIZE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER,
TLS_RECORD_HANDSHAKE, TLS_VERSION, TLS_RECORD_HANDSHAKE, TLS_VERSION,
}; };
use crate::protocol::tls::{TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key}; use crate::protocol::tls::{
ClientHelloTlsVersion, TLS_DIGEST_LEN, TLS_DIGEST_POS, gen_fake_x25519_key,
};
use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource}; use crate::tls_front::types::{CachedTlsData, ParsedCertificateInfo, TlsProfileSource};
use crc32fast::Hasher; use crc32fast::Hasher;
@@ -190,6 +192,8 @@ pub fn build_emulated_server_hello(
session_id: &[u8], session_id: &[u8],
cached: &CachedTlsData, cached: &CachedTlsData,
use_full_cert_payload: bool, use_full_cert_payload: bool,
serverhello_compact: bool,
client_tls_version: ClientHelloTlsVersion,
rng: &SecureRandom, rng: &SecureRandom,
alpn: Option<Vec<u8>>, alpn: Option<Vec<u8>>,
new_session_tickets: u8, new_session_tickets: u8,
@@ -265,20 +269,33 @@ pub fn build_emulated_server_hello(
} }
} }
}; };
let compact_payload = cached let compact_payload = if serverhello_compact {
.cert_info
.as_ref()
.and_then(build_compact_cert_info_payload)
.and_then(hash_compact_cert_info_payload);
let selected_payload: Option<&[u8]> = if use_full_cert_payload {
cached cached
.cert_payload .cert_info
.as_ref() .as_ref()
.map(|payload| payload.certificate_message.as_slice()) .and_then(build_compact_cert_info_payload)
.filter(|payload| !payload.is_empty()) .and_then(hash_compact_cert_info_payload)
.or(compact_payload.as_deref())
} else { } else {
compact_payload.as_deref() None
};
let full_payload = cached
.cert_payload
.as_ref()
.map(|payload| payload.certificate_message.as_slice())
.filter(|payload| !payload.is_empty());
let selected_payload: Option<&[u8]> = match client_tls_version {
ClientHelloTlsVersion::Tls13 => None,
ClientHelloTlsVersion::Tls12 => {
if serverhello_compact {
if use_full_cert_payload {
full_payload.or(compact_payload.as_deref())
} else {
compact_payload.as_deref()
}
} else {
full_payload
}
}
}; };
if let Some(payload) = selected_payload { if let Some(payload) = selected_payload {
@@ -402,6 +419,7 @@ mod tests {
use crate::protocol::constants::{ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
}; };
use crate::protocol::tls::ClientHelloTlsVersion;
fn first_app_data_payload(response: &[u8]) -> &[u8] { fn first_app_data_payload(response: &[u8]) -> &[u8] {
let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize; let hello_len = u16::from_be_bytes([response[3], response[4]]) as usize;
@@ -448,6 +466,8 @@ mod tests {
&[0x22; 16], &[0x22; 16],
&cached, &cached,
true, true,
true,
ClientHelloTlsVersion::Tls12,
&rng, &rng,
None, None,
0, 0,
@@ -474,6 +494,8 @@ mod tests {
&[0x33; 16], &[0x33; 16],
&cached, &cached,
true, true,
true,
ClientHelloTlsVersion::Tls12,
&rng, &rng,
None, None,
0, 0,
@@ -506,6 +528,8 @@ mod tests {
&[0x55; 16], &[0x55; 16],
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls12,
&rng, &rng,
None, None,
0, 0,
@@ -529,6 +553,68 @@ mod tests {
); );
} }
#[test]
fn test_build_emulated_server_hello_tls13_never_uses_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",
&[0x56; 32],
&[0x78; 16],
&cached,
true,
true,
ClientHelloTlsVersion::Tls13,
&rng,
None,
0,
);
let payload = first_app_data_payload(&response);
assert!(
!payload.starts_with(&cert_msg),
"TLS 1.3 response path must not expose certificate payload bytes"
);
}
#[test]
fn test_build_emulated_server_hello_compact_disabled_skips_compact_payload() {
let mut cached = make_cached(None);
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()],
});
let rng = SecureRandom::new();
let response = build_emulated_server_hello(
b"secret",
&[0x90; 32],
&[0x91; 16],
&cached,
false,
false,
ClientHelloTlsVersion::Tls12,
&rng,
Some(b"h2".to_vec()),
0,
);
let payload = first_app_data_payload(&response);
let expected_alpn_marker = [0x00u8, 0x10, 0x00, 0x05, 0x00, 0x03, 0x02, b'h', b'2'];
assert!(
payload.starts_with(&expected_alpn_marker),
"when compact mode is disabled and no full cert payload exists, the random/alpn path must be used"
);
}
#[test] #[test]
fn test_build_emulated_server_hello_ignores_tail_records_for_profiled_tls() { fn test_build_emulated_server_hello_ignores_tail_records_for_profiled_tls() {
let mut cached = make_cached(None); let mut cached = make_cached(None);
@@ -545,6 +631,8 @@ mod tests {
&[0x34; 16], &[0x34; 16],
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
None, None,
0, 0,

View File

@@ -4,6 +4,7 @@ use crate::crypto::SecureRandom;
use crate::protocol::constants::{ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
}; };
use crate::protocol::tls::ClientHelloTlsVersion;
use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::emulator::build_emulated_server_hello;
use crate::tls_front::types::{ use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource, CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsProfileSource,
@@ -62,6 +63,8 @@ fn emulated_server_hello_keeps_single_change_cipher_spec_for_client_compatibilit
&[0x72; 16], &[0x72; 16],
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
None, None,
0, 0,
@@ -84,6 +87,8 @@ fn emulated_server_hello_does_not_emit_profile_ticket_tail_when_disabled() {
&[0x82; 16], &[0x82; 16],
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
None, None,
0, 0,
@@ -104,6 +109,8 @@ fn emulated_server_hello_uses_profile_ticket_lengths_when_enabled() {
&[0x92; 16], &[0x92; 16],
&cached, &cached,
false, false,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
None, None,
2, 2,

View File

@@ -4,6 +4,7 @@ use crate::crypto::SecureRandom;
use crate::protocol::constants::{ use crate::protocol::constants::{
TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE, TLS_RECORD_APPLICATION, TLS_RECORD_CHANGE_CIPHER, TLS_RECORD_HANDSHAKE,
}; };
use crate::protocol::tls::ClientHelloTlsVersion;
use crate::tls_front::emulator::build_emulated_server_hello; use crate::tls_front::emulator::build_emulated_server_hello;
use crate::tls_front::types::{ use crate::tls_front::types::{
CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource, CachedTlsData, ParsedServerHello, TlsBehaviorProfile, TlsCertPayload, TlsProfileSource,
@@ -55,6 +56,8 @@ fn emulated_server_hello_ignores_oversized_alpn_when_marker_would_not_fit() {
&[0x22; 16], &[0x22; 16],
&cached, &cached,
true, true,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
Some(oversized_alpn), Some(oversized_alpn),
0, 0,
@@ -91,6 +94,8 @@ fn emulated_server_hello_embeds_full_alpn_marker_when_body_can_fit() {
&[0x41; 16], &[0x41; 16],
&cached, &cached,
true, true,
true,
ClientHelloTlsVersion::Tls13,
&rng, &rng,
Some(b"h2".to_vec()), Some(b"h2".to_vec()),
0, 0,
@@ -119,6 +124,8 @@ fn emulated_server_hello_prefers_cert_payload_over_alpn_marker() {
&[0x42; 16], &[0x42; 16],
&cached, &cached,
true, true,
true,
ClientHelloTlsVersion::Tls12,
&rng, &rng,
Some(b"h2".to_vec()), Some(b"h2".to_vec()),
0, 0,