kanidmd_lib/credential/
softlock.rs

1//! Represents a temporary denial of the credential to authenticate. This is used
2//! to ratelimit and prevent bruteforcing of accounts. At an initial failure the
3//! SoftLock is created and the count set to 1, with a unlock_at set to 1 second
4//! later, and a reset_count_at: at a maximum time window for a cycle.
5//!
6//! If the softlock already exists, and the failure count is 0, then this acts as the
7//! creation where the reset_count_at window is then set.
8//!
9//! While current_time < unlock_at, all authentication attempts are denied with a
10//! message regarding the account being temporarily unavailable. Once
11//! unlock_at < current_time, authentication will be processed again. If a subsequent
12//! failure occurs, unlock_at is extended based on policy, and failure_count incremented.
13//!
14//! If unlock_at < current_time, and authentication succeeds the login is allowed
15//! and no changes to failure_count or unlock_at are made.
16//!
17//! If reset_count_at < current_time, then failure_count is reset to 0 before processing.
18//!
19//! This allows handling of max_failure_count, so that when that value from policy is
20//! exceeded then unlock_at is set to reset_count_at to softlock until the cycle
21//! is over (see NIST sp800-63b.). For example, reset_count_at will be 24 hours after
22//! the first failed authentication attempt.
23//!
24//! This also works for something like TOTP which allows a 60 second cycle for the
25//! reset_count_at and a max number of attempts in that window (say 5). with short
26//! delays in between (1 second).
27//!
28//! ```text
29//!
30//!                                                  ┌────────────────────────┐
31//!                                                  │reset_at < current_time │
32//!                                                 ─└────────────────────────┘
33//!                                                │                         │
34//!                                                ▼
35//!             ┌─────┐                         .─────.       ┌────┐         │
36//!             │Valid│                        ╱       ╲      │Fail│
37//!        ┌────┴─────┴───────────────────────(count = 0)─────┴────┴┐        │
38//!        │                                   `.     ,'            │
39//!        │                                     `───'              │        │
40//!        │             ┌────────────────────────┐▲                │
41//!        │             │reset_at < current_time │                 │        │
42//!        │             └────────────────────────┘│                │
43//!        │                      ┌ ─ ─ ─ ─ ─ ─ ─ ─                 │        │
44//!        │                                                        │
45//!        │                      ├─────┬───────┬──┐                ▼        │
46//!        │                      │     │ Fail  │  │             .─────.
47//!        │                      │     │count++│  │           ,'       `.   │
48//!        ▼                   .─────.  └───────┘  │          ;  Locked   :
49//! ┌────────────┐            ╱       ╲            └─────────▶: count > 0 ;◀─┤
50//! │Auth Success│◀─┬─────┬──(Unlocked )                       ╲         ╱   │
51//! └────────────┘  │Valid│   `.     ,'                         `.     ,'    │
52//!                 └─────┘     `───'                             `───'      │
53//!                               ▲                                 │        │
54//!                               │                                 │        │
55//!                               └─────┬──────────────────────────┬┴┬───────┴──────────────────┐
56//!                                     │ expire_at < current_time │ │ current_time < expire_at │
57//!                                     └──────────────────────────┘ └──────────────────────────┘
58//!
59//! ```
60//!
61
62use std::time::Duration;
63
64const ONEDAY: u64 = 86400;
65
66#[derive(Debug, Clone)]
67pub enum CredSoftLockPolicy {
68    Password,
69    Totp(u64),
70    Webauthn,
71    Unrestricted,
72}
73
74impl CredSoftLockPolicy {
75    /// Determine the next lock state after a failure based on this credentials
76    /// policy.
77    fn failure_next_state(&self, count: usize, ct: Duration) -> LockState {
78        match self {
79            CredSoftLockPolicy::Password => {
80                let next_day_end = ct.as_secs() + ONEDAY;
81                let rem = next_day_end % ONEDAY;
82                let reset_at = Duration::from_secs(next_day_end - rem);
83
84                if count < 3 {
85                    LockState::Locked(count, reset_at, ct + Duration::from_secs(1))
86                } else if count < 9 {
87                    LockState::Locked(count, reset_at, ct + Duration::from_secs(3))
88                } else if count < 25 {
89                    LockState::Locked(count, reset_at, ct + Duration::from_secs(5))
90                } else if count < 100 {
91                    LockState::Locked(count, reset_at, ct + Duration::from_secs(10))
92                } else {
93                    LockState::Locked(count, reset_at, reset_at)
94                }
95            }
96            CredSoftLockPolicy::Totp(step) => {
97                // reset at is based on the next step ending.
98                let next_window_end = ct.as_secs() + step;
99                let rem = next_window_end % step;
100                let reset_at = Duration::from_secs(next_window_end - rem);
101                // We delay for 1 second, unless count is > 3, then we set
102                // unlock at to reset_at.
103                if count >= 3 {
104                    LockState::Locked(count, reset_at, reset_at)
105                } else {
106                    LockState::Locked(count, reset_at, ct + Duration::from_secs(1))
107                }
108            }
109            CredSoftLockPolicy::Webauthn => {
110                // we only lock for 1 second to slow them down.
111                // TODO: Could this be a DOS/Abuse vector?
112                LockState::Locked(
113                    count,
114                    ct + Duration::from_secs(1),
115                    ct + Duration::from_secs(1),
116                )
117            }
118            CredSoftLockPolicy::Unrestricted => {
119                // No action needed
120                LockState::Init
121            }
122        }
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127enum LockState {
128    Init,
129    // count
130    // * Number of Failures in this cycle
131    // unlock_at
132    // * Time of next allowed check (works with delay)
133    // reset_count_at
134    // * The time to reset the state to init.
135    //     count  reset_at  unlock_at
136    Locked(usize, Duration, Duration),
137    Unlocked(usize, Duration),
138}
139
140#[derive(Debug, Clone)]
141pub(crate) struct CredSoftLock {
142    state: LockState,
143    // Policy (for determining delay times based on num failures, and when to reset?)
144    policy: CredSoftLockPolicy,
145}
146
147impl CredSoftLock {
148    pub fn new(policy: CredSoftLockPolicy) -> Self {
149        CredSoftLock {
150            state: LockState::Init,
151            policy,
152        }
153    }
154
155    pub fn apply_time_step(&mut self, ct: Duration) {
156        // Do a reset if needed?
157        let mut next_state = match self.state {
158            LockState::Init => LockState::Init,
159            LockState::Locked(count, reset_at, unlock_at) => {
160                if ct > reset_at {
161                    LockState::Init
162                } else if ct > unlock_at {
163                    LockState::Unlocked(count, reset_at)
164                } else {
165                    LockState::Locked(count, reset_at, unlock_at)
166                }
167            }
168            LockState::Unlocked(count, reset_at) => {
169                if ct > reset_at {
170                    LockState::Init
171                } else {
172                    LockState::Unlocked(count, reset_at)
173                }
174            }
175        };
176        std::mem::swap(&mut self.state, &mut next_state);
177    }
178
179    /// Is this credential valid to proceed at this point in time.
180    pub fn is_valid(&self) -> bool {
181        !matches!(self.state, LockState::Locked(_count, _reset_at, _unlock_at))
182    }
183
184    /// Document a failure of authentication at this time.
185    pub fn record_failure(&mut self, ct: Duration) {
186        let mut next_state = match self.state {
187            LockState::Init => {
188                self.policy.failure_next_state(1, ct)
189                // LockState::Locked(1, reset_at, unlock_at)
190            }
191            LockState::Locked(count, _reset_at, _unlock_at) => {
192                // We should never reach this but just in case ...
193                self.policy.failure_next_state(count + 1, ct)
194                // LockState::Locked(count + 1, reset_at, unlock_at)
195            }
196            LockState::Unlocked(count, _reset_at) => {
197                self.policy.failure_next_state(count + 1, ct)
198                // LockState::Locked(count + 1, reset_at, unlock_at)
199            }
200        };
201        std::mem::swap(&mut self.state, &mut next_state);
202    }
203
204    #[cfg(test)]
205    pub fn is_state_init(&self) -> bool {
206        matches!(self.state, LockState::Init)
207    }
208
209    #[cfg(test)]
210    fn peek_state(&self) -> &LockState {
211        &self.state
212    }
213
214    /*
215    #[cfg(test)]
216    fn set_failure_count(&mut self, count: usize) {
217        let mut next_state = match self.state {
218            LockState::Init => panic!(),
219            LockState::Locked(_count, reset_at, unlock_at) => {
220                LockState::Locked(count, reset_at, unlock_at)
221            }
222            LockState::Unlocked(count, reset_at) => {
223                LockState::Unlocked(count, reset_at)
224            }
225        };
226        std::mem::swap(&mut self.state, &mut next_state);
227    }
228    */
229}
230
231#[cfg(test)]
232mod tests {
233    use crate::credential::softlock::*;
234    use crate::credential::totp::TOTP_DEFAULT_STEP;
235
236    #[test]
237    fn test_credential_softlock_statemachine() {
238        // Check that given the set of inputs, correct decisions about
239        // locking are made, and the states can be moved through.
240        // ==> Check the init state.
241        let mut slock = CredSoftLock::new(CredSoftLockPolicy::Password);
242        assert!(slock.is_state_init());
243        assert!(slock.is_valid());
244        // A success does nothing, so we don't track them.
245        let ct = Duration::from_secs(10);
246        // Generate a failure
247        // ==> trans to locked
248        slock.record_failure(ct);
249        assert!(
250            slock.peek_state()
251                == &LockState::Locked(1, Duration::from_secs(ONEDAY), Duration::from_secs(10 + 1))
252        );
253        // It will now fail
254        // ==> trans ct < exp_at
255        slock.apply_time_step(ct);
256        assert!(!slock.is_valid());
257        // A few seconds later it will be okay.
258        // ==> trans ct < exp_at
259        let ct2 = ct + Duration::from_secs(2);
260        slock.apply_time_step(ct2);
261        assert!(slock.is_valid());
262        // Now trigger a failure now, we move back to locked.
263        // ==> trans fail unlock -> lock
264        slock.record_failure(ct2);
265        assert!(
266            slock.peek_state()
267                == &LockState::Locked(2, Duration::from_secs(ONEDAY), Duration::from_secs(10 + 3))
268        );
269        assert!(!slock.is_valid());
270        // Now check the reset_at behaviour. We need to check a locked and unlocked state.
271        let mut slock2 = slock.clone();
272        // This triggers the reset at from locked.
273        // ==> trans locked -> init
274        let ct3 = ct + Duration::from_secs(ONEDAY + 2);
275        slock.apply_time_step(ct3);
276        assert!(slock.is_state_init());
277        assert!(slock.is_valid());
278        // For slock2, we move to unlocked:
279        // ==> trans unlocked -> init
280        let ct4 = ct2 + Duration::from_secs(2);
281        slock2.apply_time_step(ct4);
282        eprintln!("{:?}", slock2.peek_state());
283        assert_eq!(
284            slock2.peek_state(),
285            &LockState::Unlocked(2, Duration::from_secs(ONEDAY))
286        );
287        slock2.apply_time_step(ct3);
288        assert!(slock2.is_state_init());
289        assert!(slock2.is_valid());
290    }
291
292    #[test]
293    fn test_credential_softlock_policy_password() {
294        let policy = CredSoftLockPolicy::Password;
295
296        assert!(
297            policy.failure_next_state(1, Duration::from_secs(0))
298                == LockState::Locked(1, Duration::from_secs(ONEDAY), Duration::from_secs(1))
299        );
300
301        assert!(
302            policy.failure_next_state(8, Duration::from_secs(0))
303                == LockState::Locked(8, Duration::from_secs(ONEDAY), Duration::from_secs(3))
304        );
305
306        assert!(
307            policy.failure_next_state(24, Duration::from_secs(0))
308                == LockState::Locked(24, Duration::from_secs(ONEDAY), Duration::from_secs(5))
309        );
310
311        assert!(
312            policy.failure_next_state(99, Duration::from_secs(0))
313                == LockState::Locked(99, Duration::from_secs(ONEDAY), Duration::from_secs(10))
314        );
315
316        assert!(
317            policy.failure_next_state(100, Duration::from_secs(0))
318                == LockState::Locked(
319                    100,
320                    Duration::from_secs(ONEDAY),
321                    Duration::from_secs(ONEDAY)
322                )
323        );
324    }
325
326    #[test]
327    fn test_credential_softlock_policy_totp() {
328        let policy = CredSoftLockPolicy::Totp(TOTP_DEFAULT_STEP);
329
330        assert!(
331            policy.failure_next_state(1, Duration::from_secs(10))
332                == LockState::Locked(
333                    1,
334                    Duration::from_secs(TOTP_DEFAULT_STEP),
335                    Duration::from_secs(11)
336                )
337        );
338
339        assert!(
340            policy.failure_next_state(2, Duration::from_secs(10))
341                == LockState::Locked(
342                    2,
343                    Duration::from_secs(TOTP_DEFAULT_STEP),
344                    Duration::from_secs(11)
345                )
346        );
347
348        assert!(
349            policy.failure_next_state(3, Duration::from_secs(10))
350                == LockState::Locked(
351                    3,
352                    Duration::from_secs(TOTP_DEFAULT_STEP),
353                    Duration::from_secs(TOTP_DEFAULT_STEP)
354                )
355        );
356    }
357
358    #[test]
359    fn test_credential_softlock_policy_webauthn() {
360        let policy = CredSoftLockPolicy::Webauthn;
361
362        assert!(
363            policy.failure_next_state(1, Duration::from_secs(0))
364                == LockState::Locked(1, Duration::from_secs(1), Duration::from_secs(1))
365        );
366
367        // No matter how many failures, webauthn always only delays by 1 second.
368        assert!(
369            policy.failure_next_state(1000, Duration::from_secs(0))
370                == LockState::Locked(1000, Duration::from_secs(1), Duration::from_secs(1))
371        );
372    }
373}