mirror of https://github.com/ospab/ostp.git
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:
parent
29554a71f1
commit
f904695760
|
|
@ -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],
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue