Skip to main content

hennery_kernel/
ratelimit.rs

1//! Failure-based rate limiting per client address (kernel spec §3.2, §4.1):
2//! a few free failures within a window, then an exponentially growing
3//! lockout.
4//!
5//! - **Every attempt counts as a failure the moment it is made**
6//!   (`attempt`), under the same lock as the lockout check, so a burst of
7//!   parallel requests cannot get more guesses in than the policy allows.
8//!   Only a success (`succeeded`) takes it back, and clears the address.
9//! - **The key is the network, not the socket address:** an IPv4-mapped
10//!   IPv6 address counts as its IPv4 address, and IPv6 addresses count per
11//!   /64, the block one subscriber usually holds.
12//! - **The window runs from the end of the last lockout**, so waiting out a
13//!   lockout does not reset the count; the next wrong attempt doubles it.
14//! - **Bounded, and a live entry is never evicted (amendment 2026-10-01).**
15//!   At most `capacity` addresses are tracked. Past it, forgotten entries are
16//!   pruned first; if the table is still full of live ones, the new
17//!   address's attempt is instead counted against one shared "overflow"
18//!   entry with its own budget and lockout, rather than evicting an
19//!   unrelated address's real count. An earlier version evicted the oldest
20//!   not-locked-out entry (then the oldest of all) to make room — but an
21//!   attacker holding more addresses than `capacity` (a `/52` of IPv6 is
22//!   4097 distinct `/64`s) could rotate through them so that no single
23//!   address ever accumulated enough failures to lock out, turning the
24//!   per-address budget into a throughput limit only. Sharing one overflow
25//!   budget instead means a flood from more addresses than the table holds
26//!   locks *itself* out together and slows pairing for everyone until that
27//!   shared window or lockout passes (or the collector restarts) — a
28//!   deliberate trade-off, not a bug: a single generous per-address budget
29//!   should not be defeatable by rotating addresses.
30//! - **In memory only:** a collector restart clears every count and
31//!   lockout, including the shared overflow entry. Each *tracked* address
32//!   still has its own separate budget; only addresses past capacity share
33//!   the overflow entry's.
34//! - **Loopback is one address with its own entry (3b decision 9).** Every
35//!   `127.0.0.0/8` address counts as `127.0.0.1` (on Linux the whole block
36//!   reaches `lo`, so per-address entries would give a local process
37//!   millions of budgets), and loopback always gets its own entry, past
38//!   capacity too: `hennery up`'s own host and an unconfigured reverse proxy on the same
39//!   machine are never pushed into the shared overflow budget by a flood
40//!   from elsewhere. It is not exempt: its budget is the same as anyone's.
41//!
42//! Callers pass `now`, so the policy is testable without sleeping.
43
44use std::collections::HashMap;
45use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
46use std::sync::Mutex;
47use std::time::{Duration, Instant};
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub struct Policy {
51    /// Failures allowed within `window`; the last of them starts the first
52    /// lockout.
53    pub free_failures: u32,
54    /// Failures are forgotten this long after the last one, or after the
55    /// end of its lockout.
56    pub window: Duration,
57    /// The first lockout; each further failure doubles it.
58    pub first_lockout: Duration,
59    pub max_lockout: Duration,
60}
61
62impl Policy {
63    /// Host enrollment: 5 wrong codes per 10 minutes (kernel spec §4.1).
64    pub const ENROLL: Policy = Policy {
65        free_failures: 5,
66        window: Duration::from_secs(10 * 60),
67        first_lockout: Duration::from_secs(60),
68        max_lockout: Duration::from_secs(60 * 60),
69    };
70
71    /// Operator login and step-up: 5 wrong passwords per minute, then
72    /// exponential backoff (kernel spec §3.2).
73    pub const LOGIN: Policy = Policy {
74        free_failures: 5,
75        window: Duration::from_secs(60),
76        first_lockout: Duration::from_secs(60),
77        max_lockout: Duration::from_secs(60 * 60),
78    };
79
80    /// Passkey logins begun and not finished: 30 a minute, then the same
81    /// backoff (plan 3c decision 8). A budget of its own, so a password
82    /// lockout does not stop passkey login (3b-i's obligation). Every start
83    /// counts, and a page that offers passkeys on load starts one each
84    /// time, hence more than a password's five.
85    pub const PASSKEY_LOGIN: Policy = Policy {
86        free_failures: 30,
87        window: Duration::from_secs(60),
88        first_lockout: Duration::from_secs(60),
89        max_lockout: Duration::from_secs(60 * 60),
90    };
91}
92
93/// Addresses tracked by default.
94pub const DEFAULT_CAPACITY: usize = 4096;
95
96#[derive(Debug, Clone, Copy)]
97struct Entry {
98    failures: u32,
99    last_failure: Instant,
100    locked_until: Option<Instant>,
101}
102
103impl Entry {
104    fn locked(&self, now: Instant) -> bool {
105        self.locked_until.is_some_and(|until| now < until)
106    }
107
108    /// Nothing within `window` of the last failure or, if later, of the end
109    /// of the lockout: waiting out a long lockout does not reset the count.
110    fn forgotten(&self, policy: &Policy, now: Instant) -> bool {
111        let since = self
112            .locked_until
113            .map_or(self.last_failure, |u| u.max(self.last_failure));
114        !self.locked(now) && now.saturating_duration_since(since) >= policy.window
115    }
116}
117
118/// What a client address is counted as: its IPv4 address (also when it
119/// arrives IPv4-mapped), or its IPv6 /64. Every IPv4 loopback address is
120/// `127.0.0.1`, and `::1` is itself.
121pub fn key(addr: IpAddr) -> IpAddr {
122    match addr.to_canonical() {
123        IpAddr::V4(v4) if v4.is_loopback() => IpAddr::V4(Ipv4Addr::LOCALHOST),
124        IpAddr::V4(v4) => IpAddr::V4(v4),
125        // Not masked: its /64 would be `::`, which is not loopback.
126        IpAddr::V6(v6) if v6.is_loopback() => IpAddr::V6(v6),
127        IpAddr::V6(v6) => {
128            let s = v6.segments();
129            IpAddr::V6(Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
130        }
131    }
132}
133
134/// The per-address table plus the one shared budget new addresses fall back
135/// to once it is full of live entries (amendment 2026-10-01).
136struct State {
137    entries: HashMap<IpAddr, Entry>,
138    overflow: Option<Entry>,
139}
140
141pub struct Limiter {
142    policy: Policy,
143    capacity: usize,
144    state: Mutex<State>,
145}
146
147impl Limiter {
148    pub fn new(policy: Policy) -> Self {
149        Self::with_capacity(policy, DEFAULT_CAPACITY)
150    }
151
152    pub fn with_capacity(policy: Policy, capacity: usize) -> Self {
153        Self {
154            policy,
155            capacity: capacity.max(1),
156            state: Mutex::new(State {
157                entries: HashMap::new(),
158                overflow: None,
159            }),
160        }
161    }
162
163    /// Start an attempt from `addr`: `Err(retry_after)` while it (or the
164    /// shared overflow budget it falls back to) is locked out, else the
165    /// attempt is counted as a failure until `succeeded`.
166    pub fn attempt(&self, addr: IpAddr, now: Instant) -> Result<(), Duration> {
167        let policy = self.policy;
168        let addr = key(addr);
169        let mut state = self.state.lock().expect("limiter lock");
170        if let Some(entry) = state.entries.get_mut(&addr) {
171            return record_attempt(entry, &policy, now);
172        }
173        // A new address: forgotten entries are pruned to make room, but a
174        // live one is never evicted for it (amendment 2026-10-01). Loopback
175        // gets its own entry regardless (3b decision 9).
176        state.entries.retain(|_, e| !e.forgotten(&policy, now));
177        if state.entries.len() < self.capacity || addr.is_loopback() {
178            let entry = state.entries.entry(addr).or_insert(Entry {
179                failures: 0,
180                last_failure: now,
181                locked_until: None,
182            });
183            return record_attempt(entry, &policy, now);
184        }
185        // The table is still full of live entries: share the one overflow
186        // budget instead of evicting someone else's real count.
187        let overflow = state.overflow.get_or_insert(Entry {
188            failures: 0,
189            last_failure: now,
190            locked_until: None,
191        });
192        record_attempt(overflow, &policy, now)
193    }
194
195    /// The attempt succeeded: `addr`'s own count and lockout are cleared.
196    /// (An address that was counted against the shared overflow budget
197    /// instead never had its own entry to clear; the overflow budget itself
198    /// is shared and is not reset by any one address's success.)
199    pub fn succeeded(&self, addr: IpAddr) {
200        self.state.lock().expect("limiter lock").entries.remove(&key(addr));
201    }
202
203    /// Forget every address and the overflow budget: a lockout ends now.
204    pub fn clear(&self) {
205        let mut state = self.state.lock().expect("limiter lock");
206        state.entries.clear();
207        state.overflow = None;
208    }
209
210    /// Addresses currently tracked with their own entry (not counting the
211    /// shared overflow budget, which is not itself an address).
212    pub fn tracked(&self) -> usize {
213        self.state.lock().expect("limiter lock").entries.len()
214    }
215}
216
217/// Check `entry`'s lockout first (an attempt while locked out is not
218/// counted again), else count this attempt as a failure — forgetting a
219/// stale count first — and set a fresh lockout if this failure reaches it.
220/// Shared between a per-address `Entry` and the overflow `Entry`: the same
221/// policy applies either way.
222fn record_attempt(entry: &mut Entry, policy: &Policy, now: Instant) -> Result<(), Duration> {
223    if let Some(until) = entry.locked_until
224        && now < until
225    {
226        return Err(until - now);
227    }
228    if entry.forgotten(policy, now) {
229        entry.failures = 0;
230        entry.locked_until = None;
231    }
232    entry.failures += 1;
233    entry.last_failure = now;
234    if entry.failures >= policy.free_failures {
235        let doublings = (entry.failures - policy.free_failures).min(20);
236        let lockout = policy
237            .first_lockout
238            .saturating_mul(1 << doublings)
239            .min(policy.max_lockout);
240        entry.locked_until = Some(now + lockout);
241    }
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::sync::Arc;
249
250    const A: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, 1));
251    const B: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, 2));
252
253    fn secs(s: u64) -> Duration {
254        Duration::from_secs(s)
255    }
256
257    #[test]
258    fn the_fifth_attempt_locks_out_and_each_further_one_doubles_the_lockout() {
259        let limiter = Limiter::new(Policy::ENROLL);
260        let t0 = Instant::now();
261        for _ in 0..5 {
262            assert_eq!(limiter.attempt(A, t0), Ok(()));
263        }
264        assert_eq!(limiter.attempt(A, t0), Err(secs(60)));
265        assert_eq!(limiter.attempt(A, t0 + secs(59)), Err(secs(1)));
266        assert_eq!(limiter.attempt(A, t0 + secs(60)), Ok(()));
267        assert_eq!(limiter.attempt(A, t0 + secs(60)), Err(secs(120)));
268        assert_eq!(limiter.attempt(A, t0 + secs(180)), Ok(()));
269        assert_eq!(limiter.attempt(A, t0 + secs(180)), Err(secs(240)));
270        // Other addresses are not affected.
271        assert_eq!(limiter.attempt(B, t0), Ok(()));
272    }
273
274    #[test]
275    fn a_parallel_burst_gets_no_more_than_the_free_attempts() {
276        let limiter = Arc::new(Limiter::new(Policy::ENROLL));
277        let now = Instant::now();
278        let barrier = Arc::new(std::sync::Barrier::new(32));
279        let threads: Vec<_> = (0..32)
280            .map(|_| {
281                let limiter = limiter.clone();
282                let barrier = barrier.clone();
283                std::thread::spawn(move || {
284                    barrier.wait();
285                    limiter.attempt(A, now).is_ok()
286                })
287            })
288            .collect();
289        let allowed = threads.into_iter().map(|t| t.join().unwrap()).filter(|ok| *ok).count();
290        assert_eq!(allowed, 5);
291    }
292
293    #[test]
294    fn failures_older_than_the_window_are_forgotten() {
295        let limiter = Limiter::new(Policy::ENROLL);
296        let t0 = Instant::now();
297        for _ in 0..4 {
298            limiter.attempt(A, t0).unwrap();
299        }
300        let later = t0 + secs(10 * 60);
301        for _ in 0..4 {
302            assert_eq!(limiter.attempt(A, later), Ok(()));
303        }
304        assert_eq!(limiter.attempt(A, later), Ok(()));
305        assert!(limiter.attempt(A, later).is_err());
306    }
307
308    #[test]
309    fn a_success_clears_the_count_and_the_lockout_is_capped() {
310        let limiter = Limiter::new(Policy::ENROLL);
311        let t0 = Instant::now();
312        for _ in 0..4 {
313            limiter.attempt(A, t0).unwrap();
314        }
315        limiter.succeeded(A);
316        for _ in 0..4 {
317            assert_eq!(limiter.attempt(A, t0), Ok(()));
318        }
319        // Waiting out every lockout, each wrong attempt doubles the next
320        // one, up to the cap.
321        let mut t = t0;
322        let mut longest = Duration::ZERO;
323        for _ in 0..40 {
324            if let Err(wait) = limiter.attempt(A, t) {
325                longest = longest.max(wait);
326                t += wait;
327            }
328        }
329        assert_eq!(longest, secs(60 * 60));
330    }
331
332    #[test]
333    fn an_ipv6_64_is_one_address_and_a_mapped_ipv4_is_its_ipv4() {
334        let limiter = Limiter::new(Policy::ENROLL);
335        let t0 = Instant::now();
336        let v6 = |last: u16| IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 2, 0, 0, 0, last));
337        for last in 1..=5 {
338            limiter.attempt(v6(last), t0).unwrap();
339        }
340        assert!(limiter.attempt(v6(99), t0).is_err(), "same /64");
341        let other_64 = IpAddr::V6(Ipv6Addr::new(0x2001, 0xdb8, 1, 3, 0, 0, 0, 1));
342        assert_eq!(limiter.attempt(other_64, t0), Ok(()));
343
344        for _ in 0..5 {
345            limiter.attempt(A, t0).unwrap();
346        }
347        let mapped = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0xc000, 0x0201));
348        assert_eq!(key(mapped), A);
349        assert!(limiter.attempt(mapped, t0).is_err());
350    }
351
352    #[test]
353    fn past_its_capacity_forgotten_entries_are_pruned_before_anything_else() {
354        let limiter = Limiter::with_capacity(Policy::ENROLL, 3);
355        let t0 = Instant::now();
356        let addr = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, n));
357        for n in 1..=3 {
358            limiter.attempt(addr(n), t0).unwrap();
359        }
360        assert_eq!(limiter.tracked(), 3);
361        // Every entry is forgotten by now: the table has room again without
362        // anything being shared through the overflow budget.
363        let later = t0 + secs(10 * 60);
364        assert_eq!(limiter.attempt(addr(4), later), Ok(()));
365        assert_eq!(limiter.tracked(), 1);
366    }
367
368    /// Amendment 2026-10-01 (round 2): a live (not-yet-forgotten) entry is
369    /// never evicted to make room. Past capacity, a new address's attempt is
370    /// instead counted against one address-agnostic overflow budget, so an
371    /// attacker cannot dodge the per-address limit by rotating through more
372    /// addresses than the table can hold.
373    #[test]
374    fn once_the_table_is_full_of_live_entries_new_addresses_share_one_overflow_budget() {
375        let limiter = Limiter::with_capacity(Policy::ENROLL, 3);
376        let t0 = Instant::now();
377        let live = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, n));
378        for n in 1..=3 {
379            // One failure each: live and not locked, so none is forgotten
380            // and none is locked either.
381            assert_eq!(limiter.attempt(live(n), t0), Ok(()));
382        }
383        assert_eq!(limiter.tracked(), 3);
384
385        // Five different new addresses, each turned away from a full table:
386        // every one of them shares the same overflow entry.
387        let flood = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, n));
388        for n in 1..=5 {
389            assert_eq!(limiter.attempt(flood(n), t0), Ok(()), "overflow attempt {n}");
390        }
391        // The table never grew: no live entry was evicted, and the overflow
392        // budget is not itself a tracked address.
393        assert_eq!(limiter.tracked(), 3);
394        // The sixth address to overflow finds the shared budget locked out
395        // for the policy's own first lockout, even though it never made an
396        // attempt before — the same doubling policy as any per-address entry.
397        assert_eq!(limiter.attempt(flood(6), t0), Err(secs(60)));
398
399        // The three original addresses were never touched by the flood: each
400        // still has exactly its first failure. Four more attempts each (five
401        // total) are still free, and only the sixth locks them out — proving
402        // the count survived, rather than merely returning `Ok` (which a
403        // reset-to-zero entry would also do for its next four attempts).
404        for n in 1..=3 {
405            for _ in 0..4 {
406                assert_eq!(
407                    limiter.attempt(live(n), t0),
408                    Ok(()),
409                    "address {n} was reset by the flood"
410                );
411            }
412            assert!(
413                limiter.attempt(live(n), t0).is_err(),
414                "address {n} was reset by the flood (should be locked after 5 total failures)"
415            );
416        }
417        assert_eq!(limiter.tracked(), 3);
418    }
419
420    /// 3b decision 9 (M3 of 3a's final review): past capacity, loopback
421    /// still gets its own entry, so a flood from elsewhere cannot use up
422    /// `hennery up`'s own budget; and the whole of `127.0.0.0/8` is that
423    /// one entry, so a local process cannot rotate through it.
424    #[test]
425    fn loopback_keeps_one_entry_of_its_own_past_capacity() {
426        let limiter = Limiter::with_capacity(Policy::ENROLL, 1);
427        let t0 = Instant::now();
428        limiter.attempt(A, t0).unwrap();
429        // The table is full of a live entry: a new address overflows.
430        for _ in 0..5 {
431            limiter.attempt(B, t0).unwrap();
432        }
433        assert!(limiter.attempt(B, t0).is_err(), "the overflow budget is used up");
434        let lo = |d: u8| IpAddr::V4(std::net::Ipv4Addr::new(127, d, 0, 1));
435        assert_eq!(key(lo(9)), IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
436        for d in 0..5 {
437            assert_eq!(
438                limiter.attempt(lo(d), t0),
439                Ok(()),
440                "loopback shared the overflow budget"
441            );
442        }
443        assert!(limiter.attempt(lo(200), t0).is_err(), "127/8 is one address");
444        assert_eq!(limiter.tracked(), 2);
445        // `::1` is loopback too, with its own entry.
446        assert_eq!(limiter.attempt(IpAddr::V6(Ipv6Addr::LOCALHOST), t0), Ok(()));
447    }
448
449    /// The all-locked case: every tracked address is already locked out
450    /// (never forgotten, since `forgotten` exempts a locked entry), so
451    /// pruning frees nothing. A flood of new addresses must still never
452    /// evict any of them.
453    #[test]
454    fn locked_entries_are_never_evicted_even_by_a_large_flood() {
455        let limiter = Limiter::with_capacity(Policy::ENROLL, 2);
456        let t0 = Instant::now();
457        let locked = |n: u8| IpAddr::V4(std::net::Ipv4Addr::new(203, 0, 113, n));
458        for n in 1..=2 {
459            for _ in 0..5 {
460                limiter.attempt(locked(n), t0).unwrap();
461            }
462            assert!(limiter.attempt(locked(n), t0).is_err());
463        }
464        assert_eq!(limiter.tracked(), 2);
465
466        let flood = |n: u16| IpAddr::V4(std::net::Ipv4Addr::new(198, 51, 100, (n % 256) as u8));
467        for n in 1..=20 {
468            let _ = limiter.attempt(flood(n), t0);
469            assert_eq!(limiter.tracked(), 2, "a locked entry was evicted at flood attempt {n}");
470        }
471        // Both locked addresses are still locked, undisturbed by the flood.
472        for n in 1..=2 {
473            assert!(limiter.attempt(locked(n), t0).is_err(), "address {n} was reset");
474        }
475    }
476}