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 {
86                        count,
87                        reset_at,
88                        unlock_at: ct + Duration::from_secs(1),
89                    }
90                } else if count < 9 {
91                    LockState::Locked {
92                        count,
93                        reset_at,
94                        unlock_at: ct + Duration::from_secs(3),
95                    }
96                } else if count < 25 {
97                    LockState::Locked {
98                        count,
99                        reset_at,
100                        unlock_at: ct + Duration::from_secs(5),
101                    }
102                } else if count < 100 {
103                    LockState::Locked {
104                        count,
105                        reset_at,
106                        unlock_at: ct + Duration::from_secs(10),
107                    }
108                } else {
109                    LockState::Locked {
110                        count,
111                        reset_at,
112                        unlock_at: reset_at,
113                    }
114                }
115            }
116            CredSoftLockPolicy::Totp(step) => {
117                // reset at is based on the next step ending.
118                let next_window_end = ct.as_secs() + step;
119                let rem = next_window_end % step;
120                let reset_at = Duration::from_secs(next_window_end - rem);
121                // We delay for 1 second, unless count is > 3, then we set
122                // unlock at to reset_at.
123                if count >= 3 {
124                    LockState::Locked {
125                        count,
126                        reset_at,
127                        unlock_at: reset_at,
128                    }
129                } else {
130                    LockState::Locked {
131                        count,
132                        reset_at,
133                        unlock_at: ct + Duration::from_secs(1),
134                    }
135                }
136            }
137            CredSoftLockPolicy::Webauthn => {
138                // we only lock for 1 second to slow them down.
139                // TODO: Could this be a DOS/Abuse vector?
140                LockState::Locked {
141                    count,
142                    reset_at: ct + Duration::from_secs(1),
143                    unlock_at: ct + Duration::from_secs(1),
144                }
145            }
146            CredSoftLockPolicy::Unrestricted => {
147                // No action needed
148                LockState::Init
149            }
150        }
151    }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155enum LockState {
156    Init,
157    // count
158    // * Number of Failures in this cycle
159    // unlock_at
160    // * Time of next allowed check (works with delay)
161    // reset_count_at
162    // * The time to reset the state to init.
163    //     count  reset_at  unlock_at
164    Locked {
165        count: usize,
166        reset_at: Duration,
167        unlock_at: Duration,
168    },
169    Unlocked(usize, Duration),
170}
171
172#[derive(Debug, Clone)]
173pub(crate) struct CredSoftLock {
174    state: LockState,
175    // Policy (for determining delay times based on num failures, and when to reset?)
176    policy: CredSoftLockPolicy,
177    last_expire_at: Duration,
178}
179
180impl CredSoftLock {
181    pub fn new(policy: CredSoftLockPolicy) -> Self {
182        CredSoftLock {
183            state: LockState::Init,
184            policy,
185            last_expire_at: Duration::from_secs(0),
186        }
187    }
188
189    pub fn apply_time_step(&mut self, ct: Duration, expire_at: Option<Duration>) {
190        // Do a reset if needed?
191        let mut next_state = match self.state {
192            LockState::Init => LockState::Init,
193            LockState::Locked {
194                count,
195                mut reset_at,
196                unlock_at,
197            } => {
198                // If there is a softlock expiry time, then we use it to *bound* the reset_at time.
199                // That way the remaining logic will kick in and then move the reset_at.
200                if let Some(expiry) = expire_at {
201                    if self.last_expire_at != expiry {
202                        // This lets us track former expiration times. We should only apply the reset/clear event ONCE.
203                        self.last_expire_at = expiry;
204
205                        // Now, we have to choose *if* we actually do a clear.
206                        if reset_at > expiry {
207                            // Okay, so the reset_at is beyond the expiry, we cap it now. This can
208                            // either cause a reset/clear, or the reset_at to be bound to expiry in the unlock state.
209                            //
210                            // for example, consider someone set expiry into the future beyond the reset_at time.
211                            // Then we don't actually want this to DO anything, because that wouldn't help anyone.
212                            reset_at = expiry
213                        }
214                    }
215                }
216
217                if ct > reset_at {
218                    LockState::Init
219                } else if ct > unlock_at {
220                    LockState::Unlocked(count, reset_at)
221                } else {
222                    LockState::Locked {
223                        count,
224                        reset_at,
225                        unlock_at,
226                    }
227                }
228            }
229            LockState::Unlocked(count, reset_at) => {
230                if ct > reset_at {
231                    LockState::Init
232                } else {
233                    LockState::Unlocked(count, reset_at)
234                }
235            }
236        };
237        std::mem::swap(&mut self.state, &mut next_state);
238    }
239
240    /// Is this credential valid to proceed at this point in time.
241    pub fn is_valid(&self) -> bool {
242        !matches!(self.state, LockState::Locked { .. })
243    }
244
245    /// Document a failure of authentication at this time.
246    pub fn record_failure(&mut self, ct: Duration) {
247        let mut next_state = match self.state {
248            LockState::Init => {
249                self.policy.failure_next_state(1, ct)
250                // LockState::Locked(1, reset_at, unlock_at)
251            }
252            LockState::Locked {
253                count,
254                reset_at: _,
255                unlock_at: _,
256            } => {
257                // We should never reach this but just in case ...
258                self.policy.failure_next_state(count + 1, ct)
259                // LockState::Locked(count + 1, reset_at, unlock_at)
260            }
261            LockState::Unlocked(count, _reset_at) => {
262                self.policy.failure_next_state(count + 1, ct)
263                // LockState::Locked(count + 1, reset_at, unlock_at)
264            }
265        };
266        std::mem::swap(&mut self.state, &mut next_state);
267    }
268
269    #[cfg(test)]
270    pub fn is_state_init(&self) -> bool {
271        matches!(self.state, LockState::Init)
272    }
273
274    #[cfg(test)]
275    fn peek_state(&self) -> &LockState {
276        &self.state
277    }
278
279    /*
280    #[cfg(test)]
281    fn set_failure_count(&mut self, count: usize) {
282        let mut next_state = match self.state {
283            LockState::Init => panic!(),
284            LockState::Locked(_count, reset_at, unlock_at) => {
285                LockState::Locked(count, reset_at, unlock_at)
286            }
287            LockState::Unlocked(count, reset_at) => {
288                LockState::Unlocked(count, reset_at)
289            }
290        };
291        std::mem::swap(&mut self.state, &mut next_state);
292    }
293    */
294}
295
296#[cfg(test)]
297mod tests {
298    use crate::credential::softlock::*;
299    use crate::credential::totp::TOTP_DEFAULT_STEP;
300
301    #[test]
302    fn test_credential_softlock_statemachine() {
303        // Check that given the set of inputs, correct decisions about
304        // locking are made, and the states can be moved through.
305        // ==> Check the init state.
306        let mut slock = CredSoftLock::new(CredSoftLockPolicy::Password);
307        assert!(slock.is_state_init());
308        assert!(slock.is_valid());
309        // A success does nothing, so we don't track them.
310        let ct = Duration::from_secs(10);
311        // Generate a failure
312        // ==> trans to locked
313        slock.record_failure(ct);
314        assert!(
315            slock.peek_state()
316                == &LockState::Locked {
317                    count: 1,
318                    reset_at: Duration::from_secs(ONEDAY),
319                    unlock_at: Duration::from_secs(10 + 1)
320                }
321        );
322        // It will now fail
323        // ==> trans ct < exp_at
324        slock.apply_time_step(ct, None);
325        assert!(!slock.is_valid());
326        // A few seconds later it will be okay.
327        // ==> trans ct < exp_at
328        let ct2 = ct + Duration::from_secs(2);
329        slock.apply_time_step(ct2, None);
330        assert!(slock.is_valid());
331        // Now trigger a failure now, we move back to locked.
332        // ==> trans fail unlock -> lock
333        slock.record_failure(ct2);
334        assert!(
335            slock.peek_state()
336                == &LockState::Locked {
337                    count: 2,
338                    reset_at: Duration::from_secs(ONEDAY),
339                    unlock_at: Duration::from_secs(10 + 3)
340                }
341        );
342        assert!(!slock.is_valid());
343        // Now check the reset_at behaviour. We need to check a locked and unlocked state.
344        let mut slock2 = slock.clone();
345        // This triggers the reset at from locked.
346        // ==> trans locked -> init
347        let ct3 = ct + Duration::from_secs(ONEDAY + 2);
348        slock.apply_time_step(ct3, None);
349        assert!(slock.is_state_init());
350        assert!(slock.is_valid());
351        // For slock2, we move to unlocked:
352        // ==> trans unlocked -> init
353        let ct4 = ct2 + Duration::from_secs(2);
354        slock2.apply_time_step(ct4, None);
355        eprintln!("{:?}", slock2.peek_state());
356        assert_eq!(
357            slock2.peek_state(),
358            &LockState::Unlocked(2, Duration::from_secs(ONEDAY))
359        );
360        slock2.apply_time_step(ct3, None);
361        assert!(slock2.is_state_init());
362        assert!(slock2.is_valid());
363    }
364
365    #[test]
366    fn test_credential_softlock_policy_password() {
367        let policy = CredSoftLockPolicy::Password;
368
369        assert!(
370            policy.failure_next_state(1, Duration::from_secs(0))
371                == LockState::Locked {
372                    count: 1,
373                    reset_at: Duration::from_secs(ONEDAY),
374                    unlock_at: Duration::from_secs(1)
375                }
376        );
377
378        assert!(
379            policy.failure_next_state(8, Duration::from_secs(0))
380                == LockState::Locked {
381                    count: 8,
382                    reset_at: Duration::from_secs(ONEDAY),
383                    unlock_at: Duration::from_secs(3)
384                }
385        );
386
387        assert!(
388            policy.failure_next_state(24, Duration::from_secs(0))
389                == LockState::Locked {
390                    count: 24,
391                    reset_at: Duration::from_secs(ONEDAY),
392                    unlock_at: Duration::from_secs(5)
393                }
394        );
395
396        assert!(
397            policy.failure_next_state(99, Duration::from_secs(0))
398                == LockState::Locked {
399                    count: 99,
400                    reset_at: Duration::from_secs(ONEDAY),
401                    unlock_at: Duration::from_secs(10)
402                }
403        );
404
405        assert!(
406            policy.failure_next_state(100, Duration::from_secs(0))
407                == LockState::Locked {
408                    count: 100,
409                    reset_at: Duration::from_secs(ONEDAY),
410                    unlock_at: Duration::from_secs(ONEDAY)
411                }
412        );
413    }
414
415    #[test]
416    fn test_credential_softlock_policy_totp() {
417        let policy = CredSoftLockPolicy::Totp(TOTP_DEFAULT_STEP);
418
419        assert!(
420            policy.failure_next_state(1, Duration::from_secs(10))
421                == LockState::Locked {
422                    count: 1,
423                    reset_at: Duration::from_secs(TOTP_DEFAULT_STEP),
424                    unlock_at: Duration::from_secs(11)
425                }
426        );
427
428        assert!(
429            policy.failure_next_state(2, Duration::from_secs(10))
430                == LockState::Locked {
431                    count: 2,
432                    reset_at: Duration::from_secs(TOTP_DEFAULT_STEP),
433                    unlock_at: Duration::from_secs(11)
434                }
435        );
436
437        assert!(
438            policy.failure_next_state(3, Duration::from_secs(10))
439                == LockState::Locked {
440                    count: 3,
441                    reset_at: Duration::from_secs(TOTP_DEFAULT_STEP),
442                    unlock_at: Duration::from_secs(TOTP_DEFAULT_STEP)
443                }
444        );
445    }
446
447    #[test]
448    fn test_credential_softlock_policy_webauthn() {
449        let policy = CredSoftLockPolicy::Webauthn;
450
451        assert!(
452            policy.failure_next_state(1, Duration::from_secs(0))
453                == LockState::Locked {
454                    count: 1,
455                    reset_at: Duration::from_secs(1),
456                    unlock_at: Duration::from_secs(1)
457                }
458        );
459
460        // No matter how many failures, webauthn always only delays by 1 second.
461        assert!(
462            policy.failure_next_state(1000, Duration::from_secs(0))
463                == LockState::Locked {
464                    count: 1000,
465                    reset_at: Duration::from_secs(1),
466                    unlock_at: Duration::from_secs(1)
467                }
468        );
469    }
470
471    #[test]
472    fn test_credential_softlock_expire_at_aka_reset() {
473        // test the behaviour of the expire at.
474        let mut slock = CredSoftLock::new(CredSoftLockPolicy::Password);
475        assert!(slock.is_state_init());
476        assert!(slock.is_valid());
477
478        let ct = Duration::from_secs(10);
479        // Generate a failure
480        // ==> trans to locked
481        slock.record_failure(ct);
482        assert_eq!(
483            slock.peek_state(),
484            &LockState::Locked {
485                count: 1,
486                reset_at: Duration::from_secs(ONEDAY),
487                unlock_at: Duration::from_secs(10 + 1)
488            }
489        );
490
491        // We're in a failed state now, so we can now trigger the reset behaviour.
492        slock.apply_time_step(ct, None);
493
494        // Changes nothing.
495        assert_eq!(
496            slock.peek_state(),
497            &LockState::Locked {
498                count: 1,
499                reset_at: Duration::from_secs(ONEDAY),
500                unlock_at: Duration::from_secs(10 + 1)
501            }
502        );
503
504        // Now, if we set the expiry to now, the lock still stays.
505        slock.apply_time_step(ct, Some(Duration::from_secs(10)));
506        assert_eq!(
507            slock.peek_state(),
508            &LockState::Locked {
509                count: 1,
510                reset_at: Duration::from_secs(10), // <<-- Notice the reset_at time has now shifted.
511                unlock_at: Duration::from_secs(10 + 1)
512            }
513        );
514
515        // But step forward, and we reset.
516        let ct = Duration::from_secs(11);
517        slock.apply_time_step(ct, None);
518        assert_eq!(slock.peek_state(), &LockState::Init);
519
520        // Now we record a new failure, we should be locked again.
521        slock.record_failure(ct);
522
523        assert_eq!(
524            slock.peek_state(),
525            &LockState::Locked {
526                count: 1,
527                reset_at: Duration::from_secs(ONEDAY),
528                unlock_at: Duration::from_secs(11 + 1)
529            }
530        );
531
532        // And the time state doesn't change that.
533        slock.apply_time_step(ct, Some(Duration::from_secs(10)));
534
535        assert_eq!(
536            slock.peek_state(),
537            &LockState::Locked {
538                count: 1,
539                reset_at: Duration::from_secs(ONEDAY),
540                unlock_at: Duration::from_secs(11 + 1)
541            }
542        );
543    }
544}