diff --git a/Cargo.toml b/Cargo.toml index 653f621..41164eb 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/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). 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/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..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], @@ -74,8 +75,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/framing/frame.rs b/ostp-core/src/framing/frame.rs index 3f039ef..370aa77 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())), } } @@ -104,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/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 8ec23cf..a1d1a5e 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}; @@ -237,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())); } @@ -263,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)) @@ -281,9 +281,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; @@ -358,13 +361,8 @@ impl ProtocolMachine { FrameKind::Data => { ProtocolAction::DeliverApp(packet.header.stream_id, packet.payload) } - FrameKind::Resume => { - // 0-RTT: treat early data as application data - tracing::info!("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 } @@ -685,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), + } } } } @@ -732,26 +740,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::*; 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()); - } -} 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 d3bf540..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. @@ -83,6 +88,36 @@ 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; + +/// 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)] @@ -101,9 +136,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() @@ -239,7 +303,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); } @@ -302,7 +366,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 +390,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); } @@ -384,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); @@ -403,7 +502,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); } @@ -461,6 +560,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(); diff --git a/ostp-server/src/lib.rs b/ostp-server/src/lib.rs index ad6957d..70b7960 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 {