rightkit_daemon/fail_limit.rs
1//! Failed-authentication limiter (issue #504), so a caller guessing a per-run secret cannot keep
2//! reconnecting to hammer the host. Ported from MailRight's `agent/fail_limit.rs`; the values
3//! are the ones that file uses (the issue text says 5 failures in 60 s for both buckets; the
4//! source's global bucket is 30, and the constant below keeps the source's value).
5//!
6//! Two buckets: one per connection (5 failures in 60 s blocks that connection for 60 s) and one
7//! across all connections (a caller that reconnects after every refusal still hits it). Both
8//! count failed secrets only. The caller consults the limiter only after a secret has failed to
9//! verify, through [`FailLimiter::refuses_unverified`]. A correctly authenticated hello or request
10//! is never checked here, so a block caused by other callers cannot refuse it or end its
11//! connection. The clock is passed in (`now`), never read here, so tests inject time. Bounded: at
12//! most [`MAX_ENTRIES`] per-connection entries.
13
14use std::collections::HashMap;
15use std::time::{Duration, Instant};
16
17/// Length of the failure window.
18pub const WINDOW: Duration = Duration::from_secs(60);
19/// How long a bucket stays blocked once its limit trips.
20pub const BLOCK: Duration = Duration::from_secs(60);
21/// Failures from one connection inside [`WINDOW`] that block that connection.
22pub const CONN_MAX_FAILS: u32 = 5;
23/// Failures from all connections inside [`WINDOW`] that block every connection.
24pub const GLOBAL_MAX_FAILS: u32 = 30;
25/// Most per-connection entries kept at once.
26pub const MAX_ENTRIES: usize = 256;
27
28#[derive(Default)]
29struct Bucket {
30 start: Option<Instant>,
31 fails: u32,
32 blocked_until: Option<Instant>,
33}
34
35impl Bucket {
36 fn blocked(&self, now: Instant) -> bool {
37 self.blocked_until.is_some_and(|t| now < t)
38 }
39
40 fn window_open(&self, now: Instant) -> bool {
41 self.start
42 .is_some_and(|s| now.saturating_duration_since(s) < WINDOW)
43 }
44
45 /// Nothing to remember: not blocked and no failure inside the window.
46 fn idle(&self, now: Instant) -> bool {
47 !self.blocked(now) && !self.window_open(now)
48 }
49
50 fn fail(&mut self, now: Instant, max: u32) {
51 if !self.window_open(now) {
52 self.start = Some(now);
53 self.fails = 0;
54 }
55 self.fails += 1;
56 if self.fails >= max {
57 self.blocked_until = Some(now + BLOCK);
58 self.start = None;
59 self.fails = 0;
60 }
61 }
62}
63
64/// The limiter. One instance per host, shared by its connection threads behind a lock.
65#[derive(Default)]
66pub struct FailLimiter {
67 conns: HashMap<u64, Bucket>,
68 global: Bucket,
69}
70
71impl FailLimiter {
72 pub fn new() -> Self {
73 Self::default()
74 }
75
76 /// True when a secret that did not verify from `conn` must be refused at `now`: this
77 /// connection is blocked, or all callers together are. Call this only for an attempt whose
78 /// secret failed to verify; never for one that verified.
79 pub fn refuses_unverified(&self, conn: u64, now: Instant) -> bool {
80 self.global.blocked(now) || self.conns.get(&conn).is_some_and(|b| b.blocked(now))
81 }
82
83 /// Record one failed authentication (a secret that did not verify) from `conn`.
84 pub fn record_failure(&mut self, conn: u64, now: Instant) {
85 self.global.fail(now, GLOBAL_MAX_FAILS);
86 if !self.conns.contains_key(&conn) && self.conns.len() >= MAX_ENTRIES {
87 self.conns.retain(|_, b| !b.idle(now));
88 if self.conns.len() >= MAX_ENTRIES {
89 // Table full of live entries: the global bucket still counts.
90 return;
91 }
92 }
93 self.conns
94 .entry(conn)
95 .or_default()
96 .fail(now, CONN_MAX_FAILS);
97 }
98
99 /// A connection closed. Its entry is dropped unless a block is still running.
100 pub fn forget(&mut self, conn: u64, now: Instant) {
101 if self.conns.get(&conn).is_some_and(|b| b.idle(now)) {
102 self.conns.remove(&conn);
103 }
104 }
105}