Skip to main content

webauthn_kit/
challenge.rs

1//! Challenge generation, single-use consumption (replay protection),
2//! freshness/expiry enforcement, and the sign-count clone-detection state
3//! machine.
4//!
5//! This module lifts the challenge/replay/sign-count logic that was previously
6//! entangled with application storage into a self-contained, storage-free
7//! helper. Pending challenges are held in-process; challenge *consumption* is
8//! atomic (single-use) and time-bounded.
9//!
10//! # Storage contract
11//!
12//! [`ChallengeStore`] keeps only *pending* challenges in memory. It never
13//! touches credentials. Integrators persisting state across restarts should
14//! either accept that pending challenges are lost on restart (users simply
15//! retry) or implement their own store using [`ChallengeStore`] as reference.
16//!
17//! # Threat model
18//!
19//! - **Replay**: a consumed challenge is removed from the store before the
20//!   result is returned, so the same challenge can never complete two
21//!   ceremonies, even with concurrent requests (single map entry).
22//! - **Staleness**: challenges carry a creation timestamp; consumption fails
23//!   with [`WebauthnError::ChallengeExpired`] once older than the caller's
24//!   timeout.
25//! - **Predictability**: challenge bytes come from the OS CSPRNG (see
26//!   [`generate_challenge_bytes`]).
27//! - **Cloned authenticators**: [`check_sign_count`] rejects counters that
28//!   move backwards.
29
30use std::collections::HashMap;
31use std::sync::Arc;
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use crate::config::WebauthnConfig;
35use crate::credential::{
36    AllowCredential, AuthenticationOptions, AuthenticatorSelection, ExcludeCredential,
37    PubKeyCredParam, RegistrationOptions, RelyingParty, WebauthnUser,
38};
39use crate::crypto::{base64_encode_urlsafe, generate_challenge_bytes};
40use crate::error::WebauthnError;
41
42/// Current Unix timestamp in seconds; `0` if the clock is before the epoch
43/// (which would only make challenges look stale, never fresh — fail-safe).
44fn unix_now() -> i64 {
45    SystemTime::now()
46        .duration_since(UNIX_EPOCH)
47        .map(|d| d.as_secs() as i64)
48        .unwrap_or(0)
49}
50
51/// Enforce the sign-count freshness state machine for an authentication
52/// assertion.
53///
54/// Semantics (preserved from the reference implementation):
55///
56/// - If the stored counter is `0` (authenticators that do not implement the
57///   counter, or first use), the check is skipped and the assertion is
58///   accepted with whatever counter the authenticator reports.
59/// - Otherwise, a reported counter *strictly lower* than the stored counter
60///   indicates a cloned authenticator and is rejected.
61/// - An *equal* counter is accepted: many hardware keys only increment the
62///   counter occasionally and this must not lock users out.
63///
64/// # Security note
65///
66/// The caller must persist `new_sign_count` only after this function returns
67/// `Ok`, ideally with a compare-and-swap against `current_sign_count` so that
68/// two concurrent authentications cannot both bump the stored counter.
69///
70/// Returns `Ok(())` when the counter is fresh, [`WebauthnError::VerificationFailed`]
71/// when clone activity is suspected.
72pub fn check_sign_count(current_sign_count: u32, new_sign_count: u32) -> Result<(), WebauthnError> {
73    if current_sign_count != 0 && new_sign_count < current_sign_count {
74        return Err(WebauthnError::VerificationFailed(format!(
75            "Sign count decreased: {new_sign_count} < {current_sign_count} (possible cloned authenticator)"
76        )));
77    }
78    Ok(())
79}
80
81/// A pending registration challenge.
82#[derive(Debug, Clone)]
83struct RegistrationChallenge {
84    username: String,
85    challenge_bytes: Vec<u8>,
86    created_at: i64,
87}
88
89/// A pending authentication challenge.
90#[derive(Debug, Clone)]
91struct AuthenticationChallenge {
92    username: String,
93    challenge_bytes: Vec<u8>,
94    allowed_credential_ids: Vec<String>,
95    created_at: i64,
96}
97
98/// In-memory store of pending `WebAuthn` challenges with single-use
99/// consumption and expiry enforcement.
100///
101/// Construct with [`ChallengeStore::new`] or [`ChallengeStore::with_clock`]
102/// (the latter for deterministic expiry tests). See the module documentation
103/// for the threat model and storage contract.
104pub struct ChallengeStore {
105    /// Injectable clock (Unix seconds); defaults to the system clock.
106    now: Arc<dyn Fn() -> i64 + Send + Sync>,
107    /// Pending registration challenges (challenge ID → entry).
108    registration_challenges: HashMap<String, RegistrationChallenge>,
109    /// Pending authentication challenges (challenge ID → entry).
110    authentication_challenges: HashMap<String, AuthenticationChallenge>,
111}
112
113impl Default for ChallengeStore {
114    fn default() -> Self {
115        Self::new()
116    }
117}
118
119impl std::fmt::Debug for ChallengeStore {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        // Deliberately omit challenge contents (secrets in transit).
122        f.debug_struct("ChallengeStore")
123            .field("pending_registration", &self.registration_challenges.len())
124            .field(
125                "pending_authentication",
126                &self.authentication_challenges.len(),
127            )
128            .finish()
129    }
130}
131
132impl ChallengeStore {
133    /// Create a new empty challenge store using the system clock.
134    #[must_use]
135    pub fn new() -> Self {
136        Self {
137            now: Arc::new(unix_now),
138            registration_challenges: HashMap::new(),
139            authentication_challenges: HashMap::new(),
140        }
141    }
142
143    /// Create a challenge store with an injectable clock (Unix seconds),
144    /// primarily for deterministic expiry testing.
145    #[must_use]
146    pub fn with_clock(now: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
147        Self {
148            now,
149            registration_challenges: HashMap::new(),
150            authentication_challenges: HashMap::new(),
151        }
152    }
153
154    /// Store a pending registration challenge.
155    ///
156    /// The challenge is keyed by `challenge_id` (an opaque handle the caller
157    /// tracks in its own session state) and stores the raw challenge bytes the
158    /// client must echo back.
159    pub fn store_registration_challenge(
160        &mut self,
161        challenge_id: &str,
162        username: &str,
163        challenge_bytes: Vec<u8>,
164    ) {
165        self.store_registration_challenge_at(challenge_id, username, challenge_bytes, (self.now)());
166    }
167
168    /// Store a pending registration challenge with an explicit creation
169    /// timestamp (Unix seconds). Useful for testing expiry and for
170    /// restoring persisted state.
171    pub fn store_registration_challenge_at(
172        &mut self,
173        challenge_id: &str,
174        username: &str,
175        challenge_bytes: Vec<u8>,
176        created_at: i64,
177    ) {
178        self.registration_challenges.insert(
179            challenge_id.to_string(),
180            RegistrationChallenge {
181                username: username.to_string(),
182                challenge_bytes,
183                created_at,
184            },
185        );
186    }
187
188    /// Consume a registration challenge (single use), returning the
189    /// associated username and raw challenge bytes.
190    ///
191    /// # Security notes
192    ///
193    /// - The entry is removed *before* validation results are returned, so a
194    ///   challenge can never be used twice (replay protection).
195    /// - Expired challenges are rejected with [`WebauthnError::ChallengeExpired`].
196    /// - Unknown IDs are rejected with [`WebauthnError::InvalidChallenge`].
197    pub fn consume_registration_challenge(
198        &mut self,
199        challenge_id: &str,
200        timeout_secs: u64,
201    ) -> Result<(String, Vec<u8>), WebauthnError> {
202        let challenge = self
203            .registration_challenges
204            .remove(challenge_id)
205            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
206
207        if (self.now)() - challenge.created_at > timeout_secs as i64 {
208            return Err(WebauthnError::ChallengeExpired);
209        }
210
211        Ok((challenge.username, challenge.challenge_bytes))
212    }
213
214    /// Store a pending authentication challenge.
215    pub fn store_authentication_challenge(
216        &mut self,
217        challenge_id: &str,
218        username: &str,
219        challenge_bytes: Vec<u8>,
220        allowed_credential_ids: Vec<String>,
221    ) {
222        self.store_authentication_challenge_at(
223            challenge_id,
224            username,
225            challenge_bytes,
226            allowed_credential_ids,
227            (self.now)(),
228        );
229    }
230
231    /// Store a pending authentication challenge with an explicit creation
232    /// timestamp (Unix seconds).
233    pub fn store_authentication_challenge_at(
234        &mut self,
235        challenge_id: &str,
236        username: &str,
237        challenge_bytes: Vec<u8>,
238        allowed_credential_ids: Vec<String>,
239        created_at: i64,
240    ) {
241        self.authentication_challenges.insert(
242            challenge_id.to_string(),
243            AuthenticationChallenge {
244                username: username.to_string(),
245                challenge_bytes,
246                allowed_credential_ids,
247                created_at,
248            },
249        );
250    }
251
252    /// Consume an authentication challenge (single use), returning the
253    /// username, raw challenge bytes, and the allowed credential IDs.
254    ///
255    /// Same security semantics as [`ChallengeStore::consume_registration_challenge`].
256    pub fn consume_authentication_challenge(
257        &mut self,
258        challenge_id: &str,
259        timeout_secs: u64,
260    ) -> Result<(String, Vec<u8>, Vec<String>), WebauthnError> {
261        let challenge = self
262            .authentication_challenges
263            .remove(challenge_id)
264            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
265
266        if (self.now)() - challenge.created_at > timeout_secs as i64 {
267            return Err(WebauthnError::ChallengeExpired);
268        }
269
270        Ok((
271            challenge.username,
272            challenge.challenge_bytes,
273            challenge.allowed_credential_ids,
274        ))
275    }
276
277    /// Generate a fresh registration challenge and the corresponding
278    /// `navigator.credentials.create()` options.
279    ///
280    /// The advertised `pubKeyCredParams` are built from
281    /// [`WebauthnConfig::allowed_algorithms`]; unknown algorithm IDs are
282    /// skipped (advertised as "unknown" device names by the protocol layer is
283    /// not a concern here). `existing_credential_ids` are advertised as
284    /// `excludeCredentials` so compliant authenticators refuse re-registering
285    /// a credential the server already holds.
286    ///
287    /// Returns `(challenge_id, options)`; the challenge ID doubles as the
288    /// Base64url challenge string and the returned options embed the same
289    /// value. Store the pair via [`ChallengeStore::store_registration_challenge`].
290    #[must_use]
291    pub fn generate_registration_challenge(
292        &self,
293        config: &WebauthnConfig,
294        username: &str,
295        display_name: &str,
296        existing_credential_ids: &[String],
297    ) -> (String, RegistrationOptions) {
298        let challenge_bytes = generate_challenge_bytes();
299        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
300
301        let mut algorithms = config.allowed_algorithms.clone();
302        algorithms.sort();
303        algorithms.dedup();
304        let pub_key_cred_params = if algorithms.is_empty() {
305            vec![
306                PubKeyCredParam {
307                    alg: -7,
308                    type_: "public-key".to_string(),
309                },
310                PubKeyCredParam {
311                    alg: -257,
312                    type_: "public-key".to_string(),
313                },
314            ]
315        } else {
316            algorithms
317                .into_iter()
318                .map(|alg| PubKeyCredParam {
319                    alg,
320                    type_: "public-key".to_string(),
321                })
322                .collect()
323        };
324
325        let options = RegistrationOptions {
326            challenge: challenge_b64.clone(),
327            rp: RelyingParty {
328                id: config.rp_id.clone(),
329                name: config.rp_name.clone(),
330            },
331            user: WebauthnUser {
332                id: base64_encode_urlsafe(username.as_bytes()),
333                name: display_name.to_string(),
334                display_name: username.to_string(),
335            },
336            pub_key_cred_params,
337            timeout: config.challenge_timeout_secs * 1000,
338            exclude_credentials: existing_credential_ids
339                .iter()
340                .map(|id| ExcludeCredential {
341                    id: id.clone(),
342                    type_: "public-key".to_string(),
343                    transports: None,
344                })
345                .collect(),
346            attestation: "none".to_string(),
347            authenticator_selection: AuthenticatorSelection {
348                resident_key: "preferred".to_string(),
349                user_verification: "preferred".to_string(),
350            },
351        };
352
353        (challenge_b64, options)
354    }
355
356    /// Generate a fresh authentication challenge and the corresponding
357    /// `navigator.credentials.get()` options.
358    ///
359    /// Returns `(challenge_id, options)` as in
360    /// [`ChallengeStore::generate_registration_challenge`].
361    #[must_use]
362    pub fn generate_authentication_challenge(
363        &self,
364        config: &WebauthnConfig,
365        credential_ids: Vec<String>,
366    ) -> (String, AuthenticationOptions) {
367        let challenge_bytes = generate_challenge_bytes();
368        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
369
370        let options = AuthenticationOptions {
371            challenge: challenge_b64.clone(),
372            rp_id: config.rp_id.clone(),
373            allow_credentials: credential_ids
374                .iter()
375                .map(|id| AllowCredential {
376                    id: id.clone(),
377                    type_: "public-key".to_string(),
378                    transports: None,
379                })
380                .collect(),
381            timeout: config.challenge_timeout_secs * 1000,
382            user_verification: "preferred".to_string(),
383        };
384
385        (challenge_b64, options)
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    fn test_config() -> WebauthnConfig {
394        WebauthnConfig {
395            rp_id: "localhost".to_string(),
396            rp_name: "Kit Test".to_string(),
397            rp_origins: vec!["http://localhost:8080".to_string()],
398            allowed_algorithms: vec![-7, -257],
399            challenge_timeout_secs: 300,
400        }
401    }
402
403    fn clock_at(secs: i64) -> Arc<dyn Fn() -> i64 + Send + Sync> {
404        Arc::new(move || secs)
405    }
406
407    #[test]
408    fn test_challenge_registration_flow() {
409        let mut store = ChallengeStore::new();
410        let config = test_config();
411
412        let (challenge_id, _options) =
413            store.generate_registration_challenge(&config, "alice", "Alice", &[]);
414
415        store.store_registration_challenge(&challenge_id, "alice", challenge_id.as_bytes().into());
416
417        let (username, bytes) = store
418            .consume_registration_challenge(&challenge_id, 300)
419            .unwrap();
420        assert_eq!(username, "alice");
421        assert_eq!(bytes, challenge_id.as_bytes());
422
423        // Single use: second consume must fail.
424        assert!(store
425            .consume_registration_challenge(&challenge_id, 300)
426            .is_err());
427    }
428
429    #[test]
430    fn test_challenge_expiration() {
431        let mut store = ChallengeStore::new();
432        store.store_registration_challenge_at("ch-1", "alice", vec![0u8; 32], unix_now() - 301);
433
434        let result = store.consume_registration_challenge("ch-1", 300);
435        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
436    }
437
438    #[test]
439    fn test_challenge_not_found() {
440        let mut store = ChallengeStore::new();
441        let result = store.consume_registration_challenge("nonexistent", 300);
442        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
443    }
444
445    #[test]
446    fn test_authentication_challenge_flow() {
447        let mut store = ChallengeStore::new();
448        let config = test_config();
449
450        let (challenge_id, _options) =
451            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
452
453        store.store_authentication_challenge(
454            &challenge_id,
455            "alice",
456            vec![0u8; 32],
457            vec!["cred-1".to_string()],
458        );
459
460        let (username, _bytes, allowed) = store
461            .consume_authentication_challenge(&challenge_id, 300)
462            .unwrap();
463        assert_eq!(username, "alice");
464        assert_eq!(allowed, vec!["cred-1".to_string()]);
465
466        assert!(store
467            .consume_authentication_challenge(&challenge_id, 300)
468            .is_err());
469    }
470
471    #[test]
472    fn test_consume_authentication_challenge_not_found() {
473        let mut store = ChallengeStore::new();
474        let result = store.consume_authentication_challenge("nonexistent", 300);
475        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
476    }
477
478    #[test]
479    fn test_consume_authentication_challenge_expired() {
480        let mut store = ChallengeStore::new();
481        store.store_authentication_challenge_at(
482            "ch-1",
483            "alice",
484            vec![0u8; 32],
485            vec!["cred-1".to_string()],
486            unix_now() - 400,
487        );
488        let result = store.consume_authentication_challenge("ch-1", 300);
489        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
490    }
491
492    #[test]
493    fn test_expiry_with_injected_clock() {
494        let t0 = 1_700_000_000i64;
495        let mut store = ChallengeStore::with_clock(clock_at(t0));
496        store.store_registration_challenge_at("ch", "alice", vec![0u8; 32], t0);
497
498        let mut store = ChallengeStore::with_clock(clock_at(t0 + 301));
499        store.store_registration_challenge_at("ch2", "alice", vec![0u8; 32], t0);
500
501        let result = store.consume_registration_challenge("ch2", 300);
502        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
503
504        // Just inside the window succeeds.
505        store.store_registration_challenge_at("ch3", "alice", vec![0u8; 32], t0 + 1);
506        let result = store.consume_registration_challenge("ch3", 300);
507        assert!(result.is_ok());
508    }
509
510    #[test]
511    fn test_registration_options_serialization() {
512        let store = ChallengeStore::new();
513        let config = test_config();
514        let (_, options) = store.generate_registration_challenge(
515            &config,
516            "alice",
517            "Alice Johnson",
518            &["existing-1".to_string()],
519        );
520
521        let json = serde_json::to_string(&options).unwrap();
522        let deser: RegistrationOptions = serde_json::from_str(&json).unwrap();
523        assert_eq!(deser.rp.id, "localhost");
524        assert_eq!(deser.user.display_name, "alice");
525        assert_eq!(deser.exclude_credentials.len(), 1);
526        assert_eq!(deser.pub_key_cred_params.len(), 2);
527    }
528
529    #[test]
530    fn test_authentication_options_serialization() {
531        let store = ChallengeStore::new();
532        let config = test_config();
533        let (_, options) =
534            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
535
536        let json = serde_json::to_string(&options).unwrap();
537        let deser: AuthenticationOptions = serde_json::from_str(&json).unwrap();
538        assert_eq!(deser.rp_id, "localhost");
539        assert_eq!(deser.allow_credentials.len(), 1);
540    }
541
542    #[test]
543    fn test_registration_options_configurable() {
544        let store = ChallengeStore::new();
545        let config = WebauthnConfig {
546            rp_id: "custom.example.com".to_string(),
547            rp_name: "Custom App".to_string(),
548            rp_origins: vec!["https://custom.example.com".to_string()],
549            allowed_algorithms: vec![-7, -257],
550            challenge_timeout_secs: 600,
551        };
552        let (_, options) = store.generate_registration_challenge(
553            &config,
554            "alice",
555            "Alice",
556            &["existing-cred".to_string()],
557        );
558        assert_eq!(options.rp.id, "custom.example.com");
559        assert_eq!(options.rp.name, "Custom App");
560        assert_eq!(options.timeout, 600_000);
561        assert_eq!(options.exclude_credentials.len(), 1);
562    }
563
564    #[test]
565    fn test_authentication_options_configurable() {
566        let store = ChallengeStore::new();
567        let config = WebauthnConfig {
568            rp_id: "custom.example.com".to_string(),
569            rp_name: "Custom App".to_string(),
570            rp_origins: vec![],
571            allowed_algorithms: vec![],
572            challenge_timeout_secs: 120,
573        };
574        let (_, options) = store
575            .generate_authentication_challenge(&config, vec!["c1".to_string(), "c2".to_string()]);
576        assert_eq!(options.rp_id, "custom.example.com");
577        assert_eq!(options.timeout, 120_000);
578        assert_eq!(options.allow_credentials.len(), 2);
579    }
580
581    #[test]
582    fn test_pub_key_cred_params_from_allowed_algorithms() {
583        let store = ChallengeStore::new();
584        let config = WebauthnConfig {
585            allowed_algorithms: vec![-257, -7, -257],
586            ..test_config()
587        };
588        let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
589        let algs: Vec<i32> = options.pub_key_cred_params.iter().map(|p| p.alg).collect();
590        assert_eq!(algs, vec![-257, -7]);
591        assert!(options
592            .pub_key_cred_params
593            .iter()
594            .all(|p| p.type_ == "public-key"));
595    }
596
597    #[test]
598    fn test_user_id_is_base64_of_username() {
599        let store = ChallengeStore::new();
600        let (_, options) =
601            store.generate_registration_challenge(&test_config(), "alice", "Alice", &[]);
602        let decoded = crate::crypto::base64_decode_urlsafe(&options.user.id).unwrap();
603        assert_eq!(decoded, b"alice");
604    }
605
606    #[test]
607    fn test_check_sign_count_first_use_zero_stored() {
608        assert!(check_sign_count(0, 0).is_ok());
609        assert!(check_sign_count(0, 42).is_ok());
610        assert!(check_sign_count(0, u32::MAX).is_ok());
611    }
612
613    #[test]
614    fn test_check_sign_count_monotonic() {
615        assert!(check_sign_count(1, 2).is_ok());
616        assert!(check_sign_count(5, 5).is_ok()); // equal allowed (no-counter authenticators)
617        assert!(check_sign_count(u32::MAX, u32::MAX).is_ok());
618        assert!(check_sign_count(u32::MAX - 1, u32::MAX).is_ok());
619    }
620
621    #[test]
622    fn test_check_sign_count_decrease_rejected() {
623        assert!(matches!(
624            check_sign_count(10, 5),
625            Err(WebauthnError::VerificationFailed(_))
626        ));
627        assert!(matches!(
628            check_sign_count(u32::MAX, 0),
629            Err(WebauthnError::VerificationFailed(_))
630        ));
631        assert!(matches!(
632            check_sign_count(1, 0),
633            Err(WebauthnError::VerificationFailed(_))
634        ));
635    }
636
637    #[test]
638    fn test_debug_does_not_leak_challenge_bytes() {
639        let mut store = ChallengeStore::new();
640        store.store_registration_challenge("ch", "alice", b"secret-challenge".to_vec());
641        let dbg = format!("{store:?}");
642        assert!(!dbg.contains("secret-challenge"));
643    }
644}