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 do
28//!   not strictly increase, per WebAuthn §7.2.
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;
41use crate::policy::ResidentKeyPolicy;
42
43/// Current Unix timestamp in seconds; `0` if the clock is before the epoch
44/// (which would only make challenges look stale, never fresh — fail-safe).
45fn unix_now() -> i64 {
46    SystemTime::now()
47        .duration_since(UNIX_EPOCH)
48        .map(|d| d.as_secs() as i64)
49        .unwrap_or(0)
50}
51
52/// What a sign-count comparison says, independent of any policy.
53///
54/// WebAuthn L3 §7.2 step 18 is explicit that a counter which does not increase
55/// is *"a signal, but not proof"*, and it names one benign cause: *"a race
56/// condition where the Relying Party is processing assertion responses in an
57/// order other than the order they were generated."* So the comparison itself
58/// yields a fact; what the relying party does about it is a separate decision,
59/// and this enum is the first half.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SignCountVerdict {
62    /// The stored counter was zero, so there is no baseline: first use, or an
63    /// authenticator that has never signed.
64    NoBaseline,
65    /// The reported counter was zero, so the authenticator implements no
66    /// counter and the assertion carries no freshness signal either way.
67    NoCounter,
68    /// The counter strictly increased. Fresh.
69    Increased,
70    /// The counter is unchanged — which is exactly what a *replayed* assertion
71    /// looks like, since it carries the value the authenticator last wrote.
72    Equal,
73    /// The counter went backwards.
74    Decreased,
75}
76
77impl SignCountVerdict {
78    /// Whether this verdict is a counter regression — L3's "signal".
79    pub fn is_regression(self) -> bool {
80        matches!(self, Self::Equal | Self::Decreased)
81    }
82
83    /// Whether the assertion may proceed under a policy that treats regressions
84    /// as signals.
85    pub fn is_fresh(self) -> bool {
86        !self.is_regression()
87    }
88}
89
90/// Classify a sign-count comparison. Never fails; see [`SignCountVerdict`].
91pub fn classify_sign_count(current: u32, new: u32) -> SignCountVerdict {
92    if current == 0 {
93        SignCountVerdict::NoBaseline
94    } else if new == 0 {
95        SignCountVerdict::NoCounter
96    } else if new > current {
97        SignCountVerdict::Increased
98    } else if new == current {
99        SignCountVerdict::Equal
100    } else {
101        SignCountVerdict::Decreased
102    }
103}
104
105/// What a relying party does when the counter does not increase.
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OnRegression {
108    /// Refuse the assertion. Correct for a relying party that processes
109    /// assertions one at a time, and the safe default.
110    Reject,
111    /// Accept the assertion and report the regression, so the caller can score
112    /// it. Required for a relying party that verifies assertions
113    /// concurrently: L3 §7.2 names out-of-order processing as a benign cause of
114    /// an apparently regressed counter, and a party that hard-fails there
115    /// locks out legitimate users.
116    Signal,
117}
118
119/// The relying party's sign-count policy.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct SignCountPolicy {
122    /// What to do on a counter regression.
123    pub on_regression: OnRegression,
124}
125
126impl Default for SignCountPolicy {
127    /// Fail closed: refuse on regression. A party that has not thought about
128    /// out-of-order processing gets the safe behaviour.
129    fn default() -> Self {
130        Self {
131            on_regression: OnRegression::Reject,
132        }
133    }
134}
135
136impl SignCountPolicy {
137    /// A policy for a relying party that verifies assertions concurrently and
138    /// would otherwise lock out users on an out-of-order race.
139    pub fn signal_on_regression() -> Self {
140        Self {
141            on_regression: OnRegression::Signal,
142        }
143    }
144
145    /// Apply this policy to a verdict.
146    pub fn evaluate(
147        &self,
148        current: u32,
149        new: u32,
150    ) -> (SignCountVerdict, Result<(), WebauthnError>) {
151        let verdict = classify_sign_count(current, new);
152        if verdict.is_regression() && self.on_regression == OnRegression::Reject {
153            let relation = if verdict == SignCountVerdict::Equal {
154                "unchanged"
155            } else {
156                "decreased"
157            };
158            return (
159                verdict,
160                Err(WebauthnError::VerificationFailed(format!(
161                    "Sign count {relation}: {new} <= {current} (possible cloned authenticator)"
162                ))),
163            );
164        }
165        (verdict, Ok(()))
166    }
167}
168
169/// Enforce the sign-count freshness state machine under the default policy.
170///
171/// Equivalent to [`SignCountPolicy::default`] plus [`SignCountPolicy::evaluate`]:
172/// a counter that does not strictly increase is refused. The zero exemptions
173/// are load-bearing in both directions — a stored zero means no baseline, a
174/// reported zero means no counter.
175///
176/// # Security note
177///
178/// L3 §7.2 treats a non-increasing counter as a signal, not proof, and names
179/// out-of-order processing as a benign cause. This function is therefore
180/// deliberately strict; a relying party that verifies assertions concurrently
181/// should call [`classify_sign_count`] (or
182/// [`SignCountPolicy::signal_on_regression`]) and score the result rather than
183/// lock the user out.
184///
185/// The caller must persist `new_sign_count` only after this function returns
186/// `Ok`, ideally with a compare-and-swap against `current_sign_count` so two
187/// concurrent authentications cannot both bump the stored counter. L3 §7.2 goes
188/// further: state updates (`signCount`, `backupState`, `uvInitialized`) SHOULD be
189/// **deferred until after** any additional security checks the relying party
190/// performs have succeeded, which [`DeferredSignCountUpdate`] makes explicit.
191///
192/// # Requirements
193/// REQ-WA-111, REQ-WA-112
194pub fn check_sign_count(current_sign_count: u32, new_sign_count: u32) -> Result<(), WebauthnError> {
195    SignCountPolicy::default()
196        .evaluate(current_sign_count, new_sign_count)
197        .1
198}
199
200/// A sign-count advance held back until the host's own checks have passed.
201///
202/// L3 §7.2: *"If the Relying Party performs additional security checks beyond
203/// these WebAuthn authentication ceremony steps, the above state updates SHOULD
204/// be deferred to after those additional checks are completed successfully."*
205/// Applying the counter early means a later check that fails has already
206/// advanced the stored value — the credential now looks stale to every
207/// subsequent assertion.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct DeferredSignCountUpdate {
210    /// The counter the credential is believed to hold now.
211    pub current: u32,
212    /// The counter the assertion reported.
213    pub proposed: u32,
214}
215
216impl DeferredSignCountUpdate {
217    /// Stage an update. Takes no effect until [`Self::commit`].
218    pub fn stage(current: u32, new: u32) -> Self {
219        Self {
220            current,
221            proposed: new,
222        }
223    }
224
225    /// The value to store, if this update should be applied at all.
226    ///
227    /// A proposed counter of zero is never stored: it means the authenticator
228    /// has no counter, and persisting it would erase a real baseline.
229    pub fn value(&self) -> Option<u32> {
230        if self.proposed == 0 {
231            None
232        } else {
233            Some(self.proposed)
234        }
235    }
236
237    /// Commit the staged update. Call only after the host's own checks passed.
238    ///
239    /// Returns the new counter, or the unchanged current one when there was
240    /// nothing to store.
241    pub fn commit(self) -> u32 {
242        self.value().unwrap_or(self.current)
243    }
244}
245
246/// A pending registration challenge.
247#[derive(Debug, Clone)]
248struct RegistrationChallenge {
249    username: String,
250    challenge_bytes: Vec<u8>,
251    created_at: i64,
252}
253
254/// A pending authentication challenge.
255#[derive(Debug, Clone)]
256struct AuthenticationChallenge {
257    username: String,
258    challenge_bytes: Vec<u8>,
259    allowed_credential_ids: Vec<String>,
260    created_at: i64,
261}
262
263/// In-memory store of pending `WebAuthn` challenges with single-use
264/// consumption and expiry enforcement.
265///
266/// Construct with [`ChallengeStore::new`] or [`ChallengeStore::with_clock`]
267/// (the latter for deterministic expiry tests). See the module documentation
268/// for the threat model and storage contract.
269///
270/// # Requirements
271/// REQ-WA-108, REQ-WA-109, REQ-WA-115, REQ-WA-200
272pub struct ChallengeStore {
273    /// Injectable clock (Unix seconds); defaults to the system clock.
274    now: Arc<dyn Fn() -> i64 + Send + Sync>,
275    /// Pending registration challenges (challenge ID → entry).
276    registration_challenges: HashMap<String, RegistrationChallenge>,
277    /// Pending authentication challenges (challenge ID → entry).
278    authentication_challenges: HashMap<String, AuthenticationChallenge>,
279}
280
281impl Default for ChallengeStore {
282    fn default() -> Self {
283        Self::new()
284    }
285}
286
287impl std::fmt::Debug for ChallengeStore {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        // Deliberately omit challenge contents (secrets in transit).
290        f.debug_struct("ChallengeStore")
291            .field("pending_registration", &self.registration_challenges.len())
292            .field(
293                "pending_authentication",
294                &self.authentication_challenges.len(),
295            )
296            .finish()
297    }
298}
299
300impl ChallengeStore {
301    /// Create a new empty challenge store using the system clock.
302    #[must_use]
303    pub fn new() -> Self {
304        Self {
305            now: Arc::new(unix_now),
306            registration_challenges: HashMap::new(),
307            authentication_challenges: HashMap::new(),
308        }
309    }
310
311    /// Create a challenge store with an injectable clock (Unix seconds),
312    /// primarily for deterministic expiry testing.
313    #[must_use]
314    pub fn with_clock(now: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
315        Self {
316            now,
317            registration_challenges: HashMap::new(),
318            authentication_challenges: HashMap::new(),
319        }
320    }
321
322    /// Store a pending registration challenge.
323    ///
324    /// The challenge is keyed by `challenge_id` (an opaque handle the caller
325    /// tracks in its own session state) and stores the raw challenge bytes the
326    /// client must echo back.
327    pub fn store_registration_challenge(
328        &mut self,
329        challenge_id: &str,
330        username: &str,
331        challenge_bytes: Vec<u8>,
332    ) {
333        self.store_registration_challenge_at(challenge_id, username, challenge_bytes, (self.now)());
334    }
335
336    /// Store a pending registration challenge with an explicit creation
337    /// timestamp (Unix seconds). Useful for testing expiry and for
338    /// restoring persisted state.
339    pub fn store_registration_challenge_at(
340        &mut self,
341        challenge_id: &str,
342        username: &str,
343        challenge_bytes: Vec<u8>,
344        created_at: i64,
345    ) {
346        self.registration_challenges.insert(
347            challenge_id.to_string(),
348            RegistrationChallenge {
349                username: username.to_string(),
350                challenge_bytes,
351                created_at,
352            },
353        );
354    }
355
356    /// Consume a registration challenge (single use), returning the
357    /// associated username and raw challenge bytes.
358    ///
359    /// # Security notes
360    ///
361    /// - The entry is removed *before* validation results are returned, so a
362    ///   challenge can never be used twice (replay protection).
363    /// - Expired challenges are rejected with [`WebauthnError::ChallengeExpired`].
364    /// - Unknown IDs are rejected with [`WebauthnError::InvalidChallenge`].
365    ///
366    /// # Requirements
367    /// REQ-WA-108, REQ-WA-109
368    pub fn consume_registration_challenge(
369        &mut self,
370        challenge_id: &str,
371        timeout_secs: u64,
372    ) -> Result<(String, Vec<u8>), WebauthnError> {
373        let challenge = self
374            .registration_challenges
375            .remove(challenge_id)
376            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
377
378        if (self.now)() - challenge.created_at > timeout_secs as i64 {
379            return Err(WebauthnError::ChallengeExpired);
380        }
381
382        Ok((challenge.username, challenge.challenge_bytes))
383    }
384
385    /// Store a pending authentication challenge.
386    pub fn store_authentication_challenge(
387        &mut self,
388        challenge_id: &str,
389        username: &str,
390        challenge_bytes: Vec<u8>,
391        allowed_credential_ids: Vec<String>,
392    ) {
393        self.store_authentication_challenge_at(
394            challenge_id,
395            username,
396            challenge_bytes,
397            allowed_credential_ids,
398            (self.now)(),
399        );
400    }
401
402    /// Store a pending authentication challenge with an explicit creation
403    /// timestamp (Unix seconds).
404    pub fn store_authentication_challenge_at(
405        &mut self,
406        challenge_id: &str,
407        username: &str,
408        challenge_bytes: Vec<u8>,
409        allowed_credential_ids: Vec<String>,
410        created_at: i64,
411    ) {
412        self.authentication_challenges.insert(
413            challenge_id.to_string(),
414            AuthenticationChallenge {
415                username: username.to_string(),
416                challenge_bytes,
417                allowed_credential_ids,
418                created_at,
419            },
420        );
421    }
422
423    /// Consume an authentication challenge (single use), returning the
424    /// username, raw challenge bytes, and the allowed credential IDs.
425    ///
426    /// Same security semantics as [`ChallengeStore::consume_registration_challenge`].
427    ///
428    /// # Requirements
429    /// REQ-WA-108, REQ-WA-109
430    pub fn consume_authentication_challenge(
431        &mut self,
432        challenge_id: &str,
433        timeout_secs: u64,
434    ) -> Result<(String, Vec<u8>, Vec<String>), WebauthnError> {
435        let challenge = self
436            .authentication_challenges
437            .remove(challenge_id)
438            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
439
440        if (self.now)() - challenge.created_at > timeout_secs as i64 {
441            return Err(WebauthnError::ChallengeExpired);
442        }
443
444        Ok((
445            challenge.username,
446            challenge.challenge_bytes,
447            challenge.allowed_credential_ids,
448        ))
449    }
450
451    /// Generate a fresh registration challenge and the corresponding
452    /// `navigator.credentials.create()` options.
453    ///
454    /// The advertised `pubKeyCredParams` are built from
455    /// [`WebauthnConfig::allowed_algorithms`]; unknown algorithm IDs are
456    /// skipped (advertised as "unknown" device names by the protocol layer is
457    /// not a concern here). `existing_credential_ids` are advertised as
458    /// `excludeCredentials` so compliant authenticators refuse re-registering
459    /// a credential the server already holds.
460    ///
461    /// The `residentKey` / `userVerification` / `attestation` preferences in
462    /// the options reflect the corresponding [`WebauthnConfig`] settings;
463    /// when [`ResidentKeyPolicy::Required`] is configured, the L2
464    /// `requireResidentKey = true` flag is emitted as well. These are
465    /// *preferences*: server-side enforcement lives in
466    /// [`crate::policy::CredentialPolicy`].
467    ///
468    /// Returns `(challenge_id, options)`; the challenge ID doubles as the
469    /// Base64url challenge string and the returned options embed the same
470    /// value. Store the pair via [`ChallengeStore::store_registration_challenge`].
471    ///
472    /// # Requirements
473    /// REQ-WA-005, REQ-WA-145
474    #[must_use]
475    pub fn generate_registration_challenge(
476        &self,
477        config: &WebauthnConfig,
478        username: &str,
479        display_name: &str,
480        existing_credential_ids: &[String],
481    ) -> (String, RegistrationOptions) {
482        let challenge_bytes = generate_challenge_bytes();
483        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
484
485        let mut algorithms = config.allowed_algorithms.clone();
486        algorithms.sort();
487        algorithms.dedup();
488        let pub_key_cred_params = if algorithms.is_empty() {
489            vec![
490                PubKeyCredParam {
491                    alg: -7,
492                    type_: "public-key".to_string(),
493                },
494                PubKeyCredParam {
495                    alg: -35,
496                    type_: "public-key".to_string(),
497                },
498                PubKeyCredParam {
499                    alg: -257,
500                    type_: "public-key".to_string(),
501                },
502            ]
503        } else {
504            algorithms
505                .into_iter()
506                .map(|alg| PubKeyCredParam {
507                    alg,
508                    type_: "public-key".to_string(),
509                })
510                .collect()
511        };
512
513        let resident_key_required = config.resident_key == ResidentKeyPolicy::Required;
514        let options = RegistrationOptions {
515            challenge: challenge_b64.clone(),
516            rp: RelyingParty {
517                id: config.rp_id.clone(),
518                name: config.rp_name.clone(),
519            },
520            user: WebauthnUser {
521                id: base64_encode_urlsafe(username.as_bytes()),
522                name: display_name.to_string(),
523                display_name: username.to_string(),
524            },
525            pub_key_cred_params,
526            timeout: config.challenge_timeout_secs * 1000,
527            exclude_credentials: existing_credential_ids
528                .iter()
529                .map(|id| ExcludeCredential {
530                    id: id.clone(),
531                    type_: "public-key".to_string(),
532                    transports: None,
533                })
534                .collect(),
535            attestation: config.attestation_conveyance.as_str().to_string(),
536            authenticator_selection: AuthenticatorSelection {
537                resident_key: config.resident_key.as_str().to_string(),
538                user_verification: config
539                    .credential_policy
540                    .user_verification
541                    .as_str()
542                    .to_string(),
543                require_resident_key: resident_key_required,
544            },
545        };
546
547        (challenge_b64, options)
548    }
549
550    /// Generate a fresh authentication challenge and the corresponding
551    /// `navigator.credentials.get()` options.
552    ///
553    /// The `userVerification` preference reflects the config's credential
554    /// policy; enforcement of that policy happens in
555    /// [`crate::verify_authentication`].
556    ///
557    /// Returns `(challenge_id, options)` as in
558    /// [`ChallengeStore::generate_registration_challenge`].
559    ///
560    /// # Requirements
561    /// REQ-WA-006, REQ-WA-145
562    #[must_use]
563    pub fn generate_authentication_challenge(
564        &self,
565        config: &WebauthnConfig,
566        credential_ids: Vec<String>,
567    ) -> (String, AuthenticationOptions) {
568        let challenge_bytes = generate_challenge_bytes();
569        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
570
571        let options = AuthenticationOptions {
572            challenge: challenge_b64.clone(),
573            rp_id: config.rp_id.clone(),
574            allow_credentials: credential_ids
575                .iter()
576                .map(|id| AllowCredential {
577                    id: id.clone(),
578                    type_: "public-key".to_string(),
579                    transports: None,
580                })
581                .collect(),
582            timeout: config.challenge_timeout_secs * 1000,
583            user_verification: config
584                .credential_policy
585                .user_verification
586                .as_str()
587                .to_string(),
588        };
589
590        (challenge_b64, options)
591    }
592}
593
594// Tests exercise failure paths and invariants directly; unwrap/expect,
595// slicing, and panicking asserts are acceptable here — violations
596// surface as test failures, not production panics.
597#[allow(
598    clippy::unwrap_used,
599    clippy::expect_used,
600    clippy::indexing_slicing,
601    clippy::panic
602)]
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    fn test_config() -> WebauthnConfig {
608        WebauthnConfig {
609            rp_id: "localhost".to_string(),
610            rp_name: "Kit Test".to_string(),
611            rp_origins: vec!["http://localhost:8080".to_string()],
612            allowed_algorithms: vec![-7, -257],
613            attestation: crate::attestation::AttestationPolicy::default(),
614            credential_policy: crate::policy::CredentialPolicy::default(),
615            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
616            attestation_conveyance: crate::policy::AttestationConveyance::None,
617            challenge_timeout_secs: 300,
618        }
619    }
620
621    fn clock_at(secs: i64) -> Arc<dyn Fn() -> i64 + Send + Sync> {
622        Arc::new(move || secs)
623    }
624
625    #[test]
626    fn test_challenge_registration_flow() {
627        let mut store = ChallengeStore::new();
628        let config = test_config();
629
630        let (challenge_id, _options) =
631            store.generate_registration_challenge(&config, "alice", "Alice", &[]);
632
633        store.store_registration_challenge(&challenge_id, "alice", challenge_id.as_bytes().into());
634
635        let (username, bytes) = store
636            .consume_registration_challenge(&challenge_id, 300)
637            .unwrap();
638        assert_eq!(username, "alice");
639        assert_eq!(bytes, challenge_id.as_bytes());
640
641        // Single use: second consume must fail.
642        assert!(store
643            .consume_registration_challenge(&challenge_id, 300)
644            .is_err());
645    }
646
647    #[test]
648    fn test_challenge_expiration() {
649        let mut store = ChallengeStore::new();
650        store.store_registration_challenge_at("ch-1", "alice", vec![0u8; 32], unix_now() - 301);
651
652        let result = store.consume_registration_challenge("ch-1", 300);
653        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
654    }
655
656    #[test]
657    fn test_challenge_not_found() {
658        let mut store = ChallengeStore::new();
659        let result = store.consume_registration_challenge("nonexistent", 300);
660        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
661    }
662
663    #[test]
664    fn test_authentication_challenge_flow() {
665        let mut store = ChallengeStore::new();
666        let config = test_config();
667
668        let (challenge_id, _options) =
669            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
670
671        store.store_authentication_challenge(
672            &challenge_id,
673            "alice",
674            vec![0u8; 32],
675            vec!["cred-1".to_string()],
676        );
677
678        let (username, _bytes, allowed) = store
679            .consume_authentication_challenge(&challenge_id, 300)
680            .unwrap();
681        assert_eq!(username, "alice");
682        assert_eq!(allowed, vec!["cred-1".to_string()]);
683
684        assert!(store
685            .consume_authentication_challenge(&challenge_id, 300)
686            .is_err());
687    }
688
689    #[test]
690    fn test_consume_authentication_challenge_not_found() {
691        let mut store = ChallengeStore::new();
692        let result = store.consume_authentication_challenge("nonexistent", 300);
693        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
694    }
695
696    #[test]
697    fn test_consume_authentication_challenge_expired() {
698        let mut store = ChallengeStore::new();
699        store.store_authentication_challenge_at(
700            "ch-1",
701            "alice",
702            vec![0u8; 32],
703            vec!["cred-1".to_string()],
704            unix_now() - 400,
705        );
706        let result = store.consume_authentication_challenge("ch-1", 300);
707        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
708    }
709
710    #[test]
711    fn test_expiry_with_injected_clock() {
712        let t0 = 1_700_000_000i64;
713        let mut store = ChallengeStore::with_clock(clock_at(t0));
714        store.store_registration_challenge_at("ch", "alice", vec![0u8; 32], t0);
715
716        let mut store = ChallengeStore::with_clock(clock_at(t0 + 301));
717        store.store_registration_challenge_at("ch2", "alice", vec![0u8; 32], t0);
718
719        let result = store.consume_registration_challenge("ch2", 300);
720        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
721
722        // Just inside the window succeeds.
723        store.store_registration_challenge_at("ch3", "alice", vec![0u8; 32], t0 + 1);
724        let result = store.consume_registration_challenge("ch3", 300);
725        assert!(result.is_ok());
726    }
727
728    #[test]
729    fn test_registration_options_serialization() {
730        let store = ChallengeStore::new();
731        let config = test_config();
732        let (_, options) = store.generate_registration_challenge(
733            &config,
734            "alice",
735            "Alice Johnson",
736            &["existing-1".to_string()],
737        );
738
739        let json = serde_json::to_string(&options).unwrap();
740        let deser: RegistrationOptions = serde_json::from_str(&json).unwrap();
741        assert_eq!(deser.rp.id, "localhost");
742        assert_eq!(deser.user.display_name, "alice");
743        assert_eq!(deser.exclude_credentials.len(), 1);
744        assert_eq!(deser.pub_key_cred_params.len(), 2);
745    }
746
747    #[test]
748    fn test_authentication_options_serialization() {
749        let store = ChallengeStore::new();
750        let config = test_config();
751        let (_, options) =
752            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
753
754        let json = serde_json::to_string(&options).unwrap();
755        let deser: AuthenticationOptions = serde_json::from_str(&json).unwrap();
756        assert_eq!(deser.rp_id, "localhost");
757        assert_eq!(deser.allow_credentials.len(), 1);
758    }
759
760    #[test]
761    fn test_registration_options_configurable() {
762        let store = ChallengeStore::new();
763        let config = WebauthnConfig {
764            rp_id: "custom.example.com".to_string(),
765            rp_name: "Custom App".to_string(),
766            rp_origins: vec!["https://custom.example.com".to_string()],
767            allowed_algorithms: vec![-7, -257],
768            attestation: crate::attestation::AttestationPolicy::default(),
769            credential_policy: crate::policy::CredentialPolicy::default(),
770            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
771            attestation_conveyance: crate::policy::AttestationConveyance::None,
772            challenge_timeout_secs: 600,
773        };
774        let (_, options) = store.generate_registration_challenge(
775            &config,
776            "alice",
777            "Alice",
778            &["existing-cred".to_string()],
779        );
780        assert_eq!(options.rp.id, "custom.example.com");
781        assert_eq!(options.rp.name, "Custom App");
782        assert_eq!(options.timeout, 600_000);
783        assert_eq!(options.exclude_credentials.len(), 1);
784    }
785
786    #[test]
787    fn test_authentication_options_configurable() {
788        let store = ChallengeStore::new();
789        let config = WebauthnConfig {
790            rp_id: "custom.example.com".to_string(),
791            rp_name: "Custom App".to_string(),
792            rp_origins: vec![],
793            allowed_algorithms: vec![],
794            attestation: crate::attestation::AttestationPolicy::default(),
795            credential_policy: crate::policy::CredentialPolicy::default(),
796            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
797            attestation_conveyance: crate::policy::AttestationConveyance::None,
798            challenge_timeout_secs: 120,
799        };
800        let (_, options) = store
801            .generate_authentication_challenge(&config, vec!["c1".to_string(), "c2".to_string()]);
802        assert_eq!(options.rp_id, "custom.example.com");
803        assert_eq!(options.timeout, 120_000);
804        assert_eq!(options.allow_credentials.len(), 2);
805    }
806
807    /// REQ-WA-145: the config's resident-key, user-verification, and
808    /// attestation-conveyance preferences are reflected in the emitted
809    /// options, including the L2 `requireResidentKey` flag when resident
810    /// keys are required.
811    #[test]
812    fn test_policy_preferences_plumbed_into_options() {
813        let store = ChallengeStore::new();
814        let config = WebauthnConfig {
815            resident_key: crate::policy::ResidentKeyPolicy::Required,
816            attestation_conveyance: crate::policy::AttestationConveyance::Direct,
817            credential_policy: crate::policy::CredentialPolicy {
818                user_verification: crate::policy::UserVerificationPolicy::Required,
819                ..crate::policy::CredentialPolicy::default()
820            },
821            ..test_config()
822        };
823
824        let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
825        assert_eq!(reg.attestation, "direct");
826        assert_eq!(reg.authenticator_selection.resident_key, "required");
827        assert!(reg.authenticator_selection.require_resident_key);
828        assert_eq!(reg.authenticator_selection.user_verification, "required");
829
830        let (_, auth) = store.generate_authentication_challenge(&config, vec![]);
831        assert_eq!(auth.user_verification, "required");
832    }
833
834    /// REQ-WA-145: discouraging resident keys never sets the L2
835    /// `requireResidentKey` flag.
836    #[test]
837    fn test_discouraged_resident_key_leaves_require_flag_false() {
838        let store = ChallengeStore::new();
839        let config = WebauthnConfig {
840            resident_key: crate::policy::ResidentKeyPolicy::Discouraged,
841            ..test_config()
842        };
843        let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
844        assert_eq!(reg.authenticator_selection.resident_key, "discouraged");
845        assert!(!reg.authenticator_selection.require_resident_key);
846    }
847
848    #[test]
849    fn test_pub_key_cred_params_from_allowed_algorithms() {
850        let store = ChallengeStore::new();
851        let config = WebauthnConfig {
852            allowed_algorithms: vec![-257, -7, -257],
853            ..test_config()
854        };
855        let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
856        let algs: Vec<i32> = options.pub_key_cred_params.iter().map(|p| p.alg).collect();
857        assert_eq!(algs, vec![-257, -7]);
858        assert!(options
859            .pub_key_cred_params
860            .iter()
861            .all(|p| p.type_ == "public-key"));
862    }
863
864    #[test]
865    fn test_user_id_is_base64_of_username() {
866        let store = ChallengeStore::new();
867        let (_, options) =
868            store.generate_registration_challenge(&test_config(), "alice", "Alice", &[]);
869        let decoded = crate::crypto::base64_decode_urlsafe(&options.user.id).unwrap();
870        assert_eq!(decoded, b"alice");
871    }
872
873    #[test]
874    fn test_check_sign_count_first_use_zero_stored() {
875        assert!(check_sign_count(0, 0).is_ok());
876        assert!(check_sign_count(0, 42).is_ok());
877        assert!(check_sign_count(0, u32::MAX).is_ok());
878    }
879
880    #[test]
881    fn test_check_sign_count_increases() {
882        assert!(check_sign_count(1, 2).is_ok());
883        assert!(check_sign_count(5, 6).is_ok());
884        assert!(check_sign_count(u32::MAX - 1, u32::MAX).is_ok());
885    }
886
887    /// §7.2: "not greater than" is the clone signal, so an equal counter is
888    /// refused. A replayed assertion reports the counter the authenticator last
889    /// wrote, which is exactly this case.
890    #[test]
891    fn test_check_sign_count_equal_is_refused() {
892        let err = check_sign_count(5, 5).expect_err("an equal counter is a replay");
893        assert!(
894            err.to_string().contains("unchanged"),
895            "the error names the relation: {err}"
896        );
897        assert!(check_sign_count(u32::MAX, u32::MAX).is_err());
898    }
899
900    /// A stored zero means there is no baseline to compare against — first use
901    /// — and a reported zero means the authenticator implements no counter.
902    #[test]
903    fn test_check_sign_count_zero_exemptions() {
904        assert!(check_sign_count(0, 0).is_ok());
905        assert!(check_sign_count(0, 42).is_ok());
906        assert!(check_sign_count(0, u32::MAX).is_ok());
907        assert!(check_sign_count(42, 0).is_ok(), "no counter, no signal");
908        assert!(
909            check_sign_count(u32::MAX, 0).is_ok(),
910            "no counter, no signal"
911        );
912    }
913
914    /// L3 §7.2 step 18: a counter that does not increase is "a signal, but not
915    /// proof", and it names a benign cause — the RP processing assertions out
916    /// of order. The classification must therefore be available separately from
917    /// the decision, so a concurrent verifier can score rather than lock out.
918    #[test]
919    fn verdicts_separate_the_fact_from_the_decision() {
920        assert_eq!(classify_sign_count(0, 0), SignCountVerdict::NoBaseline);
921        assert_eq!(classify_sign_count(0, 7), SignCountVerdict::NoBaseline);
922        assert_eq!(classify_sign_count(7, 0), SignCountVerdict::NoCounter);
923        assert_eq!(classify_sign_count(7, 8), SignCountVerdict::Increased);
924        assert_eq!(classify_sign_count(7, 7), SignCountVerdict::Equal);
925        assert_eq!(classify_sign_count(7, 3), SignCountVerdict::Decreased);
926
927        assert!(classify_sign_count(7, 8).is_fresh());
928        assert!(!classify_sign_count(7, 8).is_regression());
929        // Both regressions are signals...
930        assert!(classify_sign_count(7, 7).is_regression());
931        assert!(classify_sign_count(7, 3).is_regression());
932    }
933
934    /// The scenario the split exists for: two assertions generated in order,
935    /// verified out of order. The second one to *arrive* carries the lower
936    /// counter, and refusing it would lock out a legitimate user.
937    #[test]
938    fn out_of_order_assertions_are_signals_under_a_concurrent_policy() {
939        let stored = 10;
940        // Assertion B (counter 11) is verified first and succeeds.
941        assert!(check_sign_count(stored, 11).is_ok());
942        let deferred = DeferredSignCountUpdate::stage(stored, 11);
943        // ...and only then does assertion A (counter 10) arrive. Against a
944        // single-threaded verifier this looks exactly like a replay.
945        let (verdict, result) = SignCountPolicy::signal_on_regression().evaluate(stored, 10);
946        assert_eq!(
947            verdict,
948            SignCountVerdict::Equal,
949            "the counter appears not to increase"
950        );
951        assert!(
952            result.is_ok(),
953            "but a concurrent verifier scores it, not fails it"
954        );
955        assert_eq!(
956            deferred.commit(),
957            11,
958            "and the stored counter advances exactly once"
959        );
960
961        // The default policy is still fail-closed: same input, refused.
962        assert!(
963            check_sign_count(stored, 10).is_err(),
964            "a verifier that has not opted into out-of-order handling must fail closed"
965        );
966    }
967
968    /// L3 §7.2: state updates SHOULD be deferred until additional security
969    /// checks succeed, and a zero must never overwrite a real baseline.
970    #[test]
971    fn deferred_update_does_not_erase_a_baseline() {
972        let update = DeferredSignCountUpdate::stage(41, 0);
973        assert_eq!(
974            update.value(),
975            None,
976            "a counter-less authenticator stores nothing"
977        );
978        assert_eq!(update.commit(), 41, "and the existing baseline survives");
979
980        let real = DeferredSignCountUpdate::stage(41, 42);
981        assert_eq!(real.value(), Some(42));
982        assert_eq!(real.commit(), 42);
983
984        // A regression must not be committed as if it were progress.
985        let regression = DeferredSignCountUpdate::stage(41, 7);
986        assert_eq!(
987            regression.value(),
988            Some(7),
989            "the host decides; the helper does not"
990        );
991    }
992
993    #[test]
994    fn test_check_sign_count_decrease_reports_verification_failed() {
995        assert!(check_sign_count(10, 3).is_err());
996        assert!(check_sign_count(u32::MAX, 0).is_ok());
997    }
998
999    #[test]
1000    fn test_check_sign_count_decrease_refused() {
1001        assert!(matches!(
1002            check_sign_count(10, 5),
1003            Err(WebauthnError::VerificationFailed(_))
1004        ));
1005        assert!(matches!(
1006            check_sign_count(10, 9),
1007            Err(WebauthnError::VerificationFailed(_))
1008        ));
1009        // A *reported* zero used to be treated as a decrease here. It is not:
1010        // §7.2 skips the check when either side is zero, because a zero is how
1011        // an authenticator says "I have no counter", which is no freshness
1012        // signal rather than a backwards one. The zero cases are covered by
1013        // test_check_sign_count_zero_exemptions.
1014    }
1015
1016    #[test]
1017    fn test_debug_does_not_leak_challenge_bytes() {
1018        let mut store = ChallengeStore::new();
1019        store.store_registration_challenge("ch", "alice", b"secret-challenge".to_vec());
1020        let dbg = format!("{store:?}");
1021        assert!(!dbg.contains("secret-challenge"));
1022    }
1023
1024    /// `Default` must behave exactly like `new()` (an empty, usable store).
1025    #[test]
1026    fn default_store_is_usable() {
1027        let mut store = ChallengeStore::default();
1028        store.store_registration_challenge("ch", "alice", vec![0u8; 32]);
1029        let (username, _) = store.consume_registration_challenge("ch", 300).unwrap();
1030        assert_eq!(username, "alice");
1031    }
1032
1033    /// An empty `allowed_algorithms` config falls back to advertising the
1034    /// implemented algorithms (ES256, ES384, RS256).
1035    #[test]
1036    fn empty_algorithms_fall_back_to_es256_rs256() {
1037        let store = ChallengeStore::new();
1038        let config = WebauthnConfig {
1039            allowed_algorithms: vec![],
1040            ..test_config()
1041        };
1042        let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
1043        let params: Vec<(i32, &str)> = options
1044            .pub_key_cred_params
1045            .iter()
1046            .map(|p| (p.alg, p.type_.as_str()))
1047            .collect();
1048        assert_eq!(
1049            params,
1050            vec![
1051                (-7, "public-key"),
1052                (-35, "public-key"),
1053                (-257, "public-key")
1054            ]
1055        );
1056    }
1057
1058    /// The default (real) clock path must record creation times the
1059    /// expiry math treats as fresh: a challenge consumed immediately with
1060    /// a generous timeout succeeds. Mutants of `unix_now` (returning 0,
1061    /// 1, or -1) make every challenge look centuries old and fail here.
1062    #[test]
1063    fn default_clock_store_consumes_fresh_challenges() {
1064        let mut store = ChallengeStore::new();
1065        let config = test_config();
1066
1067        store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
1068        store
1069            .consume_registration_challenge("reg-ch", 300)
1070            .expect("fresh registration challenge must consume on the real clock");
1071
1072        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1073        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1074        store
1075            .consume_authentication_challenge(&auth_id, 300)
1076            .expect("fresh authentication challenge must consume on the real clock");
1077    }
1078
1079    /// The default clock must be the real system clock: recorded creation
1080    /// times land in a ±5 s window around wall-clock now. (Store and
1081    /// consume read the same injected closure, so expiry math alone cannot
1082    /// distinguish a constant-time mutant — the recorded value can.)
1083    #[test]
1084    fn default_clock_records_current_unix_time() {
1085        let mut store = ChallengeStore::new();
1086        store.store_registration_challenge("c", "alice", vec![0u8; 32]);
1087        let created = store.registration_challenges["c"].created_at;
1088        let now = std::time::SystemTime::now()
1089            .duration_since(std::time::UNIX_EPOCH)
1090            .map(|d| d.as_secs() as i64)
1091            .unwrap_or(0);
1092        assert!(
1093            (now - 5..=now + 5).contains(&created),
1094            "created_at {created} must be wall-clock now (~{now})"
1095        );
1096    }
1097
1098    /// Expiry is *strictly* greater: a challenge consumed at exactly
1099    /// `timeout_secs` seconds of age is still valid; one second later it
1100    /// is expired.
1101    #[test]
1102    fn expiry_boundary_is_strictly_greater_than_timeout() {
1103        let config = test_config();
1104
1105        let mut store = ChallengeStore::with_clock(clock_at(1_000));
1106        store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
1107        // Rebind the clock to exactly timeout age: elapsed == timeout.
1108        store.now = Arc::new(|| 1_300);
1109        store
1110            .consume_registration_challenge("reg-ch", 300)
1111            .expect("challenge aged exactly timeout_secs must still consume");
1112
1113        let mut store = ChallengeStore::with_clock(clock_at(1_000));
1114        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1115        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1116        // Exactly timeout age: still valid (strict >).
1117        store.now = Arc::new(|| 1_300);
1118        store
1119            .consume_authentication_challenge(&auth_id, 300)
1120            .expect("authentication challenge aged exactly timeout_secs must still consume");
1121
1122        // One second past: expired.
1123        let mut store = ChallengeStore::with_clock(clock_at(1_000));
1124        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1125        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1126        store.now = Arc::new(|| 1_301);
1127        assert!(
1128            matches!(
1129                store.consume_authentication_challenge(&auth_id, 300),
1130                Err(WebauthnError::ChallengeExpired)
1131            ),
1132            "challenge aged timeout_secs + 1 must be expired"
1133        );
1134    }
1135
1136    /// The Debug impl deliberately omits challenge contents (secrets in
1137    /// transit): rendered output shows only counters, never the pending
1138    /// challenge ids or bytes.
1139    #[test]
1140    fn debug_rendering_omits_challenge_secrets() {
1141        let mut store = ChallengeStore::with_clock(clock_at(1_000));
1142        let config = test_config();
1143        let (reg_id, reg_options) =
1144            store.generate_registration_challenge(&config, "alice", "Alice", &[]);
1145        let reg_bytes = crate::crypto::base64_decode_urlsafe(&reg_options.challenge)
1146            .expect("options challenge must be base64url");
1147        store.store_registration_challenge(&reg_id, "alice", reg_bytes);
1148        let (auth_id, auth_options) = store.generate_authentication_challenge(&config, Vec::new());
1149        store.store_authentication_challenge(&auth_id, "alice", vec![0xAB; 32], vec![]);
1150
1151        let rendered = format!("{store:?}");
1152        assert!(rendered.contains("ChallengeStore"), "{rendered}");
1153        assert!(
1154            !rendered.contains(&reg_id) && !rendered.contains(&auth_id),
1155            "challenge ids must not appear in Debug: {rendered}"
1156        );
1157        assert!(
1158            !rendered.contains(&reg_options.challenge)
1159                && !rendered.contains(&auth_options.challenge),
1160            "challenge values must not appear in Debug: {rendered}"
1161        );
1162    }
1163}