fix(server): rate-limit + cache the O(N_keys) handshake trial path (CPU DoS)

Every datagram from an unrecognized source ran the full key-trial loop:
for each registered access key, an HKDF (derive_all_secrets) plus two
HMACs (junk markers) plus a Noise read. A garbage flood from spoofed
sources could therefore force unbounded O(N_keys) crypto per packet — a
CPU-amplification DoS with no throttle (the existing token bucket only
guarded the roaming path, not this one).

Two mitigations:
  - Memoize the per-key derived secrets (pure function of key+version) and
    the per-window junk markers, so the trial loop is now cheap comparisons
    plus one Noise read per key instead of HKDF+2*HMAC per key per packet.
    Also speeds up every legitimate new connection. Caches are pruned in
    on_tick when keys are deleted.
  - Gate the trial path behind a global token bucket (TRIAL_RATE=100/s,
    same burst). The established-session fast path and roaming are not
    gated, so live sessions are unaffected; only unknown-datagram trials
    are bounded. Over-budget datagrams are dropped silently.
This commit is contained in:
ospab 2026-07-11 21:18:05 +03:00
parent 29554a71f1
commit f904695760
2 changed files with 77 additions and 4 deletions

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, /// The derivation uses the access key as both IKM and salt material,
/// split into two halves. No fixed strings are used — the access key /// split into two halves. No fixed strings are used — the access key
/// alone determines all derived values. /// alone determines all derived values.
#[derive(Clone)]
pub struct DerivedSecrets { pub struct DerivedSecrets {
pub obfuscation_key: [u8; 8], pub obfuscation_key: [u8; 8],
pub psk: [u8; 32], pub psk: [u8; 32],

View File

@ -83,8 +83,29 @@ pub struct Dispatcher {
replay_cache: std::collections::HashMap<Vec<u8>, u64>, replay_cache: std::collections::HashMap<Vec<u8>, u64>,
roaming_tokens: f64, roaming_tokens: f64,
last_token_regen: std::time::Instant, 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;
#[allow(dead_code)] #[allow(dead_code)]
impl Dispatcher { impl Dispatcher {
pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> Self { pub fn new(machine_config: ProtocolConfig, access_keys: Arc<RwLock<HashMap<String, crate::api::UserMeta>>>) -> Self {
@ -101,9 +122,38 @@ impl Dispatcher {
replay_cache: std::collections::HashMap::new(), replay_cache: std::collections::HashMap::new(),
roaming_tokens: 50.0, roaming_tokens: 50.0,
last_token_regen: std::time::Instant::now(), 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. /// Returns a shared reference to user stats for the Management API.
pub fn user_stats_ref(&self) -> Arc<RwLock<HashMap<String, Arc<UserStats>>>> { pub fn user_stats_ref(&self) -> Arc<RwLock<HashMap<String, Arc<UserStats>>>> {
self.user_stats.clone() self.user_stats.clone()
@ -302,7 +352,22 @@ impl Dispatcher {
} }
} }
// Not an existing session — try each registered access key's derived obfuscation key // Not an existing session — this is the expensive O(N_keys) trial path.
// Gate it behind a token bucket so a garbage/spoofed-source flood cannot
// force unbounded per-packet crypto work. Existing sessions (fast path
// above) and roaming are unaffected. Regenerate at TRIAL_RATE/sec.
{
let now = std::time::Instant::now();
let elapsed = now.duration_since(self.last_trial_regen).as_secs_f64();
self.last_trial_regen = now;
self.trial_tokens = (self.trial_tokens + elapsed * TRIAL_RATE).min(TRIAL_RATE);
if self.trial_tokens < 1.0 {
// Out of budget: drop silently (no response, no state, no log spam).
return Ok(DispatchOutcome::Unauthorized);
}
self.trial_tokens -= 1.0;
}
let keys_snapshot: Vec<String> = self.access_keys.read().unwrap_or_else(|e| e.into_inner()).keys().cloned().collect(); 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 // Junk marker rotates per time window; check the current and previous
@ -311,13 +376,12 @@ impl Dispatcher {
let junk_window = ostp_core::crypto::current_junk_window(); let junk_window = ostp_core::crypto::current_junk_window();
for candidate_key in keys_snapshot { 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 // Junk frames carry this key's time-rotating marker (no global
// constant, no static per-user signature). Drop silently. // constant, no static per-user signature). Drop silently.
if packet.len() >= 4 { if packet.len() >= 4 {
let m_now = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window); let (m_now, m_prev) = self.cached_junk_markers(&candidate_key, junk_window);
let m_prev = ostp_core::crypto::derive_junk_marker(candidate_key.as_bytes(), junk_window.wrapping_sub(1));
if packet[0..4] == m_now || packet[0..4] == m_prev { if packet[0..4] == m_now || packet[0..4] == m_prev {
return Ok(DispatchOutcome::Junk); return Ok(DispatchOutcome::Junk);
} }
@ -461,6 +525,14 @@ impl Dispatcher {
.as_secs(); .as_secs();
self.replay_cache.retain(|_, &mut ts| (current_sys_time as i64 - ts as i64).abs() <= 300); 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 frames = Vec::new();
let mut expired = Vec::new(); let mut expired = Vec::new();
let now = std::time::Instant::now(); let now = std::time::Instant::now();