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}