diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 5dd025f..13ddf02 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -133,6 +133,9 @@ pub struct Bridge { pub frag_sleep: u64, pub junk_pc: [usize; 2], pub junk_ps: [usize; 2], + pub ttl_desync: bool, + pub ttl_desync_ttl: u8, + pub ttl_desync_count: u8, pub mtu: usize, pub kill_switch: bool, pub reload_tx: Option>, @@ -184,6 +187,9 @@ impl Bridge { frag_sleep: config.transport.frag_sleep, junk_pc: config.transport.junk_pc, junk_ps: config.transport.junk_ps, + ttl_desync: config.transport.ttl_desync, + ttl_desync_ttl: config.transport.ttl_desync_ttl, + ttl_desync_count: config.transport.ttl_desync_count, mtu: config.ostp.mtu, kill_switch: config.kill_switch, reload_tx: None, @@ -1108,6 +1114,34 @@ impl Bridge { let is_uot = matches!(socket, crate::transport::Transport::Uot { .. }); let (attempt_limit, attempt_timeout_ms) = if is_uot { (1, 8000) } else { (4, 1200) }; + // TTL-desync (UDP only, opt-in): fire decoy datagrams that reach an + // on-path DPI box but expire before the server, so the box classifies + // the flow on the decoys rather than the real handshake that follows. + // Each carries the key's junk marker, so any decoy that does reach + // the server is dropped there silently. + if self.ttl_desync && !is_uot && self.ttl_desync_count > 0 { + let marker = ostp_core::crypto::derive_junk_marker( + &self.access_key, + ostp_core::crypto::current_junk_window(), + ); + let decoys: Vec = { + let mut rng = rand::thread_rng(); + let [min_s, max_s] = self.junk_ps; + let min_s = min_s.max(4); + let max_s = max_s.max(min_s); + (0..self.ttl_desync_count) + .map(|_| { + let len = rng.gen_range(min_s..=max_s); + let mut b = vec![0u8; len]; + rng.fill(&mut b[..]); + b[..4].copy_from_slice(&marker); + bytes::Bytes::from(b) + }) + .collect() + }; + socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await; + } + for attempt in 0..attempt_limit { if attempt > 0 { tx.send(UiEvent::Log(format!("Handshake attempt {} lost. Retransmitting...", attempt))).await.ok(); @@ -1205,6 +1239,9 @@ impl Bridge { self.frag_sleep = cfg.transport.frag_sleep; self.junk_pc = cfg.transport.junk_pc; self.junk_ps = cfg.transport.junk_ps; + self.ttl_desync = cfg.transport.ttl_desync; + self.ttl_desync_ttl = cfg.transport.ttl_desync_ttl; + self.ttl_desync_count = cfg.transport.ttl_desync_count; self.mtu = cfg.ostp.mtu; self.keepalive_interval_sec = cfg.ostp.keepalive_interval_sec; self.kill_switch = cfg.kill_switch; diff --git a/ostp-client/src/config.rs b/ostp-client/src/config.rs index 072dd61..38fe0bd 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -92,6 +92,19 @@ pub struct TransportConfig { /// [min, max] junk packet size in bytes #[serde(default = "default_junk_size")] pub junk_ps: [usize; 2], + /// TTL-desync (UDP only): before the handshake, send decoy datagrams with a + /// lowered IP TTL so they reach an on-path DPI box but expire before the + /// server, poisoning the box's classification of the flow. Off by default — + /// it needs the TTL calibrated to the network, and the wrong value is inert. + #[serde(default)] + pub ttl_desync: bool, + /// TTL the decoy datagrams are sent with. Set it to one or two hops past the + /// injector distance the prober reports, so decoys die just beyond the DPI. + #[serde(default = "default_ttl_desync_ttl")] + pub ttl_desync_ttl: u8, + /// How many decoy datagrams to send per handshake. + #[serde(default = "default_ttl_desync_count")] + pub ttl_desync_count: u8, } fn default_transport_mode() -> String { "udp".to_string() } @@ -99,6 +112,8 @@ fn default_frag_chunk() -> usize { 2 } fn default_frag_sleep() -> u64 { 2 } fn default_junk_count() -> [usize; 2] { [2, 5] } fn default_junk_size() -> [usize; 2] { [100, 1000] } +fn default_ttl_desync_ttl() -> u8 { 8 } +fn default_ttl_desync_count() -> u8 { 2 } impl Default for TransportConfig { fn default() -> Self { @@ -109,6 +124,9 @@ impl Default for TransportConfig { frag_sleep: default_frag_sleep(), junk_pc: default_junk_count(), junk_ps: default_junk_size(), + ttl_desync: false, + ttl_desync_ttl: default_ttl_desync_ttl(), + ttl_desync_count: default_ttl_desync_count(), } } } @@ -194,6 +212,9 @@ struct RawTransportSection { frag_sleep: Option, junk_pc: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>, + ttl_desync: Option, + ttl_desync_ttl: Option, + ttl_desync_count: Option, } #[derive(Debug, Deserialize)] @@ -271,6 +292,9 @@ impl ClientConfig { frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or_else(default_frag_sleep), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or_else(default_junk_count), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or_else(default_junk_size), + ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false), + ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or_else(default_ttl_desync_ttl), + ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or_else(default_ttl_desync_count), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), diff --git a/ostp-client/src/transport/mod.rs b/ostp-client/src/transport/mod.rs index 5de7fa2..bdab9a4 100644 --- a/ostp-client/src/transport/mod.rs +++ b/ostp-client/src/transport/mod.rs @@ -53,4 +53,29 @@ impl Transport { Self::Uot { .. } => Ok("0.0.0.0:0".parse().unwrap()), } } + + /// TTL-desync: send `decoys` as datagrams with the IP TTL lowered to `ttl`, + /// then restore the socket's original TTL. The decoys are meant to reach an + /// on-path DPI box and expire before the server — poisoning the box's view + /// of the flow (it classifies on the decoy) while the server never sees + /// them. Calibrate `ttl` to the injector hop distance the prober reports. + /// + /// UDP only: this manipulates individual datagrams' TTL. On UoT the carrier + /// is one TCP stream, so a socket-level TTL change would apply to the real + /// traffic too — proper TCP desync needs injected packets (a driver), which + /// this deliberately does not attempt. No-op there. + pub async fn send_ttl_decoys(&self, decoys: &[Bytes], ttl: u8) { + let Self::Udp(sock) = self else { return }; + if decoys.is_empty() { + return; + } + let restore = sock.ttl().unwrap_or(128); + if sock.set_ttl(ttl as u32).is_err() { + return; + } + for d in decoys { + let _ = sock.send(d).await; + } + let _ = sock.set_ttl(restore); + } } diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 4efcdc1..47c0635 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -61,6 +61,9 @@ struct TransportConfigRaw { frag_sleep: Option, junk_pc: Option<[usize; 2]>, junk_ps: Option<[usize; 2]>, + ttl_desync: Option, + ttl_desync_ttl: Option, + ttl_desync_count: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -239,6 +242,9 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi frag_sleep: raw.transport.as_ref().and_then(|t| t.frag_sleep).unwrap_or(2), junk_pc: raw.transport.as_ref().and_then(|t| t.junk_pc).unwrap_or([2, 5]), junk_ps: raw.transport.as_ref().and_then(|t| t.junk_ps).unwrap_or([100, 1000]), + ttl_desync: raw.transport.as_ref().and_then(|t| t.ttl_desync).unwrap_or(false), + ttl_desync_ttl: raw.transport.as_ref().and_then(|t| t.ttl_desync_ttl).unwrap_or(8), + ttl_desync_count: raw.transport.as_ref().and_then(|t| t.ttl_desync_count).unwrap_or(2), }, exclusions: ostp_client::config::ExclusionConfig { domains: raw.exclude.as_ref().and_then(|e| e.domains.clone()).unwrap_or_default(), diff --git a/ostp/src/main.rs b/ostp/src/main.rs index 1ccf75e..af7259a 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -1713,6 +1713,9 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { frag_sleep: 2, junk_pc: [2, 5], junk_ps: [100, 1000], + ttl_desync: false, + ttl_desync_ttl: 8, + ttl_desync_count: 2, }, dns_server: client_cfg.tun.as_ref().and_then(|t| t.dns.clone()), kill_switch: client_cfg.tun.as_ref().and_then(|t| t.kill_switch).unwrap_or(false),