mirror of https://github.com/ospab/ostp.git
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:
commit
2660a37249
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue