diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 13ddf02..daa8f06 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -75,6 +75,53 @@ impl Drop for SessionState { /// Spawn the per-session receiver loop that reads inbound datagrams from the /// transport and forwards them to the bridge, returning an AbortHandle so the +/// Build a fresh, valid handshake datagram for the TTL-desync hop probe. Each +/// call uses a new session id and timestamp so the server's anti-replay does not +/// drop it as a duplicate. Returns an empty vec on the (unexpected) construction +/// error — the probe simply treats that TTL step as unanswered. +fn build_probe_handshake( + secrets: &ostp_core::crypto::DerivedSecrets, + access_key: &[u8], + profile: TrafficProfile, + mtu: usize, +) -> Vec { + let session_id: u32 = rand::random(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut handshake_payload = Vec::with_capacity(8 + 4 + access_key.len()); + handshake_payload.extend_from_slice(×tamp.to_be_bytes()); + handshake_payload.extend_from_slice(&session_id.to_be_bytes()); + handshake_payload.extend_from_slice(access_key); + + let mut machine = match ProtocolMachine::new(ProtocolConfig { + role: NoiseRole::Initiator, + psk: secrets.psk, + session_id, + handshake_payload, + padding_strategy: PaddingStrategy::Profile(profile), + obfuscation_key: secrets.obfuscation_key, + max_reorder: 16384, + max_reorder_buffer: 8192, + ack_delay_ms: 5, + rto_ms: 100, + max_retries: 8, + max_sent_history: 32768, + handshake_pad_min: secrets.handshake_pad_min, + handshake_pad_max: secrets.handshake_pad_max, + mtu, + max_padding: mtu.saturating_sub(48).max(256), + }) { + Ok(m) => m, + Err(_) => return Vec::new(), + }; + match machine.on_event(OstpEvent::Start) { + Ok(ProtocolAction::SendDatagram(frame)) => frame.to_vec(), + _ => Vec::new(), + } +} + /// task is torn down when its `SessionState` is dropped. Consolidates the three /// previously-duplicated inline copies (initial connect, network-change, and /// keepalive reconnect). @@ -136,6 +183,10 @@ pub struct Bridge { pub ttl_desync: bool, pub ttl_desync_ttl: u8, pub ttl_desync_count: u8, + pub ttl_desync_auto: bool, + /// Cached result of the hop-distance measurement, so the TTL sweep runs once + /// rather than on every (re)connect. Cleared on a network change. + ttl_desync_measured: Option, pub mtu: usize, pub kill_switch: bool, pub reload_tx: Option>, @@ -190,6 +241,8 @@ impl Bridge { ttl_desync: config.transport.ttl_desync, ttl_desync_ttl: config.transport.ttl_desync_ttl, ttl_desync_count: config.transport.ttl_desync_count, + ttl_desync_auto: config.transport.ttl_desync_auto, + ttl_desync_measured: None, mtu: config.ostp.mtu, kill_switch: config.kill_switch, reload_tx: None, @@ -1120,6 +1173,31 @@ impl Bridge { // 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 { + // Auto-calibrate the decoy TTL to the hop distance to this server, + // measured once (cached). The server answers only a valid + // handshake, so the sweep is inherently key-gated and needs no + // server-side probe endpoint. On measurement failure we fall back + // to the configured fixed TTL rather than skipping desync. + let effective_ttl = if self.ttl_desync_auto { + if self.ttl_desync_measured.is_none() { + let sec = secrets.clone(); + let key = self.access_key.clone(); + let profile = self.profile; + let mtu = self.mtu; + let make_hs = move || build_probe_handshake(&sec, &key, profile, mtu); + if let Some(hops) = crate::ttl_probe::measure_hops(target_addr, make_hs).await { + let t = crate::ttl_probe::decoy_ttl_for(hops); + self.ttl_desync_measured = Some(t); + tx.send(UiEvent::Log(format!( + "TTL-desync: server ~{hops} hops, decoy TTL {t}" + ))).await.ok(); + } + } + self.ttl_desync_measured.unwrap_or(self.ttl_desync_ttl) + } else { + self.ttl_desync_ttl + }; + let marker = ostp_core::crypto::derive_junk_marker( &self.access_key, ostp_core::crypto::current_junk_window(), @@ -1139,7 +1217,7 @@ impl Bridge { }) .collect() }; - socket.send_ttl_decoys(&decoys, self.ttl_desync_ttl).await; + socket.send_ttl_decoys(&decoys, effective_ttl).await; } for attempt in 0..attempt_limit { @@ -1242,6 +1320,8 @@ impl Bridge { 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.ttl_desync_auto = cfg.transport.ttl_desync_auto; + self.ttl_desync_measured = None; // re-measure after a config change 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 38fe0bd..e390870 100644 --- a/ostp-client/src/config.rs +++ b/ostp-client/src/config.rs @@ -105,8 +105,15 @@ pub struct TransportConfig { /// How many decoy datagrams to send per handshake. #[serde(default = "default_ttl_desync_count")] pub ttl_desync_count: u8, + /// Auto-calibrate the decoy TTL by measuring the hop distance to the server + /// (see ttl_probe). On by default, so turning desync on "just works"; the + /// measured value overrides ttl_desync_ttl. Turn off to pin ttl_desync_ttl. + #[serde(default = "default_true")] + pub ttl_desync_auto: bool, } +fn default_true() -> bool { true } + fn default_transport_mode() -> String { "udp".to_string() } fn default_frag_chunk() -> usize { 2 } fn default_frag_sleep() -> u64 { 2 } @@ -127,6 +134,7 @@ impl Default for TransportConfig { ttl_desync: false, ttl_desync_ttl: default_ttl_desync_ttl(), ttl_desync_count: default_ttl_desync_count(), + ttl_desync_auto: true, } } } @@ -215,6 +223,7 @@ struct RawTransportSection { ttl_desync: Option, ttl_desync_ttl: Option, ttl_desync_count: Option, + ttl_desync_auto: Option, } #[derive(Debug, Deserialize)] @@ -295,6 +304,7 @@ impl ClientConfig { 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), + ttl_desync_auto: raw.transport.as_ref().and_then(|t| t.ttl_desync_auto).unwrap_or(true), }, exclusions: ExclusionConfig { domains: exclusions.domains.unwrap_or_default(), diff --git a/ostp-client/src/lib.rs b/ostp-client/src/lib.rs index 8f5b7ae..1607e46 100644 --- a/ostp-client/src/lib.rs +++ b/ostp-client/src/lib.rs @@ -5,6 +5,7 @@ pub mod migrate; pub mod signal; pub mod sysproxy; pub mod transport; +pub mod ttl_probe; pub mod tunnel; diff --git a/ostp-client/src/ttl_probe.rs b/ostp-client/src/ttl_probe.rs new file mode 100644 index 0000000..2b4f7df --- /dev/null +++ b/ostp-client/src/ttl_probe.rs @@ -0,0 +1,100 @@ +//! Lightweight "how many hops to the server" probe, for auto-calibrating the +//! TTL-desync decoys — without pulling in the full ostp-prober, and without any +//! new server code or exposed port. +//! +//! The trick: the OSTP server answers only a *valid* handshake and silently +//! drops everything else. So we send the real handshake datagram with a rising +//! IP TTL and watch for the first TTL that draws a reply. A datagram whose TTL +//! is too low dies on a router before the server and creates no state there; +//! only the TTL that actually reaches the server elicits a response. That first +//! responding TTL is the hop distance to the server. +//! +//! This is inherently key-gated (no key → no valid handshake → no reply, so an +//! unauthenticated caller learns nothing) and rides the existing UDP port, which +//! is exactly the "works for key holders, no prober-server, no extra ports" +//! property we want. Decoys are then sent at `hops - 1`, so they clear the DPI +//! (which sits far closer than the server) yet die before the server. + +use std::net::SocketAddr; +use std::time::Duration; +use tokio::net::UdpSocket; + +/// Upper bound on the hop sweep. Beyond ~30 the internet does not go, and a +/// server we cannot reach within that many hops is unreachable for other +/// reasons the caller will already be handling. +const MAX_HOPS: u8 = 30; +/// Per-TTL wait for a reply. Kept short so the whole sweep is quick; each TTL is +/// tried a couple of times to ride out isolated loss. +const PER_TTL_TIMEOUT: Duration = Duration::from_millis(400); +const SENDS_PER_TTL: usize = 2; + +/// Measure the hop distance to `server` by sweeping the TTL of `handshake`. +/// +/// `make_handshake` is called once per TTL step to produce a fresh handshake +/// datagram — fresh because the server's anti-replay cache drops a repeat of the +/// same (session id, timestamp), so each probe must be distinct to be answered. +/// Returns the first TTL that drew a reply, or None if none did within MAX_HOPS. +pub async fn measure_hops(server: SocketAddr, mut make_handshake: F) -> Option +where + F: FnMut() -> Vec, +{ + let bind: SocketAddr = if server.is_ipv6() { + "[::]:0".parse().ok()? + } else { + "0.0.0.0:0".parse().ok()? + }; + let sock = UdpSocket::bind(bind).await.ok()?; + sock.connect(server).await.ok()?; + + let mut buf = [0u8; 2048]; + for ttl in 1..=MAX_HOPS { + if sock.set_ttl(ttl as u32).is_err() { + continue; + } + for _ in 0..SENDS_PER_TTL { + let frame = make_handshake(); + let _ = sock.send(&frame).await; + } + match tokio::time::timeout(PER_TTL_TIMEOUT, sock.recv(&mut buf)).await { + Ok(Ok(n)) if n > 0 => return Some(ttl), + _ => {} + } + } + None +} + +/// Given a measured server distance, the TTL to stamp on decoys: one hop short +/// of the server, so they die before it, and clamped to at least 1. +pub fn decoy_ttl_for(server_hops: u8) -> u8 { + server_hops.saturating_sub(1).max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Against a local responder, the very first TTL reaches it (localhost is + /// zero routers away), so the sweep returns 1 — exercising the "first reply + /// wins" path end to end over a real socket. + #[tokio::test] + async fn measures_against_a_local_responder() { + let responder = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let addr = responder.local_addr().unwrap(); + tokio::spawn(async move { + let mut b = [0u8; 512]; + while let Ok((n, from)) = responder.recv_from(&mut b).await { + let _ = responder.send_to(&b[..n], from).await; + } + }); + + let hops = measure_hops(addr, || b"handshake-probe".to_vec()).await; + assert_eq!(hops, Some(1), "a local responder must answer at the first TTL"); + } + + #[test] + fn decoy_ttl_is_one_short_of_the_server() { + assert_eq!(decoy_ttl_for(12), 11); + assert_eq!(decoy_ttl_for(1), 1); + assert_eq!(decoy_ttl_for(0), 1); + } +} diff --git a/ostp-gui/src-tauri/src/lib.rs b/ostp-gui/src-tauri/src/lib.rs index 47c0635..1400b96 100644 --- a/ostp-gui/src-tauri/src/lib.rs +++ b/ostp-gui/src-tauri/src/lib.rs @@ -64,6 +64,7 @@ struct TransportConfigRaw { ttl_desync: Option, ttl_desync_ttl: Option, ttl_desync_count: Option, + ttl_desync_auto: Option, } #[derive(Debug, Deserialize, Serialize, Clone)] @@ -245,6 +246,7 @@ fn map_to_client_config(raw: &ClientConfigRaw, mode: &str) -> ostp_client::confi 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), + ttl_desync_auto: raw.transport.as_ref().and_then(|t| t.ttl_desync_auto).unwrap_or(true), }, 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 af7259a..8432839 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -1716,6 +1716,7 @@ async fn run_client_directly(client_cfg: ClientConfig) -> Result<()> { ttl_desync: false, ttl_desync_ttl: 8, ttl_desync_count: 2, + ttl_desync_auto: true, }, 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),