Skip to main content

kipuka/auth/
failure_tracker.rs

1//! Authentication failure tracking and account lockout (NIAP CA PP FIA_AFL.1).
2//!
3//! FIA_AFL.1 requires the TSF to detect when a configurable number of
4//! unsuccessful authentication attempts occur for an authentication event and,
5//! on reaching that threshold, take a defined action.  Kipuka's action is a
6//! time-boxed lockout: once an identity accumulates `max_failures` failures
7//! within `failure_window`, further attempts are refused for
8//! `lockout_duration` regardless of whether the presented credential is valid.
9//!
10//! # Keying
11//!
12//! The tracker keys on the **claimed identity** (e.g. the HTTP Basic username /
13//! OTP entity-id), never on a client-supplied network address.  An attacker
14//! can spoof `X-Forwarded-For` at will, so mixing it into the lockout key would
15//! let them both evade their own lockout and lock out arbitrary victims.  The
16//! claimed identity is the value FIA_AFL.1 is written against ("the user") and
17//! is known even when authentication fails, so it is the correct key.
18//!
19//! # Concurrency and memory
20//!
21//! State is a single `parking_lot::Mutex<HashMap<..>>`.  Records are pruned
22//! opportunistically and capped at 10,000 identities. At capacity, existing
23//! counters and lockouts are retained; new identities continue through credential
24//! verification without acquiring a tracker entry until capacity becomes available.
25//! Capacity exhaustion must never lock out unrelated valid credentials.
26//!
27//! # Disabled mode
28//!
29//! `max_failures == 0` disables the control entirely — every call returns
30//! [`LockoutStatus::Allowed`] and no state is retained.  This preserves the
31//! behavior of deployments that have not opted in.
32
33use std::collections::HashMap;
34use std::time::{Duration, Instant};
35
36use parking_lot::Mutex;
37
38/// Lockout policy parameters (from `[auth_lockout]` config).
39#[derive(Debug, Clone, Copy)]
40pub struct LockoutPolicy {
41    /// Consecutive failures within `failure_window` that trigger a lockout.
42    /// `0` disables the control.
43    pub max_failures: u32,
44    /// Sliding window over which failures are counted.  A gap longer than
45    /// this resets the counter.
46    pub failure_window: Duration,
47    /// How long an identity stays locked out once the threshold is reached.
48    pub lockout_duration: Duration,
49}
50
51impl LockoutPolicy {
52    /// Whether the lockout control is active.
53    pub fn is_enabled(&self) -> bool {
54        self.max_failures > 0
55    }
56}
57
58/// Outcome of a lockout check or a recorded failure.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum LockoutStatus {
61    /// Authentication may proceed.
62    Allowed,
63    /// The identity is locked out; retry after the given duration.
64    LockedOut {
65        /// Time remaining until the lockout expires.
66        retry_after: Duration,
67    },
68}
69
70impl LockoutStatus {
71    /// Whether this status denies the attempt.
72    pub fn is_locked(&self) -> bool {
73        matches!(self, LockoutStatus::LockedOut { .. })
74    }
75}
76
77/// Per-identity failure bookkeeping.
78struct Record {
79    /// Failures counted in the current window.
80    failures: u32,
81    /// Start of the current failure window.
82    window_start: Instant,
83    /// When an active lockout expires, if any.
84    locked_until: Option<Instant>,
85}
86
87impl Record {
88    /// Whether this record can be dropped at `now` (not locked, window stale).
89    fn is_prunable(&self, now: Instant, window: Duration) -> bool {
90        let lock_expired = self.locked_until.is_none_or(|until| now >= until);
91        let window_stale = now.duration_since(self.window_start) >= window;
92        lock_expired && window_stale
93    }
94}
95
96/// Tracks authentication failures and enforces FIA_AFL.1 lockout.
97pub struct FailureTracker {
98    policy: LockoutPolicy,
99    records: Mutex<HashMap<String, Record>>,
100    last_prune: Mutex<Instant>,
101}
102
103impl FailureTracker {
104    /// Create a tracker with the given policy.
105    pub fn new(policy: LockoutPolicy) -> Self {
106        Self {
107            policy,
108            records: Mutex::new(HashMap::new()),
109            last_prune: Mutex::new(Instant::now() - Duration::from_secs(1)),
110        }
111    }
112
113    /// The active policy.
114    pub fn policy(&self) -> &LockoutPolicy {
115        &self.policy
116    }
117
118    /// Check whether `identity` is currently locked out, without recording an
119    /// attempt.  Call this before validating a credential.
120    pub fn check(&self, identity: &str) -> LockoutStatus {
121        if !self.policy.is_enabled() {
122            return LockoutStatus::Allowed;
123        }
124        let now = Instant::now();
125        let mut records = self.records.lock();
126        if records.len() >= 10_000 {
127            let mut last = self.last_prune.lock();
128            if now.duration_since(*last) >= Duration::from_secs(1).min(self.policy.failure_window) {
129                self.prune(&mut records, now);
130                *last = now;
131            }
132        }
133        if identity.len() > 4096 {
134            return LockoutStatus::LockedOut {
135                retry_after: Duration::from_secs(1),
136            };
137        }
138        Self::status_at(records.get(identity), now).unwrap_or_else(|| {
139            // Not locked — opportunistically prune this entry if stale.
140            if let Some(rec) = records.get(identity)
141                && rec.is_prunable(now, self.policy.failure_window)
142            {
143                records.remove(identity);
144            }
145            LockoutStatus::Allowed
146        })
147    }
148
149    /// Record a failed authentication attempt for `identity` and return the
150    /// resulting status.  A return of [`LockoutStatus::LockedOut`] means this
151    /// failure reached (or was already past) the threshold — the caller should
152    /// emit the FIA_AFL security-violation audit event.
153    pub fn record_failure(&self, identity: &str) -> LockoutStatus {
154        if !self.policy.is_enabled() {
155            return LockoutStatus::Allowed;
156        }
157        let now = Instant::now();
158        let mut records = self.records.lock();
159        let mut last = self.last_prune.lock();
160        if now.duration_since(*last) >= Duration::from_secs(1).min(self.policy.failure_window) {
161            self.prune(&mut records, now);
162            *last = now;
163        }
164        if identity.len() > 4096 {
165            return LockoutStatus::LockedOut {
166                retry_after: Duration::from_secs(1),
167            };
168        }
169        if records.len() >= 10_000 && !records.contains_key(identity) {
170            // The credential has already failed verification. Skipping a new
171            // tracker entry bounds memory without denying unrelated valid users
172            // or evicting active targeted lockouts and pending counters.
173            return LockoutStatus::Allowed;
174        }
175
176        let rec = records.entry(identity.to_string()).or_insert(Record {
177            failures: 0,
178            window_start: now,
179            locked_until: None,
180        });
181
182        // Already locked and still within the lockout: report remaining time.
183        if let Some(until) = rec.locked_until
184            && now < until
185        {
186            return LockoutStatus::LockedOut {
187                retry_after: until.duration_since(now),
188            };
189        }
190
191        // Start a fresh window when the previous one has elapsed, or when a
192        // prior lockout has since expired.  Without the lockout-expiry reset, a
193        // deployment whose `lockout_duration` is shorter than `failure_window`
194        // would re-lock an identity on the very first attempt after the lockout
195        // ends — the stale failure count is still at the threshold and the
196        // window has not yet aged out — instead of granting a fresh set of
197        // attempts.  (Execution only reaches here once any active lockout has
198        // expired, since a live lockout returns early above.)
199        let window_elapsed = now.duration_since(rec.window_start) >= self.policy.failure_window;
200        let lockout_expired = rec.locked_until.is_some_and(|until| now >= until);
201        if window_elapsed || lockout_expired {
202            rec.failures = 0;
203            rec.window_start = now;
204            rec.locked_until = None;
205        }
206
207        rec.failures += 1;
208
209        if rec.failures >= self.policy.max_failures {
210            let until = now + self.policy.lockout_duration;
211            rec.locked_until = Some(until);
212            LockoutStatus::LockedOut {
213                retry_after: self.policy.lockout_duration,
214            }
215        } else {
216            LockoutStatus::Allowed
217        }
218    }
219
220    /// Record a successful authentication for `identity`, clearing its failure
221    /// state.  A success does not lift an active lockout — the credential is
222    /// still refused until the lockout expires — so we only drop the record
223    /// when it is not currently locked.
224    pub fn record_success(&self, identity: &str) {
225        if !self.policy.is_enabled() {
226            return;
227        }
228        let now = Instant::now();
229        let mut records = self.records.lock();
230        if let Some(rec) = records.get(identity) {
231            let locked = rec.locked_until.is_some_and(|until| now < until);
232            if !locked {
233                records.remove(identity);
234            }
235        }
236    }
237
238    /// Compute the lockout status for an existing record at `now`, or `None`
239    /// when the identity is not locked.
240    fn status_at(rec: Option<&Record>, now: Instant) -> Option<LockoutStatus> {
241        let rec = rec?;
242        match rec.locked_until {
243            Some(until) if now < until => Some(LockoutStatus::LockedOut {
244                retry_after: until.duration_since(now),
245            }),
246            _ => None,
247        }
248    }
249
250    /// Drop records that are neither locked nor within their failure window.
251    fn prune(&self, records: &mut HashMap<String, Record>, now: Instant) {
252        records.retain(|_, rec| !rec.is_prunable(now, self.policy.failure_window));
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn policy(max: u32) -> LockoutPolicy {
261        LockoutPolicy {
262            max_failures: max,
263            failure_window: Duration::from_secs(60),
264            lockout_duration: Duration::from_secs(300),
265        }
266    }
267
268    #[test]
269    fn disabled_policy_never_locks() {
270        let t = FailureTracker::new(policy(0));
271        for _ in 0..100 {
272            assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
273        }
274        assert_eq!(t.check("bob"), LockoutStatus::Allowed);
275    }
276
277    #[test]
278    fn locks_after_threshold() {
279        let t = FailureTracker::new(policy(3));
280        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
281        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
282        // Third failure hits the threshold.
283        assert!(t.record_failure("bob").is_locked());
284        // Subsequent check reports locked.
285        assert!(t.check("bob").is_locked());
286    }
287
288    #[test]
289    fn other_identities_unaffected() {
290        let t = FailureTracker::new(policy(2));
291        assert!(t.record_failure("bob").is_locked() || t.record_failure("bob").is_locked());
292        // A different identity is independent.
293        assert_eq!(t.check("alice"), LockoutStatus::Allowed);
294        assert_eq!(t.record_failure("alice"), LockoutStatus::Allowed);
295    }
296
297    #[test]
298    fn success_clears_failures_before_lockout() {
299        let t = FailureTracker::new(policy(3));
300        t.record_failure("bob");
301        t.record_failure("bob");
302        // Success resets the counter, so the next two failures do not lock.
303        t.record_success("bob");
304        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
305        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
306        assert!(!t.check("bob").is_locked());
307    }
308
309    #[test]
310    fn success_does_not_lift_active_lockout() {
311        let t = FailureTracker::new(policy(2));
312        t.record_failure("bob");
313        assert!(t.record_failure("bob").is_locked());
314        // A late success (e.g. a racing valid attempt) must not unlock.
315        t.record_success("bob");
316        assert!(t.check("bob").is_locked());
317    }
318
319    #[test]
320    fn window_reset_allows_slow_failures() {
321        // Tight window so failures age out immediately.
322        let pol = LockoutPolicy {
323            max_failures: 2,
324            failure_window: Duration::from_millis(1),
325            lockout_duration: Duration::from_secs(300),
326        };
327        let t = FailureTracker::new(pol);
328        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
329        std::thread::sleep(Duration::from_millis(3));
330        // Window elapsed — counter resets, so this is failure #1 again.
331        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
332    }
333
334    #[test]
335    fn lockout_expires() {
336        let pol = LockoutPolicy {
337            max_failures: 1,
338            failure_window: Duration::from_secs(60),
339            lockout_duration: Duration::from_millis(5),
340        };
341        let t = FailureTracker::new(pol);
342        assert!(t.record_failure("bob").is_locked());
343        assert!(t.check("bob").is_locked());
344        std::thread::sleep(Duration::from_millis(8));
345        // Lockout has expired.
346        assert_eq!(t.check("bob"), LockoutStatus::Allowed);
347    }
348
349    #[test]
350    fn expired_lockout_starts_fresh_window() {
351        // Misconfiguration guard: lockout shorter than the failure window.
352        // After the lockout expires, the first failure must not immediately
353        // re-lock — the identity gets a fresh count within the open window.
354        let pol = LockoutPolicy {
355            max_failures: 2,
356            failure_window: Duration::from_secs(60),
357            lockout_duration: Duration::from_millis(5),
358        };
359        let t = FailureTracker::new(pol);
360        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
361        assert!(t.record_failure("bob").is_locked());
362        std::thread::sleep(Duration::from_millis(8));
363        // Lockout expired but the 60s window has not: a fresh count, not a re-lock.
364        assert_eq!(t.record_failure("bob"), LockoutStatus::Allowed);
365    }
366
367    #[test]
368    fn prunes_stale_records() {
369        let pol = LockoutPolicy {
370            max_failures: 5,
371            failure_window: Duration::from_millis(1),
372            lockout_duration: Duration::from_secs(300),
373        };
374        let t = FailureTracker::new(pol);
375        t.record_failure("ephemeral");
376        std::thread::sleep(Duration::from_millis(3));
377        // A mutating call for another identity prunes the stale entry.
378        t.record_failure("other");
379        assert!(!t.records.lock().contains_key("ephemeral"));
380    }
381}
382
383#[cfg(test)]
384mod capacity_tests {
385    use super::*;
386
387    #[test]
388    fn saturation_preserves_access_and_existing_targeted_lockouts() {
389        let tracker = FailureTracker::new(LockoutPolicy {
390            max_failures: 2,
391            failure_window: Duration::from_secs(300),
392            lockout_duration: Duration::from_secs(900),
393        });
394        tracker.record_failure("target");
395        for i in 1..10_000 {
396            tracker.record_failure(&format!("invented-{i}"));
397        }
398        assert_eq!(tracker.check("valid-user"), LockoutStatus::Allowed);
399        assert_eq!(tracker.record_failure("overflow"), LockoutStatus::Allowed);
400        assert_eq!(tracker.records.lock().len(), 10_000);
401        assert!(tracker.record_failure("target").is_locked());
402        assert!(tracker.check("target").is_locked());
403        tracker.record_success("target");
404        assert!(tracker.check("target").is_locked());
405        // Clearing an unlocked record admits new failure tracking again.
406        tracker.record_success("invented-1");
407        tracker.record_failure("overflow");
408        assert!(tracker.record_failure("overflow").is_locked());
409        assert_eq!(tracker.records.lock().len(), 10_000);
410    }
411}