merge protocol-hardening: forward secrecy + DoS/logging/dead-code hardening

Brings the crypto security audit into alpha alongside the already-merged
reconnection/UoT/Flutter fixes:

  - CRITICAL: transport keys now come from Noise Split() over ck (DH-
    inclusive), not the handshake hash — restores forward secrecy.
    Wire-breaking, PROTOCOL_VERSION 4->5.
  - Rate-limit + cache the O(N_keys) handshake trial path (CPU DoS).
  - Quiet hot-path logging; access keys no longer logged verbatim.
  - Removed dead 0-RTT resumption module (unsafe XOR ticket crypto).
  - Karn's algorithm RTT fix, 32-bit frame-length overflow guard,
    replay-cache eviction instead of global reject.
  - Updated EN/RU specification docs to match.

No conflicts: protocol-hardening's bridge.rs commit was an independent
duplicate of the same reconnection fix already on alpha, so git merged it
as a no-op on that file.
This commit is contained in:
ospab 2026-07-12 02:06:42 +03:00
commit 2660a37249
13 changed files with 320 additions and 416 deletions

View File

@ -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"

View File

@ -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).

View File

@ -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).

View File

@ -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);

View File

@ -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<HandshakeState>),
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<HandshakeState>,
}
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<usize, ProtocolError> {
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<usize, ProtocolError> {
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<Self, ProtocolError> {
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());
}
}

View File

@ -54,6 +54,7 @@ fn hkdf_expand(prk: &[u8; 32], info: &[u8], len: usize) -> Vec<u8> {
/// 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)

View File

@ -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<u8> for FrameKind {
@ -28,7 +26,6 @@ impl TryFrom<u8> 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()));
}

View File

@ -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};

View File

@ -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<Duration> = 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::*;

View File

@ -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<u8> {
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<Self> {
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<u8> {
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<Self> {
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<SessionTicket> {
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());
}
}

View File

@ -633,7 +633,7 @@ async fn handle_create_user(
return api_error::<String>("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))
}

View File

@ -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<Vec<u8>, 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<String, ostp_core::crypto::DerivedSecrets>,
/// 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<String, (u64, [u8; 4], [u8; 4])>,
/// 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<RwLock<HashMap<String, Arc<UserStats>>>> {
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<String> = 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();

View File

@ -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 {