//! Rate Limiting for FIPS Protocol //! //! Provides token bucket rate limiting for protecting against DoS attacks, //! particularly on the Noise handshake path where msg1 processing involves //! expensive cryptographic operations. //! //! ## Design //! //! - Token bucket algorithm with configurable burst and refill rate //! - Global rate limit (not per-source, since UDP sources are spoofable) //! - Applied before expensive DH operations in handshake processing //! //! ## Default Parameters //! //! - Burst capacity: 100 tokens (max concurrent handshakes) //! - Refill rate: 10 tokens/second (sustained handshake rate) //! - This allows handling burst traffic while limiting sustained attack impact //! //! ## Two buckets, and what the aggregate is //! //! Msg1 whose source matches an established link (rekey and restart //! maintenance traffic) draws on its own bucket rather than competing with //! stranger admission. It is *metered*, not exempted: an established-peer //! carve-out is by construction keyed on source address, and the sentence //! above about spoofable UDP sources is still true, so an off-path attacker //! who can forge a live peer's `(transport_id, addr)` tuple reaches the //! second bucket. Metering keeps that exposure bounded. //! //! The consequence, stated rather than left implicit: the node's total //! admitted msg1 rate is the **sum** of the two buckets, not the stranger //! bucket alone. At shipped defaults that is burst `100 + 128 = 228` and //! `10.0 + 6.4 = 16.4` msg1/sec, of which the established half is only //! reachable by a source that already matches a live link. Operators sizing //! the handshake-crypto ceiling against a host should size against the sum. //! //! The concurrency limb (`max_pending`) is deliberately *not* split, so no //! equivalent inflation happens there: one counter bounds simultaneous //! in-flight handshake state whoever holds the slot. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Instant; /// Default burst capacity (max tokens). pub const DEFAULT_BURST_CAPACITY: u32 = 100; /// Default refill rate (tokens per second). pub const DEFAULT_REFILL_RATE: f64 = 10.0; /// Token bucket rate limiter. /// /// Uses a classic token bucket algorithm where tokens are consumed for each /// operation and refilled at a constant rate. When tokens are exhausted, /// operations are rate-limited until tokens refill. #[derive(Debug, Clone)] pub struct TokenBucket { /// Maximum number of tokens (burst capacity). capacity: u32, /// Current number of available tokens (may be fractional during refill). tokens: f64, /// Tokens added per second. refill_rate: f64, /// Last time tokens were refilled. last_refill: Instant, } impl TokenBucket { /// Create a new token bucket with default parameters. /// /// - Burst capacity: 100 tokens /// - Refill rate: 10 tokens/second pub fn new() -> Self { Self::with_params(DEFAULT_BURST_CAPACITY, DEFAULT_REFILL_RATE) } /// Create a token bucket with custom parameters. /// /// # Arguments /// /// * `capacity` - Maximum number of tokens (burst capacity) /// * `refill_rate` - Tokens added per second pub fn with_params(capacity: u32, refill_rate: f64) -> Self { Self { capacity, tokens: capacity as f64, refill_rate, last_refill: Instant::now(), } } /// Try to consume one token. /// /// Returns `true` if a token was available and consumed, `false` if /// rate limited (no tokens available). pub fn try_acquire(&mut self) -> bool { self.try_acquire_n(1) } /// Try to consume n tokens. /// /// Returns `true` if n tokens were available and consumed, `false` if /// rate limited (insufficient tokens). pub fn try_acquire_n(&mut self, n: u32) -> bool { self.refill(); if self.tokens >= n as f64 { self.tokens -= n as f64; true } else { false } } /// Check if tokens are available without consuming them. #[cfg(test)] pub fn available(&mut self) -> bool { self.refill(); self.tokens >= 1.0 } /// Get the current number of available tokens. #[cfg(test)] pub fn tokens(&mut self) -> f64 { self.refill(); self.tokens } /// Get the capacity (max tokens). #[cfg(test)] pub fn capacity(&self) -> u32 { self.capacity } /// Refill tokens based on elapsed time. fn refill(&mut self) { let now = Instant::now(); let elapsed = now.duration_since(self.last_refill); let elapsed_secs = elapsed.as_secs_f64(); // Add tokens based on time elapsed self.tokens += elapsed_secs * self.refill_rate; // Cap at capacity if self.tokens > self.capacity as f64 { self.tokens = self.capacity as f64; } self.last_refill = now; } /// Reset to full capacity. #[cfg(test)] pub fn reset(&mut self) { self.tokens = self.capacity as f64; self.last_refill = Instant::now(); } /// Time until the next token is available. /// /// Returns `Duration::ZERO` if tokens are available, otherwise the /// estimated time until one token will be available. #[cfg(test)] pub fn time_until_available(&mut self) -> std::time::Duration { self.refill(); if self.tokens >= 1.0 { std::time::Duration::ZERO } else { let needed = 1.0 - self.tokens; let secs = needed / self.refill_rate; std::time::Duration::from_secs_f64(secs) } } } impl Default for TokenBucket { fn default() -> Self { Self::new() } } /// Floor on the derived established-link refill rate, in tokens/second. /// /// Covers the degenerate case of a very long (or effectively disabled) /// rekey period, where a node must still admit restart msg1 at some rate. pub const ESTABLISHED_RATE_FLOOR: f64 = 1.0; /// Which bucket an inbound msg1 draws on. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Msg1Class { /// No established link matches the source `(transport_id, addr)`. Stranger, /// The source matches an established link: rekey or restart traffic. EstablishedLink, } /// Why an inbound msg1 was refused by the limiter. /// /// `start_handshake` refuses on either limb and the caller's log line is /// blind to which without this. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Msg1Refusal { /// The shared in-flight handshake count is at `max_pending`. PendingLimit, /// The class's token bucket is empty. RateLimit, } impl Msg1Refusal { /// Stable field value for structured logs. pub fn as_str(self) -> &'static str { match self { Msg1Refusal::PendingLimit => "pending_limit", Msg1Refusal::RateLimit => "rate_limit", } } } impl std::fmt::Display for Msg1Refusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.as_str()) } } /// An in-flight handshake's pending slot, released on drop. /// /// The slot is the limiter's concurrency limb. Releasing it by hand from /// every exit path of a long handler is the shape that lets one path free /// a slot belonging to a *different* handshake, silently lifting effective /// concurrency above `max_pending` with no counter moving and no log /// firing. Holding the release in `Drop` makes that structurally /// impossible. /// /// Worth knowing when reading `max_pending`: today the count cannot /// exceed 1 in production. `start_handshake` is reached only from /// `handle_msg1`, whose sole production caller awaits it to completion /// inside `process_packet(&mut self)`, itself awaited serially in the rx /// loop, so no two msg1 handlers are ever in flight at once. The /// concurrency limb is therefore a structural invariant rather than a /// limiter that currently binds, and `Msg1Refusal::PendingLimit` does not /// fire in the field. The guard is what keeps that invariant true as the /// handler grows exit paths, and what makes it safe for different msg1 /// classes to take slots on different terms. /// /// The atomic is for `Send`-ness, not cross-thread coordination: the guard /// is held across `.await` points inside `handle_msg1`, and a /// `Rc>` would make every future containing it non-`Send`. #[must_use = "binding the slot to a named local is what holds it; \ dropping it immediately releases it straight away"] #[derive(Debug)] pub struct PendingHandshake { pending: Arc, } impl Drop for PendingHandshake { fn drop(&mut self) { let _ = self .pending .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| n.checked_sub(1)); } } /// Derive the established-link bucket's parameters from configuration. /// /// Every input is a value the operator already sets, so raising `max_peers` /// moves this automatically rather than leaving a bare constant that nobody /// revisits. /// /// - **burst = `max_peers`.** The worst *legitimate* burst on this bucket is /// every established peer re-handshaking at once (local restart, partition /// heal), and that population is bounded by `max_peers` by construction, at /// one token per msg1. /// - **rate = `(max_peers / max(rekey_after_secs, 1)) * (1 + max_resends)`,** /// floored at [`ESTABLISHED_RATE_FLOOR`]. The first factor is the /// steady-state inbound rekey rate; the second is the worst case where /// every attempt consumes its full retransmission budget. /// /// `max_peers == 0` means unlimited, for which no peer-count-derived size /// exists; the stranger bucket's own parameters are returned so an /// unlimited-peers node gets a second bucket the same size as its first /// rather than a bucket of one token. /// /// `rekey.enabled` is deliberately ignored: restart and reconnect msg1 exist /// regardless, and branching on it would make the disabled-rekey /// configuration the under-provisioned one. pub fn derive_established_bucket( max_peers: usize, rekey_after_secs: u64, max_resends: u32, stranger_burst: u32, stranger_rate: f64, ) -> (u32, f64) { if max_peers == 0 { return (stranger_burst, stranger_rate); } let burst = u32::try_from(max_peers).unwrap_or(u32::MAX); let period = rekey_after_secs.max(1) as f64; let rate = (max_peers as f64 / period) * (1.0 + f64::from(max_resends)); (burst, rate.max(ESTABLISHED_RATE_FLOOR)) } /// Rate limiter for handshake message 1 processing. /// /// Combines token bucket rate limiting with connection counting to /// protect against DoS attacks on the handshake path. The rate limb is /// split by [`Msg1Class`]; the concurrency limb is shared (see the module /// doc for why, and for what the aggregate rate becomes). #[derive(Debug)] pub struct HandshakeRateLimiter { /// Token bucket for stranger msg1. bucket: TokenBucket, /// Token bucket for established-link msg1 (rekey / restart). established: TokenBucket, /// Current count of pending inbound connections, shared with the /// outstanding [`PendingHandshake`] guards. pending: Arc, /// Maximum pending inbound connections. max_pending: usize, } impl HandshakeRateLimiter { /// Create a handshake rate limiter with the given parameters. pub fn with_params(bucket: TokenBucket, established: TokenBucket, max_pending: usize) -> Self { Self { bucket, established, pending: Arc::new(AtomicUsize::new(0)), max_pending, } } /// Check if a new handshake of `class` can be started. /// /// Returns `true` if: /// - The class's token bucket has available tokens (rate limit not exceeded) /// - Pending connection count is below maximum /// /// Does NOT consume a token - call `start_handshake` for that. #[cfg(test)] pub fn can_start_handshake(&mut self, class: Msg1Class) -> bool { self.bucket_for(class).available() && self.pending.load(Ordering::Relaxed) < self.max_pending } /// Start a new handshake, consuming a token and taking a pending slot. /// /// The returned guard releases the slot when it drops. On refusal the /// [`Msg1Refusal`] says which limb refused. pub fn start_handshake(&mut self, class: Msg1Class) -> Result { if self.pending.load(Ordering::Relaxed) >= self.max_pending { return Err(Msg1Refusal::PendingLimit); } if !self.bucket_for(class).try_acquire() { return Err(Msg1Refusal::RateLimit); } self.pending.fetch_add(1, Ordering::Relaxed); Ok(PendingHandshake { pending: Arc::clone(&self.pending), }) } fn bucket_for(&mut self, class: Msg1Class) -> &mut TokenBucket { match class { Msg1Class::Stranger => &mut self.bucket, Msg1Class::EstablishedLink => &mut self.established, } } /// Get the current pending connection count. #[cfg(test)] pub fn pending_count(&self) -> usize { self.pending.load(Ordering::Relaxed) } /// Get a reference to the stranger token bucket. #[cfg(test)] pub fn bucket(&self) -> &TokenBucket { &self.bucket } /// Get a reference to the established-link token bucket. #[cfg(test)] pub fn established_bucket(&self) -> &TokenBucket { &self.established } /// Reset the rate limiter. /// /// Does not affect slots held by live [`PendingHandshake`] guards; they /// still decrement on drop, saturating at zero. #[cfg(test)] pub fn reset(&mut self) { self.bucket.reset(); self.established.reset(); self.pending.store(0, Ordering::Relaxed); } } #[cfg(test)] mod tests { use super::*; use std::thread; use std::time::Duration; #[test] fn test_token_bucket_basic() { let mut bucket = TokenBucket::with_params(10, 1.0); // Should have full capacity assert_eq!(bucket.capacity(), 10); assert!(bucket.tokens() >= 9.9); // Allow for timing // Consume all tokens for _ in 0..10 { assert!(bucket.try_acquire()); } // Should be empty assert!(!bucket.try_acquire()); assert!(!bucket.available()); } #[test] fn test_token_bucket_refill() { let mut bucket = TokenBucket::with_params(10, 100.0); // 100 tokens/sec // Drain completely for _ in 0..10 { bucket.try_acquire(); } assert!(!bucket.available()); // Wait for refill, measuring actual elapsed time to avoid sensitivity // to OS scheduler variance (sleep can overshoot by a large margin). let before = Instant::now(); thread::sleep(Duration::from_millis(50)); let elapsed_secs = before.elapsed().as_secs_f64(); // Expected tokens = elapsed * rate, capped at capacity. // Allow ±20% tolerance around the actual elapsed time. let expected = (elapsed_secs * 100.0).min(10.0); let lo = (expected * 0.8).min(expected - 0.5).max(0.0); let hi = (expected * 1.2).max(expected + 0.5).min(10.0); let tokens = bucket.tokens(); assert!( (lo..=hi).contains(&tokens), "tokens: {}, expected ~{:.2} (range {:.2}..={:.2})", tokens, expected, lo, hi ); } #[test] fn test_token_bucket_try_acquire_n() { let mut bucket = TokenBucket::with_params(10, 1.0); // Acquire 5 assert!(bucket.try_acquire_n(5)); assert!(bucket.tokens() >= 4.9 && bucket.tokens() <= 5.1); // Acquire 5 more assert!(bucket.try_acquire_n(5)); // Can't acquire more assert!(!bucket.try_acquire_n(1)); } #[test] fn test_token_bucket_reset() { let mut bucket = TokenBucket::with_params(10, 1.0); // Drain for _ in 0..10 { bucket.try_acquire(); } // Reset bucket.reset(); // Should be full again assert!(bucket.tokens() >= 9.9); } #[test] fn test_token_bucket_time_until_available() { let mut bucket = TokenBucket::with_params(10, 10.0); // 10 tokens/sec // When full, should be zero assert_eq!(bucket.time_until_available(), Duration::ZERO); // Drain completely for _ in 0..10 { bucket.try_acquire(); } // Should need ~100ms for one token at 10/sec let wait = bucket.time_until_available(); assert!(wait.as_millis() >= 90 && wait.as_millis() <= 110); } fn test_limiter(bucket: TokenBucket, max_pending: usize) -> HandshakeRateLimiter { HandshakeRateLimiter::with_params( bucket, TokenBucket::with_params(1000, 100.0), max_pending, ) } #[test] fn test_handshake_rate_limiter_basic() { let mut limiter = test_limiter(TokenBucket::new(), 100); assert!(limiter.can_start_handshake(Msg1Class::Stranger)); assert_eq!(limiter.pending_count(), 0); // Start a handshake let slot = limiter.start_handshake(Msg1Class::Stranger).unwrap(); assert_eq!(limiter.pending_count(), 1); // Complete it drop(slot); assert_eq!(limiter.pending_count(), 0); } #[test] fn test_handshake_rate_limiter_max_pending() { let bucket = TokenBucket::with_params(1000, 100.0); let mut limiter = test_limiter(bucket, 3); // Start 3 handshakes let a = limiter.start_handshake(Msg1Class::Stranger).unwrap(); let _b = limiter.start_handshake(Msg1Class::Stranger).unwrap(); let _c = limiter.start_handshake(Msg1Class::Stranger).unwrap(); // Fourth should fail (max pending) assert!(!limiter.can_start_handshake(Msg1Class::Stranger)); assert_eq!( limiter.start_handshake(Msg1Class::Stranger).unwrap_err(), Msg1Refusal::PendingLimit ); // Complete one drop(a); // Now should be able to start another assert!(limiter.can_start_handshake(Msg1Class::Stranger)); assert!(limiter.start_handshake(Msg1Class::Stranger).is_ok()); } #[test] fn test_handshake_rate_limiter_token_exhaustion() { let bucket = TokenBucket::with_params(5, 0.0); // No refill let mut limiter = test_limiter(bucket, 100); // Start 5 handshakes (exhausts tokens), releasing each immediately for _ in 0..5 { let slot = limiter.start_handshake(Msg1Class::Stranger).unwrap(); drop(slot); } // Tokens exhausted, even though pending is 0 assert_eq!(limiter.pending_count(), 0); assert!(!limiter.can_start_handshake(Msg1Class::Stranger)); assert_eq!( limiter.start_handshake(Msg1Class::Stranger).unwrap_err(), Msg1Refusal::RateLimit ); } #[test] fn test_handshake_rate_limiter_reset() { let mut limiter = test_limiter(TokenBucket::new(), 100); // Start some handshakes let _a = limiter.start_handshake(Msg1Class::Stranger).unwrap(); let _b = limiter.start_handshake(Msg1Class::Stranger).unwrap(); assert_eq!(limiter.pending_count(), 2); // Reset limiter.reset(); assert_eq!(limiter.pending_count(), 0); assert!(limiter.bucket().tokens >= DEFAULT_BURST_CAPACITY as f64 - 0.1); } /// The pending slot is released by `Drop`, not by any reachable manual /// call, and the release saturates at zero rather than underflowing. #[test] fn pending_slot_releases_on_drop_and_saturates() { let mut limiter = test_limiter(TokenBucket::with_params(100, 100.0), 100); let outer = limiter.start_handshake(Msg1Class::Stranger).unwrap(); { let _inner = limiter.start_handshake(Msg1Class::Stranger).unwrap(); assert_eq!(limiter.pending_count(), 2); } assert_eq!( limiter.pending_count(), 1, "inner guard released its own slot" ); // A reset zeroes the counter while a guard is still live; the // guard's later drop must not underflow it. limiter.reset(); assert_eq!(limiter.pending_count(), 0); drop(outer); assert_eq!(limiter.pending_count(), 0, "release saturates at zero"); } /// The refusal reason discriminates the two limbs. This is what the /// `refused_by` log field in `handle_msg1` carries; the log line itself /// is not asserted here. #[test] fn refusal_reason_names_the_limb() { // Pending limb: max_pending 1, one live guard, tokens plentiful. let mut pending_bound = test_limiter(TokenBucket::with_params(1000, 100.0), 1); let _held = pending_bound.start_handshake(Msg1Class::Stranger).unwrap(); assert_eq!( pending_bound .start_handshake(Msg1Class::Stranger) .unwrap_err(), Msg1Refusal::PendingLimit ); // Rate limb: pending headroom, zero-capacity/zero-refill bucket. let mut rate_bound = test_limiter(TokenBucket::with_params(0, 0.0), 1000); assert_eq!( rate_bound.start_handshake(Msg1Class::Stranger).unwrap_err(), Msg1Refusal::RateLimit ); assert_eq!(Msg1Refusal::PendingLimit.to_string(), "pending_limit"); assert_eq!(Msg1Refusal::RateLimit.to_string(), "rate_limit"); } /// The two classes draw on separate buckets: draining one leaves the /// other untouched. #[test] fn msg1_classes_draw_on_separate_buckets() { let mut limiter = HandshakeRateLimiter::with_params( TokenBucket::with_params(1, 0.0), TokenBucket::with_params(3, 0.0), 1000, ); drop(limiter.start_handshake(Msg1Class::Stranger).unwrap()); assert_eq!( limiter.start_handshake(Msg1Class::Stranger).unwrap_err(), Msg1Refusal::RateLimit, "stranger bucket drained" ); for _ in 0..3 { drop( limiter .start_handshake(Msg1Class::EstablishedLink) .expect("established bucket is independent of the stranger bucket"), ); } assert_eq!( limiter .start_handshake(Msg1Class::EstablishedLink) .unwrap_err(), Msg1Refusal::RateLimit, "established bucket drains on its own terms" ); } #[test] fn derive_established_bucket_from_shipped_defaults() { // max_peers 128, rekey.after_secs 120, handshake_max_resends 5. let (burst, rate) = derive_established_bucket(128, 120, 5, 100, 10.0); assert_eq!(burst, 128); assert!( (rate - 6.4).abs() < 1e-9, "expected 6.4 tokens/sec, got {rate}" ); // Scales with max_peers rather than needing a revisit. let (burst, rate) = derive_established_bucket(512, 120, 5, 100, 10.0); assert_eq!(burst, 512); assert!( (rate - 25.6).abs() < 1e-9, "expected 25.6 tokens/sec, got {rate}" ); } /// `after_secs = 0` must not divide by zero. It is clamped to 1s, which /// yields a *large* rate, not the floor — the floor binds at the other /// end, for a very long rekey period. Both ends are asserted here /// because the two are easy to conflate. #[test] fn derive_established_bucket_degenerate_rekey_periods() { let (burst, rate) = derive_established_bucket(128, 0, 5, 100, 10.0); assert_eq!(burst, 128); assert!(rate.is_finite(), "after_secs = 0 must not divide by zero"); assert_eq!( rate, derive_established_bucket(128, 1, 5, 100, 10.0).1, "after_secs = 0 is clamped to 1s" ); assert!( rate > ESTABLISHED_RATE_FLOOR, "a zero rekey period is the high end, not the floor" ); // The floor binds for a very long / effectively disabled period. let (_, rate) = derive_established_bucket(1, 100_000, 5, 100, 10.0); assert_eq!(rate, ESTABLISHED_RATE_FLOOR); } /// `max_peers == 0` means unlimited. Deriving a burst from it would /// yield a zero- or one-token bucket, which is worse than the bug this /// second bucket exists to fix; the stranger parameters are reused. #[test] fn derive_established_bucket_unlimited_peers_reuses_stranger_params() { let (burst, rate) = derive_established_bucket(0, 120, 5, 100, 10.0); assert_eq!(burst, 100); assert_eq!(rate, 10.0); } }