mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-09 16:24:45 +00:00
Move single-consumer modules into node/:
- rate_limit.rs, wire.rs, dns.rs — exclusively used by node subsystem
- Reduces top-level lib.rs from 16 to 13 modules
Split large files into focused subdirectories:
- noise.rs (1475 lines) → noise/{mod, handshake, session, replay, tests}.rs
- tree.rs (1479 lines) → tree/{mod, coordinate, declaration, state, tests}.rs
- bloom.rs (849 lines) → bloom/{mod, filter, state, tests}.rs
- All public APIs re-exported from mod.rs, no external import changes
Remove unused rate_limit defaults:
- HANDSHAKE_TIMEOUT_SECS, MAX_PENDING_INBOUND constants
- Default constructor eliminated in favor of with_params() taking config values
Fix all clippy warnings across codebase:
- Remove .clone() on Copy types, collapse nested ifs, replace match-return-None
with ?, remove/gate unused code, fix loop indexing, remove unnecessary casts
- Box large PeerSlot enum variants to reduce size disparity
- cargo clippy --all-targets now reports zero warnings
405 lines
11 KiB
Rust
405 lines
11 KiB
Rust
//! 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
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
/// Rate limiter for handshake message 1 processing.
|
|
///
|
|
/// Combines token bucket rate limiting with connection counting to
|
|
/// protect against DoS attacks on the handshake path.
|
|
#[derive(Debug)]
|
|
pub struct HandshakeRateLimiter {
|
|
/// Token bucket for rate limiting.
|
|
bucket: TokenBucket,
|
|
/// Current count of pending inbound connections.
|
|
pending_count: usize,
|
|
/// Maximum pending inbound connections.
|
|
max_pending: usize,
|
|
}
|
|
|
|
impl HandshakeRateLimiter {
|
|
/// Create a handshake rate limiter with the given parameters.
|
|
pub fn with_params(bucket: TokenBucket, max_pending: usize) -> Self {
|
|
Self {
|
|
bucket,
|
|
pending_count: 0,
|
|
max_pending,
|
|
}
|
|
}
|
|
|
|
/// Check if a new handshake can be started.
|
|
///
|
|
/// Returns `true` if:
|
|
/// - 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) -> bool {
|
|
self.bucket.available() && self.pending_count < self.max_pending
|
|
}
|
|
|
|
/// Start a new handshake, consuming a token and incrementing pending count.
|
|
///
|
|
/// Returns `true` if the handshake was allowed, `false` if rate limited.
|
|
pub fn start_handshake(&mut self) -> bool {
|
|
if self.pending_count >= self.max_pending {
|
|
return false;
|
|
}
|
|
|
|
if self.bucket.try_acquire() {
|
|
self.pending_count += 1;
|
|
true
|
|
} else {
|
|
false
|
|
}
|
|
}
|
|
|
|
/// Mark a handshake as complete (successful or failed).
|
|
///
|
|
/// Decrements the pending connection count.
|
|
pub fn complete_handshake(&mut self) {
|
|
if self.pending_count > 0 {
|
|
self.pending_count -= 1;
|
|
}
|
|
}
|
|
|
|
/// Get the current pending connection count.
|
|
#[cfg(test)]
|
|
pub fn pending_count(&self) -> usize {
|
|
self.pending_count
|
|
}
|
|
|
|
/// Get a reference to the token bucket.
|
|
#[cfg(test)]
|
|
pub fn bucket(&self) -> &TokenBucket {
|
|
&self.bucket
|
|
}
|
|
|
|
/// Reset the rate limiter.
|
|
#[cfg(test)]
|
|
pub fn reset(&mut self) {
|
|
self.bucket.reset();
|
|
self.pending_count = 0;
|
|
}
|
|
}
|
|
|
|
|
|
#[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
|
|
thread::sleep(Duration::from_millis(50)); // Should refill ~5 tokens
|
|
|
|
// Should have tokens now
|
|
let tokens = bucket.tokens();
|
|
assert!((4.0..=6.0).contains(&tokens), "tokens: {}", tokens);
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[test]
|
|
fn test_handshake_rate_limiter_basic() {
|
|
let mut limiter = HandshakeRateLimiter::with_params(TokenBucket::new(), 100);
|
|
|
|
assert!(limiter.can_start_handshake());
|
|
assert_eq!(limiter.pending_count(), 0);
|
|
|
|
// Start a handshake
|
|
assert!(limiter.start_handshake());
|
|
assert_eq!(limiter.pending_count(), 1);
|
|
|
|
// Complete it
|
|
limiter.complete_handshake();
|
|
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 = HandshakeRateLimiter::with_params(bucket, 3);
|
|
|
|
// Start 3 handshakes
|
|
assert!(limiter.start_handshake());
|
|
assert!(limiter.start_handshake());
|
|
assert!(limiter.start_handshake());
|
|
|
|
// Fourth should fail (max pending)
|
|
assert!(!limiter.can_start_handshake());
|
|
assert!(!limiter.start_handshake());
|
|
|
|
// Complete one
|
|
limiter.complete_handshake();
|
|
|
|
// Now should be able to start another
|
|
assert!(limiter.can_start_handshake());
|
|
assert!(limiter.start_handshake());
|
|
}
|
|
|
|
#[test]
|
|
fn test_handshake_rate_limiter_token_exhaustion() {
|
|
let bucket = TokenBucket::with_params(5, 0.0); // No refill
|
|
let mut limiter = HandshakeRateLimiter::with_params(bucket, 100);
|
|
|
|
// Start 5 handshakes (exhausts tokens)
|
|
for _ in 0..5 {
|
|
assert!(limiter.start_handshake());
|
|
}
|
|
|
|
// Complete them all
|
|
for _ in 0..5 {
|
|
limiter.complete_handshake();
|
|
}
|
|
|
|
// Tokens exhausted, even though pending is 0
|
|
assert!(!limiter.can_start_handshake());
|
|
assert!(!limiter.start_handshake());
|
|
}
|
|
|
|
#[test]
|
|
fn test_handshake_rate_limiter_reset() {
|
|
let mut limiter = HandshakeRateLimiter::with_params(TokenBucket::new(), 100);
|
|
|
|
// Start some handshakes
|
|
limiter.start_handshake();
|
|
limiter.start_handshake();
|
|
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);
|
|
}
|
|
}
|