Skip to main content

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
106    /// Number of per-connection entries held (for tests).
107    #[cfg(test)]
108    pub fn entries(&self) -> usize {
109        self.conns.len()
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn a_connection_is_blocked_after_its_limit_and_not_before() {
119        let mut l = FailLimiter::new();
120        let t0 = Instant::now();
121        for _ in 0..CONN_MAX_FAILS - 1 {
122            l.record_failure(1, t0);
123        }
124        assert!(!l.refuses_unverified(1, t0));
125        l.record_failure(1, t0);
126        assert!(l.refuses_unverified(1, t0));
127        assert!(
128            !l.refuses_unverified(2, t0),
129            "another connection is not blocked by this one"
130        );
131    }
132
133    #[test]
134    fn the_block_lapses_after_block_duration() {
135        let mut l = FailLimiter::new();
136        let t0 = Instant::now();
137        for _ in 0..CONN_MAX_FAILS {
138            l.record_failure(1, t0);
139        }
140        assert!(l.refuses_unverified(1, t0 + BLOCK - Duration::from_millis(1)));
141        assert!(!l.refuses_unverified(1, t0 + BLOCK));
142    }
143
144    #[test]
145    fn failures_outside_the_window_do_not_add_up() {
146        let mut l = FailLimiter::new();
147        let t0 = Instant::now();
148        for i in 0..(CONN_MAX_FAILS * 3) {
149            l.record_failure(1, t0 + WINDOW * i);
150        }
151        assert!(!l.refuses_unverified(1, t0 + WINDOW * (CONN_MAX_FAILS * 3)));
152    }
153
154    #[test]
155    fn the_global_bucket_refuses_unverified_attempts_from_every_connection() {
156        let mut l = FailLimiter::new();
157        let t0 = Instant::now();
158        for conn in 0..GLOBAL_MAX_FAILS as u64 {
159            l.record_failure(conn, t0);
160        }
161        assert!(
162            l.refuses_unverified(10_000, t0),
163            "a fresh connection's failed secret is refused too"
164        );
165        assert!(!l.refuses_unverified(10_000, t0 + BLOCK));
166    }
167
168    #[test]
169    fn a_connection_block_does_not_count_toward_the_global_bucket() {
170        let mut l = FailLimiter::new();
171        let t0 = Instant::now();
172        // One connection trips its own bucket: 5 failures, global count only 5 of 30.
173        for _ in 0..CONN_MAX_FAILS {
174            l.record_failure(1, t0);
175        }
176        assert!(l.refuses_unverified(1, t0));
177        assert!(
178            !l.refuses_unverified(2, t0),
179            "one peer's bad secrets do not refuse other peers"
180        );
181    }
182
183    #[test]
184    fn forget_drops_an_idle_entry_only() {
185        let mut l = FailLimiter::new();
186        let t0 = Instant::now();
187        l.record_failure(1, t0);
188        assert_eq!(l.entries(), 1);
189        // Inside the window the entry still counts, so it is kept.
190        l.forget(1, t0);
191        assert_eq!(l.entries(), 1);
192        // Once the window has passed it is idle and goes.
193        l.forget(1, t0 + WINDOW);
194        assert_eq!(l.entries(), 0);
195    }
196}