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}