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] 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); + } +}