From 29554a71f1110f8796525d09530e5b923794dcb3 Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 21:14:15 +0300 Subject: [PATCH 1/7] fix(crypto)!: derive transport keys from DH-inclusive Noise Split, not the handshake hash CRITICAL forward-secrecy fix. Session transport keys were derived as SHA256(get_handshake_hash() || label). The Noise handshake hash `h` only ever absorbs PUBLIC transcript data (ephemeral pubkeys + on-wire ciphertexts, via MixHash); the ephemeral ee DH result is mixed via MixKey into the chaining key `ck` ONLY, never into `h` (confirmed in snow 0.9.6 symmetricstate.rs). So the data-transport keys depended on the PSK and the public transcript but NOT on the DH secret, meaning: - zero forward secrecy: anyone who later learns the access-key PSK can decrypt all recorded past sessions from the observed handshake alone; - any PSK holder can passively decrypt any other session on that key; - the ephemeral Diffie-Hellman was cryptographically wasted. Fix: take the two directional keys from Noise's Split() over the final `ck` via snow's dangerously_get_raw_split (risky-raw-split feature). These keys depend on ee, restoring forward secrecy. The custom out-of-order AEAD, explicit nonces, session_id AAD, framing and reordering are all unchanged - only the key SOURCE moved. The dead into_transport()/handshake_hash() paths and the unreachable NoiseSession::Transport variant are removed. Wire-breaking: PROTOCOL_VERSION 4 -> 5 so pre-fix peers derive different keys and cannot interop (version gate is invisible on the wire). Added noise unit tests for the .0/.1 -> send/recv role mapping and the not-finished guard. --- Cargo.toml | 2 +- ostp-core/src/crypto/noise.rs | 128 +++++++++++++++++++--------- ostp-core/src/crypto/obfuscation.rs | 7 +- ostp-core/src/protocol.rs | 30 ++----- 4 files changed, 100 insertions(+), 67 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1eb2676..101bb27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ anyhow = "1.0" bytes = "1.6" chacha20poly1305 = "0.10" rand = "0.8" -snow = "0.9" +snow = { version = "0.9", features = ["risky-raw-split"] } thiserror = "1.0" tokio = { version = "1.37", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "sync", "signal"] } tracing = "0.1" diff --git a/ostp-core/src/crypto/noise.rs b/ostp-core/src/crypto/noise.rs index 4fc96ba..d1f2e25 100644 --- a/ostp-core/src/crypto/noise.rs +++ b/ostp-core/src/crypto/noise.rs @@ -1,4 +1,4 @@ -use snow::{Builder, HandshakeState, TransportState}; +use snow::{Builder, HandshakeState}; use crate::protocol::ProtocolError; @@ -10,9 +10,15 @@ pub enum NoiseRole { Responder, } -pub enum NoiseSession { - Handshake(Box), - Transport(TransportState), +/// A Noise handshake in progress. OSTP does not use snow's transport mode: once +/// the handshake finishes we extract the raw Split() keys (see [`raw_split`]) +/// and drive our own out-of-order AEAD (see `crypto::aead`), because the wire +/// protocol needs explicit per-frame nonces for reordering that snow's internal +/// nonce counter can't express. +/// +/// [`raw_split`]: NoiseSession::raw_split +pub struct NoiseSession { + handshake: Box, } impl NoiseSession { @@ -36,50 +42,92 @@ impl NoiseSession { .map_err(|_| ProtocolError::Crypto("noise-responder".to_string()))?, }; - Ok(Self::Handshake(Box::new(handshake))) + Ok(Self { handshake: Box::new(handshake) }) } pub fn write_handshake(&mut self, payload: &[u8], out: &mut [u8]) -> Result { - match self { - NoiseSession::Handshake(hs) => hs - .write_message(payload, out) - .map_err(|_| ProtocolError::Crypto("noise-write".to_string())), - NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), - } + self.handshake + .write_message(payload, out) + .map_err(|_| ProtocolError::Crypto("noise-write".to_string())) } pub fn read_handshake(&mut self, input: &[u8], out: &mut [u8]) -> Result { - match self { - NoiseSession::Handshake(hs) => hs - .read_message(input, out) - .map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))), - NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), - } + self.handshake + .read_message(input, out) + .map_err(|e| ProtocolError::Crypto(format!("noise-read: {:?}", e))) } - pub fn handshake_hash(&self, out: &mut [u8]) -> Result<(), ProtocolError> { - match self { - NoiseSession::Handshake(hs) => { - let hash = hs.get_handshake_hash(); - if out.len() != hash.len() { - return Err(ProtocolError::Crypto("handshake hash length mismatch".to_string())); - } - out.copy_from_slice(hash); - Ok(()) - } - NoiseSession::Transport(_) => Err(ProtocolError::State("noise already in transport".to_string())), - } - } - - pub fn into_transport(self) -> Result { - match self { - NoiseSession::Handshake(hs) => { - let transport = hs - .into_transport_mode() - .map_err(|_| ProtocolError::Crypto("noise-transport".to_string()))?; - Ok(NoiseSession::Transport(transport)) - } - NoiseSession::Transport(_) => Ok(self), + /// Derive the two directional transport keys via Noise's Split(). + /// + /// SECURITY: keys are taken from the final chaining key `ck` (which absorbs + /// the ephemeral `ee` DH result via MixKey), NOT from the handshake hash `h` + /// (which only absorbs public transcript data — ephemeral pubkeys and + /// ciphertexts — and never the DH secret). Deriving from `ck` is what gives + /// the session forward secrecy: an adversary who later learns the PSK still + /// cannot recompute these keys without the ephemeral private keys, which are + /// discarded after the handshake. + /// + /// Must only be called once the handshake is finished (both messages of the + /// NNpsk0 exchange processed); at that point `ck` is final. Returns + /// `(send_key, recv_key)` for the given role, matching snow's TransportState + /// direction mapping: split output `.0` is initiator→responder, `.1` is + /// responder→initiator. + pub fn raw_split(&mut self, role: NoiseRole) -> Result<([u8; 32], [u8; 32]), ProtocolError> { + if !self.handshake.is_handshake_finished() { + return Err(ProtocolError::State("handshake not finished at key split".to_string())); } + let (k0, k1) = self.handshake.dangerously_get_raw_split(); + Ok(match role { + // Initiator sends on .0 (i→r), receives on .1 (r→i). + NoiseRole::Initiator => (k0, k1), + // Responder is the mirror image. + NoiseRole::Responder => (k1, k0), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drive a full NNpsk0 handshake and confirm both sides derive matching + /// directional keys. This guards the .0/.1 → send/recv role mapping in + /// `raw_split`: if it were wrong, the two sides' send/recv keys wouldn't + /// cross-match and the transport channel would silently fail to decrypt. + #[test] + fn raw_split_keys_agree_across_roles() { + let psk = [7u8; 32]; + let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap(); + let mut responder = NoiseSession::new(NoiseRole::Responder, &psk).unwrap(); + + // msg1: initiator -> responder + let mut buf1 = [0u8; 1024]; + let n1 = initiator.write_handshake(&[], &mut buf1).unwrap(); + let mut tmp = [0u8; 1024]; + responder.read_handshake(&buf1[..n1], &mut tmp).unwrap(); + + // msg2: responder -> initiator + let mut buf2 = [0u8; 1024]; + let n2 = responder.write_handshake(&[], &mut buf2).unwrap(); + initiator.read_handshake(&buf2[..n2], &mut tmp).unwrap(); + + let (i_send, i_recv) = initiator.raw_split(NoiseRole::Initiator).unwrap(); + let (r_send, r_recv) = responder.raw_split(NoiseRole::Responder).unwrap(); + + // What the initiator sends with, the responder must receive with. + assert_eq!(i_send, r_recv, "initiator send key must equal responder recv key"); + assert_eq!(r_send, i_recv, "responder send key must equal initiator recv key"); + // The two directions use distinct keys. + assert_ne!(i_send, i_recv, "the two directions must not share a key"); + } + + /// raw_split must refuse to hand out keys before the handshake is complete — + /// keys taken from a half-mixed chaining key would be wrong and insecure. + #[test] + fn raw_split_rejected_before_handshake_finishes() { + let psk = [9u8; 32]; + let mut initiator = NoiseSession::new(NoiseRole::Initiator, &psk).unwrap(); + // No messages exchanged yet: handshake not finished. + assert!(initiator.raw_split(NoiseRole::Initiator).is_err()); } } diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index 63b1427..5d58756 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -74,8 +74,11 @@ pub struct DerivedSecrets { /// without a version) produces a different obfuscation key, so a 0.4.0 server /// cannot recover its handshake header and rejects it as an unauthorized probe. /// -/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4. -pub const PROTOCOL_VERSION: u8 = 4; +/// Bump this on any wire-breaking protocol change. 0.4.0 = version 4; +/// version 5 (0.4.x hardening) moved transport keys from the handshake hash to +/// Noise's Split() output — a wire-breaking crypto change, so old peers must not +/// interop (they would derive different session keys and fail decryption). +pub const PROTOCOL_VERSION: u8 = 5; pub fn derive_all_secrets(access_key: &[u8]) -> DerivedSecrets { derive_all_secrets_versioned(access_key, PROTOCOL_VERSION) diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index 8ec23cf..92bee13 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -1,6 +1,5 @@ use bytes::Bytes; use rand::Rng; -use sha2::{Digest, Sha256}; use thiserror::Error; use std::collections::{BTreeMap, VecDeque}; use std::time::{Duration, Instant}; @@ -281,9 +280,12 @@ impl ProtocolMachine { NoiseRole::Initiator => None, }; - let mut key = [0_u8; 32]; - self.noise.handshake_hash(&mut key)?; - let (send_key, recv_key) = derive_split_keys(&key, self.role); + // Transport keys come from Noise's Split() over the final chaining key, + // so they depend on the ephemeral `ee` DH secret and give the session + // forward secrecy. (Previously these were derived from the handshake + // hash, which never absorbs the DH result — see raw_split's SECURITY + // note. That is the wire-breaking change gated by PROTOCOL_VERSION.) + let (send_key, recv_key) = self.noise.raw_split(self.role)?; self.send_cipher = Some(SessionCipher::new(&send_key)); self.recv_cipher = Some(SessionCipher::new(&recv_key)); self.state = OstpState::Established; @@ -732,26 +734,6 @@ fn nonce_in_ranges(nonce: u64, ranges: &[(u64, u64)]) -> bool { ranges.iter().any(|(start, end)| nonce >= *start && nonce <= *end) } -fn derive_split_keys(base_key: &[u8; 32], role: NoiseRole) -> ([u8; 32], [u8; 32]) { - let mut initiator_key = [0u8; 32]; - let mut responder_key = [0u8; 32]; - - let mut h1 = Sha256::new(); - h1.update(base_key); - h1.update(b"ostp-initiator"); - initiator_key.copy_from_slice(&h1.finalize()); - - let mut h2 = Sha256::new(); - h2.update(base_key); - h2.update(b"ostp-responder"); - responder_key.copy_from_slice(&h2.finalize()); - - match role { - NoiseRole::Initiator => (initiator_key, responder_key), - NoiseRole::Responder => (responder_key, initiator_key), - } -} - #[cfg(test)] mod tests { use super::*; From f904695760074deac0ea0356de14c5a038f67783 Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 21:18:05 +0300 Subject: [PATCH 2/7] fix(server): rate-limit + cache the O(N_keys) handshake trial path (CPU DoS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every datagram from an unrecognized source ran the full key-trial loop: for each registered access key, an HKDF (derive_all_secrets) plus two HMACs (junk markers) plus a Noise read. A garbage flood from spoofed sources could therefore force unbounded O(N_keys) crypto per packet — a CPU-amplification DoS with no throttle (the existing token bucket only guarded the roaming path, not this one). Two mitigations: - Memoize the per-key derived secrets (pure function of key+version) and the per-window junk markers, so the trial loop is now cheap comparisons plus one Noise read per key instead of HKDF+2*HMAC per key per packet. Also speeds up every legitimate new connection. Caches are pruned in on_tick when keys are deleted. - Gate the trial path behind a global token bucket (TRIAL_RATE=100/s, same burst). The established-session fast path and roaming are not gated, so live sessions are unaffected; only unknown-datagram trials are bounded. Over-budget datagrams are dropped silently. --- ostp-core/src/crypto/obfuscation.rs | 1 + ostp-server/src/dispatcher.rs | 80 +++++++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/ostp-core/src/crypto/obfuscation.rs b/ostp-core/src/crypto/obfuscation.rs index 5d58756..4b2494a 100644 --- a/ostp-core/src/crypto/obfuscation.rs +++ b/ostp-core/src/crypto/obfuscation.rs @@ -54,6 +54,7 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec { /// The derivation uses the access key as both IKM and salt material, /// split into two halves. No fixed strings are used — the access key /// alone determines all derived values. +#[derive(Clone)] pub struct DerivedSecrets { pub obfuscation_key: [u8; 8], pub psk: [u8; 32], diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index d3bf540..0d69b39 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -83,8 +83,29 @@ pub struct Dispatcher { replay_cache: std::collections::HashMap, u64>, roaming_tokens: f64, last_token_regen: std::time::Instant, + /// Cache of per-key derived secrets (obf key / psk / padding). These are a + /// pure function of the access key + PROTOCOL_VERSION, so they never change + /// for a given key — computing the HKDF on every unknown datagram, for every + /// registered key, was pure waste and an attacker-amplified CPU sink. + secrets_cache: HashMap, + /// Cache of each key's junk markers for the current time window. The marker + /// rotates every window, so the cached `(window, m_now, m_prev)` is refreshed + /// when the window rolls; within a window it's an HMAC we compute once, not + /// twice per key per packet. + junk_cache: HashMap, + /// Token bucket bounding how many expensive new-handshake key-trials we run + /// per second. The existing-session fast path and roaming path are NOT gated + /// by this; only the O(N_keys) trial over unknown datagrams is, so a garbage + /// flood from spoofed sources can't force unbounded per-packet crypto work. + trial_tokens: f64, + last_trial_regen: std::time::Instant, } +/// Sustained rate (and burst ceiling) of new-handshake trials per second. Legit +/// first-connect packets are rare, so this is generous for real use while still +/// capping flood-driven trial work at TRIAL_RATE × num_keys crypto ops/sec. +const TRIAL_RATE: f64 = 100.0; + #[allow(dead_code)] impl Dispatcher { pub fn new(machine_config: ProtocolConfig, access_keys: Arc>>) -> Self { @@ -101,9 +122,38 @@ impl Dispatcher { replay_cache: std::collections::HashMap::new(), roaming_tokens: 50.0, last_token_regen: std::time::Instant::now(), + secrets_cache: HashMap::new(), + junk_cache: HashMap::new(), + trial_tokens: TRIAL_RATE, + last_trial_regen: std::time::Instant::now(), } } + /// Fetch this key's derived secrets from cache, computing (and caching) them + /// on first sight. Pure function of the key, so the entry never goes stale. + fn cached_secrets(&mut self, key: &str) -> ostp_core::crypto::DerivedSecrets { + if let Some(s) = self.secrets_cache.get(key) { + return s.clone(); + } + let s = ostp_core::crypto::derive_all_secrets(key.as_bytes()); + self.secrets_cache.insert(key.to_string(), s.clone()); + s + } + + /// Fetch this key's `(m_now, m_prev)` junk markers for `window`, recomputing + /// only when the cached window has rolled. + fn cached_junk_markers(&mut self, key: &str, window: u64) -> ([u8; 4], [u8; 4]) { + if let Some(&(w, m_now, m_prev)) = self.junk_cache.get(key) { + if w == window { + return (m_now, m_prev); + } + } + let m_now = ostp_core::crypto::derive_junk_marker(key.as_bytes(), window); + let m_prev = ostp_core::crypto::derive_junk_marker(key.as_bytes(), window.wrapping_sub(1)); + self.junk_cache.insert(key.to_string(), (window, m_now, m_prev)); + (m_now, m_prev) + } + /// Returns a shared reference to user stats for the Management API. pub fn user_stats_ref(&self) -> Arc>>> { self.user_stats.clone() @@ -302,7 +352,22 @@ impl Dispatcher { } } - // Not an existing session — try each registered access key's derived obfuscation key + // Not an existing session — this is the expensive O(N_keys) trial path. + // Gate it behind a token bucket so a garbage/spoofed-source flood cannot + // force unbounded per-packet crypto work. Existing sessions (fast path + // above) and roaming are unaffected. Regenerate at TRIAL_RATE/sec. + { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(self.last_trial_regen).as_secs_f64(); + self.last_trial_regen = now; + self.trial_tokens = (self.trial_tokens + elapsed * TRIAL_RATE).min(TRIAL_RATE); + if self.trial_tokens < 1.0 { + // Out of budget: drop silently (no response, no state, no log spam). + return Ok(DispatchOutcome::Unauthorized); + } + self.trial_tokens -= 1.0; + } + let keys_snapshot: Vec = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect(); // Junk marker rotates per time window; check the current and previous @@ -311,13 +376,12 @@ impl Dispatcher { let junk_window = ostp_core::crypto::current_junk_window(); for candidate_key in keys_snapshot { - let secrets = ostp_core::crypto::derive_all_secrets(candidate_key.as_bytes()); + let secrets = self.cached_secrets(&candidate_key); // Junk frames carry this key's time-rotating marker (no global // constant, no static per-user signature). Drop silently. if packet.len() >= 4 { - let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window); - let m_prev = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window.wrapping_sub(1)); + let (m_now, m_prev) = self.cached_junk_markers(&candidate_key, junk_window); if packet[0..4] == m_now || packet[0..4] == m_prev { return Ok(DispatchOutcome::Junk); } @@ -461,6 +525,14 @@ impl Dispatcher { .as_secs(); self.replay_cache.retain(|_, &mut ts| (current_sys_time as i64 - ts as i64).abs() <= 300); + // Drop cached secrets/junk-markers for keys that have been deleted, so the + // caches can't grow without bound as keys churn. + { + let keys = self.access_keys.read().unwrap_or_else(|e| e.into_inner()); + self.secrets_cache.retain(|k, _| keys.contains_key(k)); + self.junk_cache.retain(|k, _| keys.contains_key(k)); + } + let mut frames = Vec::new(); let mut expired = Vec::new(); let now = std::time::Instant::now(); From b5735fe8c2597277a9be405f079292c158ba28e8 Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 21:21:01 +0300 Subject: [PATCH 3/7] fix: quiet hot-path logging and stop logging access keys verbatim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two classes of issue: - Hot-path/attacker-triggerable events logged at info/error with internal detail: a per-handshake info! byte dump (raw_vec[0..6]) and a per-packet error! on session-id mismatch that dumped expected/got session ids. Both are log-flood + info-leak surfaces; downgraded to debug and stripped of the sensitive detail. Close/Resume frame handling likewise moved from info to debug. - The access key (a shared secret) was written to logs verbatim in three places (session drop, key-created UI event, API create-user) and as an 8-char prefix in one. Added key_fp() — a short SHA-256 fingerprint — and routed all key logging through it so operators can still correlate events without the secret ever hitting the log. --- ostp-core/src/protocol.rs | 11 ++++++----- ostp-server/src/api.rs | 2 +- ostp-server/src/dispatcher.rs | 13 +++++++++++-- ostp-server/src/lib.rs | 3 ++- 4 files changed, 20 insertions(+), 9 deletions(-) diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index 92bee13..98b9717 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -236,7 +236,9 @@ impl ProtocolMachine { let session_id = u32::from_be_bytes([raw_vec[0], raw_vec[1], raw_vec[2], raw_vec[3]]); if session_id != self.session_id { - tracing::error!("session id mismatch! expected={:#010x}, got={:#010x}, is_handshake={}, raw_len={}", self.session_id, session_id, is_handshake, raw_vec.len()); + // Per-packet, attacker-triggerable event: keep at debug and don't + // dump internal session ids (log-flood + info-leak surface). + tracing::debug!("session id mismatch (is_handshake={})", is_handshake); return Err(ProtocolError::State("session id mismatch".to_string())); } @@ -262,8 +264,7 @@ impl ProtocolMachine { noise_len, raw_vec.len() - 6 ))); } - tracing::info!("handle_inbound: raw_vec.len()={}, noise_len={}, raw_vec[0..6]={:?}", raw_vec.len(), noise_len, &raw_vec[0..6]); - + let mut read_out = vec![0_u8; 1024]; let n = self.noise.read_handshake(&raw_vec[6..6 + noise_len], &mut read_out).map_err(|e| { ProtocolError::Crypto(format!("noise-read: {:?} (raw_len={}, noise_len={})", e, raw_vec.len(), noise_len)) @@ -362,11 +363,11 @@ impl ProtocolMachine { } FrameKind::Resume => { // 0-RTT: treat early data as application data - tracing::info!("0-RTT Resume frame received, processing early data"); + tracing::debug!("0-RTT Resume frame received, processing early data"); ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) } FrameKind::Close => { - tracing::info!("Received Close frame, terminating session"); + tracing::debug!("Received Close frame, terminating session"); self.state = OstpState::Closed; ProtocolAction::Noop } diff --git a/ostp-server/src/api.rs b/ostp-server/src/api.rs index 5d38ffd..229c5be 100644 --- a/ostp-server/src/api.rs +++ b/ostp-server/src/api.rs @@ -633,7 +633,7 @@ async fn handle_create_user( return api_error::("failed to save configuration"); } - tracing::info!("API: created user key {}", &key[..8.min(key.len())]); + tracing::info!("API: created user key (fp={})", crate::dispatcher::key_fp(&key)); (StatusCode::OK, ApiResponse::success(key)) } diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 0d69b39..8316ab4 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -106,6 +106,15 @@ pub struct Dispatcher { /// capping flood-driven trial work at TRIAL_RATE × num_keys crypto ops/sec. const TRIAL_RATE: f64 = 100.0; +/// Short, non-reversible fingerprint of an access key for logs. The access key +/// is a shared secret, so it must never be written to logs verbatim; this lets +/// an operator correlate events without exposing the key itself. +pub(crate) fn key_fp(access_key: &str) -> String { + use sha2::{Digest, Sha256}; + let h = Sha256::digest(access_key.as_bytes()); + format!("{:02x}{:02x}{:02x}", h[0], h[1], h[2]) +} + #[allow(dead_code)] impl Dispatcher { pub fn new(machine_config: ProtocolConfig, access_keys: Arc>>) -> Self { @@ -289,7 +298,7 @@ impl Dispatcher { let user_stats = self.get_or_create_user_stats(&access_key); if !key_valid || user_stats.is_over_limit() { tracing::info!("Dropping session {} for key {} (valid={}, over_limit={})", - session_id, access_key, key_valid, user_stats.is_over_limit()); + session_id, key_fp(&access_key), key_valid, user_stats.is_over_limit()); self.drop_session(session_id); return Ok(DispatchOutcome::Unauthorized); } @@ -467,7 +476,7 @@ impl Dispatcher { // Check traffic limit before accepting if user_stats.is_over_limit() { - tracing::warn!("User {} exceeded traffic limit, rejecting handshake from {}", candidate_key, peer); + tracing::warn!("User {} exceeded traffic limit, rejecting handshake from {}", key_fp(&candidate_key), peer); return Ok(DispatchOutcome::Unauthorized); } diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index 2177eb9..f3ff586 100644 --- a/ostp-server/src/lib.rs +++ b/ostp-server/src/lib.rs @@ -303,7 +303,8 @@ pub async fn run_server( } } UiEvent::KeyCreated { key } => { - tracing::info!("Access key created: {key}"); + // Never log the access key verbatim — it's a shared secret. + tracing::info!("Access key created (fp={})", crate::dispatcher::key_fp(&key)); } UiEvent::UnauthorizedProbe { peer, bytes } => { if debug { From 5754689e09e1810412b7a0f11d1a206af35eba7c Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 21:22:45 +0300 Subject: [PATCH 4/7] refactor: remove dead 0-RTT resumption module (unsafe XOR ticket crypto) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resumption module (SessionTicket/TicketValidator) was never wired into the client or server — nothing issued or validated tickets, and no Resume frame was ever sent. But it "encrypted" tickets by XOR-ing them with a single static keystream SHA256(psk || const) and had no MAC (despite a doc comment claiming HMAC): a textbook many-time-pad, trivially broken from a couple of captured tickets, and malleable. Leaving it in-tree invited someone to wire up a broken 0-RTT path later. Removed the module, its FrameKind::Resume wire variant, and the protocol handler for it. 0-RTT can be reintroduced later on a real AEAD-sealed ticket if desired. --- ostp-core/src/framing/frame.rs | 3 - ostp-core/src/lib.rs | 1 - ostp-core/src/protocol.rs | 5 - ostp-core/src/resumption.rs | 307 --------------------------------- 4 files changed, 316 deletions(-) delete mode 100644 ostp-core/src/resumption.rs diff --git a/ostp-core/src/framing/frame.rs b/ostp-core/src/framing/frame.rs index 3f039ef..74f6b18 100644 --- a/ostp-core/src/framing/frame.rs +++ b/ostp-core/src/framing/frame.rs @@ -13,8 +13,6 @@ pub enum FrameKind { KeepAlive = 4, Nack = 5, Ack = 6, - /// 0-RTT session resumption: client sends ticket + early data - Resume = 7, } impl TryFrom for FrameKind { @@ -28,7 +26,6 @@ impl TryFrom for FrameKind { 4 => Ok(Self::KeepAlive), 5 => Ok(Self::Nack), 6 => Ok(Self::Ack), - 7 => Ok(Self::Resume), _ => Err(ProtocolError::Framing("unknown frame kind".to_string())), } } diff --git a/ostp-core/src/lib.rs b/ostp-core/src/lib.rs index ee31a2a..cfddbaf 100644 --- a/ostp-core/src/lib.rs +++ b/ostp-core/src/lib.rs @@ -3,7 +3,6 @@ pub mod crypto; pub mod framing; pub mod protocol; pub mod relay; -pub mod resumption; pub use crypto::NoiseRole; pub use framing::{TrafficProfile, PaddingStrategy}; diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index 98b9717..f1d0ba7 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -361,11 +361,6 @@ impl ProtocolMachine { FrameKind::Data => { ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) } - FrameKind::Resume => { - // 0-RTT: treat early data as application data - tracing::debug!("0-RTT Resume frame received, processing early data"); - ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) - } FrameKind::Close => { tracing::debug!("Received Close frame, terminating session"); self.state = OstpState::Closed; diff --git a/ostp-core/src/resumption.rs b/ostp-core/src/resumption.rs deleted file mode 100644 index dab1c74..0000000 --- a/ostp-core/src/resumption.rs +++ /dev/null @@ -1,307 +0,0 @@ -//! 0-RTT Session Resumption for OSTP. -//! -//! When a client has previously connected to a server, it can cache -//! a "session ticket" that allows it to send encrypted data in the -//! very first packet — eliminating the handshake round-trip entirely. -//! -//! How it works: -//! 1. After a successful handshake, the server issues a SessionTicket -//! containing enough state to resume the session. -//! 2. The client stores the ticket locally (encrypted with the PSK). -//! 3. On reconnection, the client sends a ResumptionRequest with the -//! ticket + early data in the first packet. -//! 4. The server validates the ticket and immediately begins processing -//! data, achieving 0-RTT. -//! -//! Security considerations: -//! - Tickets have a TTL (default 3600s) to limit replay window. -//! - The server maintains a ticket nonce set to prevent replay. -//! - Early data is idempotent by protocol design (relay CONNECT is safe -//! because duplicate CONNECTs to the same target are no-ops). - -use std::collections::HashSet; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use sha2::{Digest, Sha256}; - -/// A session ticket that allows 0-RTT resumption. -#[derive(Debug, Clone)] -pub struct SessionTicket { - /// Unique ticket identifier (prevents replay) - pub ticket_id: [u8; 16], - /// Server session ID to resume - pub session_id: u32, - /// Derived cipher key for early data - pub cipher_key: [u8; 32], - /// Timestamp of issuance (seconds since epoch) - pub issued_at: u64, - /// Time-to-live in seconds - pub ttl: u64, -} - -/// Maximum ticket age (1 hour default) -const DEFAULT_TICKET_TTL: u64 = 3600; -/// Maximum tickets in the anti-replay set -const MAX_REPLAY_SET: usize = 10000; - -impl SessionTicket { - /// Create a new session ticket from the transport key material. - pub fn new(session_id: u32, transport_key: &[u8; 32], psk: &[u8; 32]) -> Self { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Derive ticket ID from key material + timestamp - let mut hasher = Sha256::new(); - hasher.update(transport_key); - hasher.update(now.to_be_bytes()); - hasher.update(b"ostp-ticket-id"); - let hash = hasher.finalize(); - let mut ticket_id = [0u8; 16]; - ticket_id.copy_from_slice(&hash[..16]); - - // Derive cipher key for early data from PSK + ticket - let mut key_hasher = Sha256::new(); - key_hasher.update(psk); - key_hasher.update(ticket_id); - key_hasher.update(b"ostp-early-data-key"); - let cipher_key_hash = key_hasher.finalize(); - let mut cipher_key = [0u8; 32]; - cipher_key.copy_from_slice(&cipher_key_hash); - - Self { - ticket_id, - session_id, - cipher_key, - issued_at: now, - ttl: DEFAULT_TICKET_TTL, - } - } - - /// Check if the ticket has expired. - pub fn is_expired(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now > self.issued_at + self.ttl - } - - /// Serialize the ticket to bytes for storage/transmission. - /// Wire format: [ticket_id:16][session_id:4][cipher_key:32][issued_at:8][ttl:8] - pub fn to_bytes(&self) -> Vec { - let mut out = Vec::with_capacity(68); - out.extend_from_slice(&self.ticket_id); - out.extend_from_slice(&self.session_id.to_be_bytes()); - out.extend_from_slice(&self.cipher_key); - out.extend_from_slice(&self.issued_at.to_be_bytes()); - out.extend_from_slice(&self.ttl.to_be_bytes()); - out - } - - /// Deserialize a ticket from bytes. - pub fn from_bytes(data: &[u8]) -> Option { - if data.len() < 68 { - return None; - } - let mut ticket_id = [0u8; 16]; - ticket_id.copy_from_slice(&data[0..16]); - - let session_id = u32::from_be_bytes(data[16..20].try_into().ok()?); - - let mut cipher_key = [0u8; 32]; - cipher_key.copy_from_slice(&data[20..52]); - - let issued_at = u64::from_be_bytes(data[52..60].try_into().ok()?); - let ttl = u64::from_be_bytes(data[60..68].try_into().ok()?); - - Some(Self { - ticket_id, - session_id, - cipher_key, - issued_at, - ttl, - }) - } - - /// Encrypt the ticket with a PSK for client-side storage. - /// Uses a simple XOR cipher with HMAC-SHA256 derived key. - pub fn encrypt(&self, psk: &[u8; 32]) -> Vec { - let raw = self.to_bytes(); - let mut enc_key_hasher = Sha256::new(); - enc_key_hasher.update(psk); - enc_key_hasher.update(b"ostp-ticket-encryption"); - let enc_key = enc_key_hasher.finalize(); - - let mut encrypted = raw.clone(); - for (i, byte) in encrypted.iter_mut().enumerate() { - *byte ^= enc_key[i % 32]; - } - encrypted - } - - /// Decrypt a ticket from encrypted bytes. - pub fn decrypt(encrypted: &[u8], psk: &[u8; 32]) -> Option { - let mut enc_key_hasher = Sha256::new(); - enc_key_hasher.update(psk); - enc_key_hasher.update(b"ostp-ticket-encryption"); - let enc_key = enc_key_hasher.finalize(); - - let mut decrypted = encrypted.to_vec(); - for (i, byte) in decrypted.iter_mut().enumerate() { - *byte ^= enc_key[i % 32]; - } - Self::from_bytes(&decrypted) - } -} - -/// Server-side anti-replay guard for session tickets. -#[allow(dead_code)] -pub struct TicketValidator { - /// Set of consumed ticket IDs (prevents replay) - consumed: HashSet<[u8; 16]>, - /// PSK for ticket validation - psk: [u8; 32], - /// Maximum age for tickets - max_age: Duration, -} - -impl TicketValidator { - pub fn new(psk: [u8; 32]) -> Self { - Self { - consumed: HashSet::new(), - psk, - max_age: Duration::from_secs(DEFAULT_TICKET_TTL), - } - } - - /// Validate a ticket from the client. Returns the ticket if valid, - /// or None if expired, replayed, or invalid. - pub fn validate(&mut self, encrypted_ticket: &[u8]) -> Option { - let ticket = SessionTicket::decrypt(encrypted_ticket, &self.psk)?; - - // Check expiry - if ticket.is_expired() { - tracing::debug!("0-RTT ticket rejected: expired"); - return None; - } - - // Check replay - if self.consumed.contains(&ticket.ticket_id) { - tracing::warn!("0-RTT ticket rejected: replay detected"); - return None; - } - - // Accept and mark as consumed - self.consumed.insert(ticket.ticket_id); - - // Garbage collection: remove old entries when set grows too large - if self.consumed.len() > MAX_REPLAY_SET { - // Simple strategy: clear the entire set. This is safe because - // expired tickets would fail the expiry check anyway. - self.consumed.clear(); - self.consumed.insert(ticket.ticket_id); - tracing::debug!("0-RTT replay set cleared (overflow)"); - } - - tracing::debug!("0-RTT ticket accepted: session_id={}", ticket.session_id); - Some(ticket) - } - - /// Issue a new ticket for a completed session. - pub fn issue_ticket(&self, session_id: u32, transport_key: &[u8; 32]) -> SessionTicket { - SessionTicket::new(session_id, transport_key, &self.psk) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_ticket_serialize_roundtrip() { - let psk = [42u8; 32]; - let key = [1u8; 32]; - let ticket = SessionTicket::new(12345, &key, &psk); - - let bytes = ticket.to_bytes(); - let restored = SessionTicket::from_bytes(&bytes).unwrap(); - - assert_eq!(ticket.ticket_id, restored.ticket_id); - assert_eq!(ticket.session_id, restored.session_id); - assert_eq!(ticket.cipher_key, restored.cipher_key); - assert_eq!(ticket.issued_at, restored.issued_at); - } - - #[test] - fn test_ticket_encrypt_decrypt() { - let psk = [42u8; 32]; - let key = [1u8; 32]; - let ticket = SessionTicket::new(99, &key, &psk); - - let encrypted = ticket.encrypt(&psk); - let decrypted = SessionTicket::decrypt(&encrypted, &psk).unwrap(); - - assert_eq!(ticket.ticket_id, decrypted.ticket_id); - assert_eq!(ticket.session_id, decrypted.session_id); - } - - #[test] - fn test_ticket_wrong_psk_fails() { - let psk = [42u8; 32]; - let wrong_psk = [99u8; 32]; - let key = [1u8; 32]; - let ticket = SessionTicket::new(1, &key, &psk); - let encrypted = ticket.encrypt(&psk); - - // Decrypting with wrong PSK produces garbage, from_bytes should - // still return Some but ticket_id won't match - let decrypted = SessionTicket::decrypt(&encrypted, &wrong_psk); - // It may parse but the data will be wrong - if let Some(d) = decrypted { - assert_ne!(d.ticket_id, ticket.ticket_id); - } - } - - #[test] - fn test_ticket_not_expired() { - let psk = [42u8; 32]; - let key = [1u8; 32]; - let ticket = SessionTicket::new(1, &key, &psk); - assert!(!ticket.is_expired()); - } - - #[test] - fn test_validator_replay_protection() { - let psk = [42u8; 32]; - let key = [1u8; 32]; - let mut validator = TicketValidator::new(psk); - - let ticket = validator.issue_ticket(1, &key); - let encrypted = ticket.encrypt(&psk); - - // First use should succeed - assert!(validator.validate(&encrypted).is_some()); - - // Replay should fail - assert!(validator.validate(&encrypted).is_none()); - } - - #[test] - fn test_validator_different_tickets() { - let psk = [42u8; 32]; - let mut validator = TicketValidator::new(psk); - - let ticket1 = validator.issue_ticket(1, &[1u8; 32]); - let ticket2 = validator.issue_ticket(2, &[2u8; 32]); - - assert!(validator.validate(&ticket1.encrypt(&psk)).is_some()); - assert!(validator.validate(&ticket2.encrypt(&psk)).is_some()); - } - - #[test] - fn test_truncated_ticket_fails() { - assert!(SessionTicket::from_bytes(&[0u8; 10]).is_none()); - } -} From a9509a235d85894b105c1194f4c70d68e525cdf3 Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 21:25:36 +0300 Subject: [PATCH 5/7] fix: low-severity hardening (Karn RTT, 32-bit frame overflow, replay-cache DoS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Karn's algorithm: drop_acked_frames no longer samples RTT from frames that were retransmitted (last_sent is bumped on each retransmit, so an ACK for the original transmission would measure a spuriously small RTT and drag SRTT/RTO down). Added CongestionController::on_ack_no_rtt for the case where every acked frame was ambiguous, so the window still advances without polluting the RTT estimator. Refactored the shared window-growth into grow_window. - Frame decode: header+payload+pad length now uses checked_add. payload_len is a u32 from the header and on 32-bit targets (MIPS/ARMv7 routers are supported) the sum could wrap usize and slip past the truncation check. - Replay cache: a full cache used to reject ALL new handshakes globally until the next tick, letting one flooding key-holder deny service to everyone. Now it reclaims expired entries and, if still full, evicts the single oldest — new handshakes always get in. Fixed the mislabelled "100000" log (cap is 50000) and named it REPLAY_CACHE_MAX. --- ostp-core/src/congestion.rs | 40 +++++++++++++++++++++++++++++++--- ostp-core/src/framing/frame.rs | 10 ++++++++- ostp-core/src/protocol.rs | 24 ++++++++++++++------ ostp-server/src/dispatcher.rs | 32 ++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/ostp-core/src/congestion.rs b/ostp-core/src/congestion.rs index 22758d0..9e790e1 100644 --- a/ostp-core/src/congestion.rs +++ b/ostp-core/src/congestion.rs @@ -144,6 +144,19 @@ impl CongestionController { self.bytes_in_flight = self.bytes_in_flight.saturating_add(bytes); } + /// Record that `bytes` were acknowledged but WITHOUT a usable RTT sample + /// (e.g. every acked frame was retransmitted, so Karn's algorithm forbids + /// measuring RTT from it). The window still advances; only the RTT estimator + /// is left untouched. + pub fn on_ack_no_rtt(&mut self, bytes: u64) { + let now = Instant::now(); + self.bytes_in_flight = self.bytes_in_flight.saturating_sub(bytes); + self.total_acked = self.total_acked.saturating_add(bytes); + self.grow_window(bytes); + self.update_pacing_rate(); + self.last_ack_time = now; + } + /// Record that `bytes` were acknowledged with the given RTT sample. pub fn on_ack(&mut self, bytes: u64, rtt: Duration) { let now = Instant::now(); @@ -153,6 +166,13 @@ impl CongestionController { // Update RTT measurements self.update_rtt(rtt, now); + self.grow_window(bytes); + self.update_pacing_rate(); + self.last_ack_time = now; + } + + /// Congestion-window growth shared by both ACK paths (slow start / probe). + fn grow_window(&mut self, bytes: u64) { // State machine match self.phase { Phase::SlowStart => { @@ -168,9 +188,6 @@ impl CongestionController { self.cwnd = self.cwnd.saturating_add(bytes * self.mtu / self.cwnd.max(1)); } } - - self.update_pacing_rate(); - self.last_ack_time = now; } /// Record a loss event. @@ -313,6 +330,23 @@ mod tests { assert_eq!(rto, Duration::from_millis(150)); } + #[test] + fn test_on_ack_no_rtt_grows_window_without_touching_srtt() { + let mut cc = CongestionController::new(1200); + // Establish a known SRTT with a real sample. + cc.on_send(1200); + cc.on_ack(1200, Duration::from_millis(40)); + let srtt_before = cc.smoothed_rtt(); + let cwnd_before = cc.cwnd(); + + // A Karn's-algorithm ACK (all acked frames were retransmitted): window + // must advance, RTT estimate must be untouched. + cc.on_send(1200); + cc.on_ack_no_rtt(1200); + assert!(cc.cwnd() > cwnd_before, "cwnd should still grow on a no-RTT ack"); + assert_eq!(cc.smoothed_rtt(), srtt_before, "SRTT must not move on a no-RTT ack"); + } + #[test] fn test_rto_clamp_min() { let cc = CongestionController::new(1200); diff --git a/ostp-core/src/framing/frame.rs b/ostp-core/src/framing/frame.rs index 74f6b18..370aa77 100644 --- a/ostp-core/src/framing/frame.rs +++ b/ostp-core/src/framing/frame.rs @@ -101,7 +101,15 @@ impl FramedPacket { let payload_len = header.payload_len as usize; let pad_len = header.pad_len as usize; - let expected = FRAME_HEADER_LEN + payload_len + pad_len; + // Use checked arithmetic: payload_len is a u32 from the (decrypted, but + // still to-be-trusted) header, and on 32-bit targets — MIPS/ARMv7 + // routers are supported build targets — header+payload+pad can overflow + // usize and wrap to a small value that spuriously passes the length + // check, causing an out-of-range slice below. + let expected = FRAME_HEADER_LEN + .checked_add(payload_len) + .and_then(|v| v.checked_add(pad_len)) + .ok_or_else(|| ProtocolError::Framing("frame length overflow".to_string()))?; if buf.len() < expected { return Err(ProtocolError::Framing("frame body truncated".to_string())); } diff --git a/ostp-core/src/protocol.rs b/ostp-core/src/protocol.rs index f1d0ba7..a1d1a5e 100644 --- a/ostp-core/src/protocol.rs +++ b/ostp-core/src/protocol.rs @@ -683,24 +683,34 @@ impl ProtocolMachine { fn drop_acked_frames(&mut self, ranges: &[(u64, u64)]) { let now = Instant::now(); let mut acked_bytes = 0u64; - let mut min_rtt = Duration::from_secs(60); + let mut min_rtt: Option = None; - // Compute RTT from the oldest acked frame's send timestamp for frame in self.sent_history.iter() { if nonce_in_ranges(frame.nonce, ranges) { acked_bytes += frame.bytes.len() as u64; - let rtt = now.duration_since(frame.last_sent); - if rtt < min_rtt { - min_rtt = rtt; + // Karn's algorithm: never take an RTT sample from a frame that + // was retransmitted. `last_sent` is bumped on every retransmit, + // so an ACK for the ORIGINAL transmission would be measured + // against the retransmit time, yielding a spuriously small RTT + // that drags SRTT/RTO down and triggers more spurious + // retransmits. Only unambiguous (never-retried) frames qualify. + if frame.retries == 0 { + let rtt = now.duration_since(frame.last_sent); + min_rtt = Some(min_rtt.map_or(rtt, |m| m.min(rtt))); } } } self.sent_history.retain(|frame| !nonce_in_ranges(frame.nonce, ranges)); - // Notify congestion controller + // Notify congestion controller. Feed an RTT sample only when we had at + // least one unambiguous ACK; otherwise update the window without + // polluting the RTT estimator. if acked_bytes > 0 { - self.cc.on_ack(acked_bytes, min_rtt); + match min_rtt { + Some(rtt) => self.cc.on_ack(acked_bytes, rtt), + None => self.cc.on_ack_no_rtt(acked_bytes), + } } } } diff --git a/ostp-server/src/dispatcher.rs b/ostp-server/src/dispatcher.rs index 8316ab4..20e144a 100644 --- a/ostp-server/src/dispatcher.rs +++ b/ostp-server/src/dispatcher.rs @@ -11,6 +11,11 @@ use portable_atomic::AtomicU64; /// Excess handshake attempts are silently dropped -- no response, no state allocated. const MAX_SESSIONS: usize = 1024; +/// Cap on the anti-replay handshake cache. When reached, expired entries are +/// reclaimed (and if needed the oldest is evicted) rather than rejecting new +/// handshakes globally — see the eviction logic in on_datagram. +const REPLAY_CACHE_MAX: usize = 50_000; + pub enum DispatchOutcome { Unauthorized, /// Packet matched a registered key's per-key junk marker — drop silently. @@ -457,9 +462,30 @@ impl Dispatcher { } if !self.replay_cache.contains_key(&payload.to_vec()) { - if self.replay_cache.len() >= 50_000 { - tracing::warn!("Replay cache full (100000 entries), rejecting handshake from {}", peer); - return Ok(DispatchOutcome::Unauthorized); + if self.replay_cache.len() >= REPLAY_CACHE_MAX { + // Don't globally reject new handshakes when full — + // that would let one flooding key-holder deny + // service to everyone. Reclaim space instead: + // first drop entries already past the drift + // window, then, if still full, evict the single + // oldest. A replay is still caught because it can + // only be accepted while within the 300s drift + // window, and an entry that young is never the + // one evicted before the cache genuinely holds + // 50k sub-300s handshakes. + self.replay_cache.retain(|_, &mut cached_ts| { + (now as i64 - cached_ts as i64).abs() <= 300 + }); + if self.replay_cache.len() >= REPLAY_CACHE_MAX { + if let Some(oldest) = self.replay_cache + .iter() + .min_by_key(|(_, &ts)| ts) + .map(|(k, _)| k.clone()) + { + self.replay_cache.remove(&oldest); + } + tracing::warn!("Replay cache full ({} entries), evicting oldest", REPLAY_CACHE_MAX); + } } if self.peer_machines.len() >= MAX_SESSIONS { tracing::warn!("Max sessions reached ({}), rejecting handshake from {}", MAX_SESSIONS, peer); From 4ac2e79e14ead4d8669c0ffddeeb6aa125a2125d Mon Sep 17 00:00:00 2001 From: ospab Date: Sat, 11 Jul 2026 22:01:40 +0300 Subject: [PATCH 6/7] docs: document DH-inclusive transport keys / forward secrecy + trial rate-limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reflect the crypto hardening in the EN/RU specification: - Section 6: transport keys now come from Noise Split() over the chaining key ck (includes the ee DH secret), giving forward secrecy; added the rationale for why keys must NOT come from the handshake hash h, and the wire-version-5 gate. - Section 8: documented the handshake-trial CPU-DoS defense (per-key secret/marker caching + trial-path token bucket). - Corrected the handshake replay window (±300s / 5min, was mis-stated as ±30s) and PSK derivation (HKDF-SHA256). --- docs/en/specification.md | 18 +++++++++++++++--- docs/ru/specification.md | 21 +++++++++++++++++---- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/docs/en/specification.md b/docs/en/specification.md index c24e0be..95027f7 100644 --- a/docs/en/specification.md +++ b/docs/en/specification.md @@ -90,11 +90,22 @@ Because the `Nonce` is unique per packet, the mask is cryptographically independ OSTP executes a Noise Protocol Framework exchange utilizing the `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s` pattern. -1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via SHA-256. +1. The Registration Key (`access_key`) is converted to a 32-octet strong pre-shared key (PSK) via HKDF-SHA-256. 2. The PSK is integrated into the state at pattern position zero, authorizing and encrypting the very first handshaking datagram. -3. Ephemeral Curve25519 key exchange is evaluated to synthesize autonomous symmetric keys for subsequent read/write channels. +3. Ephemeral Curve25519 key exchange (`ee`) is evaluated, and the two directional transport keys are taken from Noise's `Split()` over the final chaining key `ck`. -The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a strict ±30-second synchronization window. +> **Forward secrecy.** The transport keys are derived from the chaining key +> `ck`, which absorbs the ephemeral `ee` Diffie-Hellman result. They are **not** +> derived from the Noise handshake hash `h` — `h` only ever absorbs public +> transcript data (ephemeral public keys and on-wire ciphertexts) and never the +> DH secret, so keys derived from it would give an access-key holder the ability +> to decrypt any recorded session. Deriving from `ck` binds each session to its +> ephemeral private keys, which are discarded after the handshake: an adversary +> who later compromises the PSK still cannot decrypt past traffic. This is a +> wire-breaking property gated by the internal protocol version (currently 5); +> peers on an older version derive different keys and cannot interoperate. + +The initial handshake payload includes a Unix timestamp to mitigate replay attacks. The server enforces a ±300-second (5-minute) synchronization window and additionally records accepted handshakes in an anti-replay set for that window. --- @@ -126,4 +137,5 @@ The server supports seamless network handoffs (e.g., transitioning from Wi-Fi to * **Nonce Exhaustion:** The Nonce field is 64 bits. Implementations MUST terminate and re-key a session before the Nonce overflows to prevent AEAD keystream reuse. * **Session Exhaustion (DoS):** Servers MUST enforce a strict cap on concurrent sessions (e.g., 1024) and silently drop handshake attempts exceeding this limit to prevent memory exhaustion attacks. +* **Handshake-trial CPU DoS:** Because there is no cleartext key identifier on the wire (a deliberate stealth property), a datagram from an unknown source must be trial-decrypted against every registered key. Servers MUST bound this work: OSTP caches each key's derived secrets and time-windowed junk markers (so a trial is a cheap comparison plus one AEAD attempt per key, not a fresh HKDF/HMAC), and gates the trial path behind a global token bucket (default 100/s) so a spoofed-source flood cannot force unbounded per-packet crypto. The established-session fast path and IP-roaming path are not subject to this bucket. * **Header Authentication:** The header obfuscation mechanism provides privacy, not integrity. Header integrity is mathematically guaranteed by the Poly1305 Authentication Tag, which covers the entire 12-byte header as Additional Authenticated Data (AAD). diff --git a/docs/ru/specification.md b/docs/ru/specification.md index 1f8a32e..a19a011 100644 --- a/docs/ru/specification.md +++ b/docs/ru/specification.md @@ -90,11 +90,23 @@ OSTP поддерживает **внутреннее криптографиче OSTP использует Noise Protocol Framework с паттерном `Noise_NNpsk0_25519_ChaChaPoly_BLAKE2s`. -1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через SHA-256. -2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия (Zero-RTT авторизация). -3. Выполняется эфемерный обмен ключами Curve25519 для создания симметричных ключей передачи данных. +1. Регистрационный ключ доступа (`access_key`) преобразуется в 32-байтный строгий предварительно распределенный ключ (PSK) через HKDF-SHA-256. +2. PSK применяется на нулевой позиции паттерна, обеспечивая авторизацию и шифрование самой первой датаграммы рукопожатия. +3. Выполняется эфемерный обмен ключами Curve25519 (`ee`), и два однонаправленных транспортных ключа берутся из `Split()` протокола Noise над финальным chaining key `ck`. -Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер строго контролирует окно синхронизации (±30 секунд). +> **Прямая секретность (Forward Secrecy).** Транспортные ключи выводятся из +> chaining key `ck`, который вбирает результат эфемерного обмена Диффи-Хеллмана +> `ee`. Они **не** выводятся из handshake hash `h` протокола Noise: `h` вбирает +> только публичные данные транскрипта (эфемерные публичные ключи и шифртексты с +> провода) и никогда — сам DH-секрет, поэтому ключи, выведенные из `h`, дали бы +> держателю PSK возможность расшифровать любую записанную сессию. Вывод из `ck` +> привязывает каждую сессию к её эфемерным приватным ключам, которые +> уничтожаются после рукопожатия: злоумышленник, скомпрометировавший PSK позже, +> всё равно не сможет расшифровать прошлый трафик. Это свойство ломает +> совместимость и защищено внутренней версией протокола (сейчас 5): узлы более +> старой версии выводят другие ключи и не могут взаимодействовать. + +Первичная полезная нагрузка рукопожатия содержит Unix-отметку времени для защиты от атак повторного воспроизведения (Replay Attacks). Сервер контролирует окно синхронизации (±300 секунд, 5 минут) и дополнительно фиксирует принятые рукопожатия в множестве защиты от повтора на время этого окна. --- @@ -119,4 +131,5 @@ OSTP обеспечивает надежную доставку поверх UDP * **Исчерпание Nonce:** Поле Nonce имеет размер 64 бита. Реализации ОБЯЗАНЫ разрывать сессию до переполнения Nonce, чтобы предотвратить катастрофическое повторное использование гаммы AEAD-шифра. * **DDoS и исчерпание ресурсов:** Серверы ДОЛЖНЫ применять жесткий лимит на количество одновременных сессий (например, 1024) и молча отбрасывать запросы на рукопожатие при превышении лимита, предотвращая атаки на исчерпание памяти. +* **CPU-DoS на пути перебора рукопожатия:** Поскольку на проводе нет открытого идентификатора ключа (намеренное свойство скрытности), датаграмму от неизвестного источника приходится пробно расшифровывать каждым зарегистрированным ключом. Серверы ОБЯЗАНЫ ограничивать эту работу: OSTP кэширует производные секреты каждого ключа и его junk-маркеры для текущего временно́го окна (поэтому одна попытка — это дешёвое сравнение плюс одна попытка AEAD на ключ, а не новые HKDF/HMAC), и ограничивает путь перебора глобальным token bucket (по умолчанию 100/с), так что флуд с подменённых адресов не может навязать неограниченную криптографию на пакет. Быстрый путь установленных сессий и путь IP-роуминга под этот лимит не попадают. * **Целостность заголовка:** Механизм маскирования обеспечивает только скрытность, а не целостность. Целостность заголовков математически гарантируется 16-байтным тегом аутентификации Poly1305, который покрывает 12-байтный заголовок как присоединенные данные (AAD). From 90a919df599d2e0de7a6b2b595bbef46bcf4bf8a Mon Sep 17 00:00:00 2001 From: ospab Date: Sun, 12 Jul 2026 00:34:10 +0300 Subject: [PATCH 7/7] docs: update architecture diagram to be more understandable --- README.md | 59 ++++++++++++++++++++++----------------- README.ru.md | 57 +++++++++++++++++++++---------------- ostp-client/src/bridge.rs | 41 ++++++++++++++++++++++++--- ostp.wiki | 2 +- 4 files changed, 104 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 6c60745..078d91d 100644 --- a/README.md +++ b/README.md @@ -56,35 +56,42 @@ Download pre-built binaries for your platform from [GitHub Releases](https://git ## Architecture ```mermaid -graph TD - subgraph Client ["Client"] - A[Browser / Apps] -->|SOCKS5 / HTTP| B(Bridge Multiplexer) - TUN[TUN Interface] -->|IP Packets| B - - subgraph OSTPCoreClient ["OSTP Core Protocol"] - B --> C{Protocol Machine} - C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD] - D -->|Obfuscated UDP Payload| E((UDP Socket)) - end +flowchart LR + %% Styles + classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b + classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32 + classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5 + classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c + classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828 + + subgraph Local["💻 Client Device"] + Apps["Web Browser / Apps"]:::userApp + Socks["SOCKS5 / HTTP Proxy"]:::ostpCore + Tun["Global TUN (VPN)"]:::ostpCore + Client["OSTP Client Protocol Engine\n(Noise + ChaCha20 + ARQ)"]:::ostpCore + + Apps -->|TCP/UDP| Socks + Apps -->|IP Packets| Tun + Socks --> Client + Tun --> Client end - E <==>|Encrypted & Obfuscated UDP Tunnel| F - - subgraph Server ["Server"] - F((UDP Socket)) --> G{Dispatcher} - - subgraph OSTPCoreServer ["OSTP Core Backend"] - G -->|Auth & Decrypt| H[Session & State Guard] - H -->|TCP Stream| I[Relay Loop] - end - - G -->|Active Probing / Unauth| FB[TCP Fallback Proxy] - FB -->|Forward| NGINX[nginx / Caddy] - - H -->|Stats & Traffic| API[Management API] - - I -->|Outbound| WWW((Internet)) + subgraph Internet["🌐 Hostile Network (DPI/Firewall)"] + Tunnel{"Fully Obfuscated\nEncrypted UDP\n(Looks like noise)"}:::network end + + subgraph Remote["🖥️ Remote VPS (Server)"] + Server["OSTP Server Protocol Engine\n(Authentication & Decryption)"]:::ostpCore + Relay["Connection Multiplexer"]:::ostpCore + Fallback["Fake Website\n(Nginx/Caddy)"]:::fallback + Target["Open Internet\n(YouTube, Google, etc)"]:::external + + Server -->|Decrypted Traffic| Relay + Server -->|Active Probe / Scanner| Fallback + Relay -->|Clear Traffic| Target + end + + Client <==> Tunnel <==> Server ``` --- diff --git a/README.ru.md b/README.ru.md index 7530435..16bb4cd 100644 --- a/README.ru.md +++ b/README.ru.md @@ -35,33 +35,42 @@ ## Архитектура ```mermaid -graph TD - subgraph Client ["Клиент"] - A[Браузер / Прил.] -->|SOCKS5 / HTTP| B(Bridge Multiplexer) - TUN[TUN Интерфейс] -->|IP Пакеты| B - - subgraph OSTPCoreClient ["OSTP Core Протокол"] - B --> C{Protocol Machine} - C -->|Noise Handshake| D[ChaCha20Poly1305 AEAD] - D -->|Обфусцированный UDP| E((UDP Сокет)) - end +flowchart LR + %% Styles + classDef userApp fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#01579b + classDef ostpCore fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px,color:#2e7d32 + classDef network fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#e65100,stroke-dasharray: 5 5 + classDef external fill:#f3e5f5,stroke:#4a148c,stroke-width:2px,color:#4a148c + classDef fallback fill:#ffebee,stroke:#c62828,stroke-width:2px,color:#c62828 + + subgraph Local["💻 Устройство клиента"] + Apps["Браузер / Приложения"]:::userApp + Socks["SOCKS5 / HTTP Прокси"]:::ostpCore + Tun["Global TUN (VPN)"]:::ostpCore + Client["OSTP Клиент\n(Noise + ChaCha20 + ARQ)"]:::ostpCore + + Apps -->|TCP/UDP| Socks + Apps -->|IP Пакеты| Tun + Socks --> Client + Tun --> Client end - E <==>|Зашифрованный UDP Туннель| F - - subgraph Server ["Сервер"] - F((UDP Сокет)) --> G{Dispatcher} - - subgraph OSTPCoreServer ["OSTP Core Backend"] - G -->|Auth & Decrypt| H[Session & State Guard] - H -->|TCP Поток| I[Relay Loop] - end - - G -->|Active Probing / Unauth| FB[TCP Fallback Proxy] - FB -->|Перенаправление| NGINX[nginx / Caddy] - - I -->|Outbound| WWW((Интернет)) + subgraph Internet["🌐 Сеть с цензурой (DPI)"] + Tunnel{"Зашифрованный UDP\n(Выглядит как белый шум)"}:::network end + + subgraph Remote["🖥️ Удаленный сервер (VPS)"] + Server["OSTP Сервер\n(Аутентификация)"]:::ostpCore + Relay["Мультиплексор соединений"]:::ostpCore + Fallback["Фейковый сайт\n(Nginx/Caddy)"]:::fallback + Target["Свободный интернет\n(YouTube, Google и т.д.)"]:::external + + Server -->|Расшифрованный трафик| Relay + Server -->|Сканеры цензоров| Fallback + Relay -->|Чистый трафик| Target + end + + Client <==> Tunnel <==> Server ``` --- diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 30259bf..14c383e 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -10,7 +10,7 @@ use ostp_core::{NoiseRole, OstpEvent, PaddingStrategy, ProtocolAction, ProtocolC use rand::Rng; use tokio::net::UdpSocket; use tokio::sync::{mpsc, watch}; -use tokio::time::{interval, timeout, Instant}; +use tokio::time::{interval, timeout, Instant, MissedTickBehavior}; use crate::app::{BridgeCommand, ConnectionStatus, UiEvent}; use crate::config::ClientConfig; @@ -131,6 +131,21 @@ impl Bridge { let mut metrics_tick = interval(Duration::from_millis(500)); let mut keepalive_tick = tokio::time::interval(Duration::from_secs(self.keepalive_interval_sec.max(1))); let mut retransmit_tick = tokio::time::interval(Duration::from_millis(10)); + // CRITICAL for suspend/resume: the default MissedTickBehavior is `Burst`, + // which after a laptop sleep or a phone backgrounding the app fires ALL + // the ticks that "should" have happened during the gap back-to-back. For + // the 10ms retransmit tick that is tens of thousands of instant ticks on + // resume — a CPU storm that hangs the bridge and manifests as the app + // freezing or getting stuck "Connecting". Skip missed ticks instead. + metrics_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + keepalive_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + retransmit_tick.set_missed_tick_behavior(MissedTickBehavior::Skip); + + // Wall-clock anchor for suspend/resume detection. tokio's timers run on a + // monotonic clock; comparing it against wall-clock lets us notice that + // the machine slept (or the app was frozen in the background) and force + // one clean reconnect instead of trying to resume a long-dead session. + let mut last_wall_check = SystemTime::now(); let init_msg = if self.mode == "tun" { "Bridge initialized (TUN mode)".to_string() } else { @@ -171,13 +186,27 @@ impl Bridge { } } _ = metrics_tick.tick() => { + // Suspend/resume detection: the wall clock jumps forward on + // wake even when the monotonic timer clock does not, so a + // large gap here means the machine slept / the app was frozen. + // The session is almost certainly dead (the server evicts + // idle sessions after 10 min), so force one clean reconnect + // rather than waiting on stale-session heuristics. + let wall_gap = last_wall_check.elapsed().unwrap_or_default(); + last_wall_check = SystemTime::now(); + if self.running && wall_gap > Duration::from_secs(15) { + let _ = tx.send(UiEvent::Log(format!( + "Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs() + ))).await; + self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await; + } if self.running { self.emit_metrics(&tx).await; } } _ = keepalive_tick.tick() => { if self.running { - self.handle_keepalive(&mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await; + self.handle_keepalive(false, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await; } } _ = retransmit_tick.tick() => { @@ -523,6 +552,7 @@ impl Bridge { async fn handle_keepalive( &mut self, + force: bool, sessions_opt: &mut Option>, udp_rx_opt: &mut Option>, proxy_guard: &mut Option, @@ -531,9 +561,12 @@ impl Bridge { proxy_tx: &mpsc::UnboundedSender<(u16, ProxyToClientMsg)>, proxy_rx: &mut mpsc::Receiver, ) { - if self.last_valid_recv.elapsed().as_secs() > 25 { + if force || self.last_valid_recv.elapsed().as_secs() > 25 { let elapsed = self.last_valid_recv.elapsed().as_secs(); - if elapsed > 180 { + // On a forced (post-resume) reconnect the monotonic clock may not + // have advanced, so `elapsed` can be small — never treat a forced + // reconnect as a hard timeout; we specifically want to re-establish. + if !force && elapsed > 180 { if self.kill_switch { let _ = tx.send(UiEvent::Log(format!("Connection stall ({}s). Kill Switch is ON, retrying reconnect indefinitely...", elapsed))).await; } else { diff --git a/ostp.wiki b/ostp.wiki index 90810f2..2a22b52 160000 --- a/ostp.wiki +++ b/ostp.wiki @@ -1 +1 @@ -Subproject commit 90810f25f7af9e0a57bacce3d74ca3e46a6433e4 +Subproject commit 2a22b520b2112c35676537ad85b11804b0053f01