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/// Enforce the sign-count freshness state machine for an authentication
53/// assertion.
54///
55/// Semantics, following WebAuthn §7.2 ("if the new sign count is *not greater
56/// than* the stored sign count, and neither is zero, the authenticator may
57/// have been cloned"):
58///
59/// - If the stored counter is `0` there is no baseline to compare against, so
60///   the check is skipped: that is both an authenticator which does not
61///   implement a counter and a credential's first use.
62/// - If the *reported* counter is `0` the authenticator does not implement
63///   one, so the assertion carries no freshness signal either way and is
64///   accepted.
65/// - Otherwise the counter must strictly increase. A reported value that is
66///   lower *or equal* is the clone signal, and a replayed assertion carries
67///   the counter the authenticator last wrote — which is precisely the equal
68///   case, so accepting equality accepts replays.
69///
70/// # Security note
71///
72/// The caller must persist `new_sign_count` only after this function returns
73/// `Ok`, ideally with a compare-and-swap against `current_sign_count` so that
74/// two concurrent authentications cannot both bump the stored counter.
75///
76/// Returns `Ok(())` when the counter is fresh, [`WebauthnError::VerificationFailed`]
77/// when clone activity is suspected.
78///
79/// # Requirements
80/// REQ-WA-111, REQ-WA-112
81pub fn check_sign_count(current_sign_count: u32, new_sign_count: u32) -> Result<(), WebauthnError> {
82    // The zero exemptions are load-bearing in both directions: a stored zero
83    // means no baseline, a reported zero means the authenticator has no
84    // counter. Everything else must strictly increase.
85    if current_sign_count != 0 && new_sign_count != 0 && new_sign_count <= current_sign_count {
86        let relation = if new_sign_count == current_sign_count {
87            "unchanged"
88        } else {
89            "decreased"
90        };
91        return Err(WebauthnError::VerificationFailed(format!(
92            "Sign count {relation}: {new_sign_count} <= {current_sign_count} (possible cloned authenticator)"
93        )));
94    }
95    Ok(())
96}
97
98/// A pending registration challenge.
99#[derive(Debug, Clone)]
100struct RegistrationChallenge {
101    username: String,
102    challenge_bytes: Vec<u8>,
103    created_at: i64,
104}
105
106/// A pending authentication challenge.
107#[derive(Debug, Clone)]
108struct AuthenticationChallenge {
109    username: String,
110    challenge_bytes: Vec<u8>,
111    allowed_credential_ids: Vec<String>,
112    created_at: i64,
113}
114
115/// In-memory store of pending `WebAuthn` challenges with single-use
116/// consumption and expiry enforcement.
117///
118/// Construct with [`ChallengeStore::new`] or [`ChallengeStore::with_clock`]
119/// (the latter for deterministic expiry tests). See the module documentation
120/// for the threat model and storage contract.
121///
122/// # Requirements
123/// REQ-WA-108, REQ-WA-109, REQ-WA-115, REQ-WA-200
124pub struct ChallengeStore {
125    /// Injectable clock (Unix seconds); defaults to the system clock.
126    now: Arc<dyn Fn() -> i64 + Send + Sync>,
127    /// Pending registration challenges (challenge ID → entry).
128    registration_challenges: HashMap<String, RegistrationChallenge>,
129    /// Pending authentication challenges (challenge ID → entry).
130    authentication_challenges: HashMap<String, AuthenticationChallenge>,
131}
132
133impl Default for ChallengeStore {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl std::fmt::Debug for ChallengeStore {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        // Deliberately omit challenge contents (secrets in transit).
142        f.debug_struct("ChallengeStore")
143            .field("pending_registration", &self.registration_challenges.len())
144            .field(
145                "pending_authentication",
146                &self.authentication_challenges.len(),
147            )
148            .finish()
149    }
150}
151
152impl ChallengeStore {
153    /// Create a new empty challenge store using the system clock.
154    #[must_use]
155    pub fn new() -> Self {
156        Self {
157            now: Arc::new(unix_now),
158            registration_challenges: HashMap::new(),
159            authentication_challenges: HashMap::new(),
160        }
161    }
162
163    /// Create a challenge store with an injectable clock (Unix seconds),
164    /// primarily for deterministic expiry testing.
165    #[must_use]
166    pub fn with_clock(now: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
167        Self {
168            now,
169            registration_challenges: HashMap::new(),
170            authentication_challenges: HashMap::new(),
171        }
172    }
173
174    /// Store a pending registration challenge.
175    ///
176    /// The challenge is keyed by `challenge_id` (an opaque handle the caller
177    /// tracks in its own session state) and stores the raw challenge bytes the
178    /// client must echo back.
179    pub fn store_registration_challenge(
180        &mut self,
181        challenge_id: &str,
182        username: &str,
183        challenge_bytes: Vec<u8>,
184    ) {
185        self.store_registration_challenge_at(challenge_id, username, challenge_bytes, (self.now)());
186    }
187
188    /// Store a pending registration challenge with an explicit creation
189    /// timestamp (Unix seconds). Useful for testing expiry and for
190    /// restoring persisted state.
191    pub fn store_registration_challenge_at(
192        &mut self,
193        challenge_id: &str,
194        username: &str,
195        challenge_bytes: Vec<u8>,
196        created_at: i64,
197    ) {
198        self.registration_challenges.insert(
199            challenge_id.to_string(),
200            RegistrationChallenge {
201                username: username.to_string(),
202                challenge_bytes,
203                created_at,
204            },
205        );
206    }
207
208    /// Consume a registration challenge (single use), returning the
209    /// associated username and raw challenge bytes.
210    ///
211    /// # Security notes
212    ///
213    /// - The entry is removed *before* validation results are returned, so a
214    ///   challenge can never be used twice (replay protection).
215    /// - Expired challenges are rejected with [`WebauthnError::ChallengeExpired`].
216    /// - Unknown IDs are rejected with [`WebauthnError::InvalidChallenge`].
217    ///
218    /// # Requirements
219    /// REQ-WA-108, REQ-WA-109
220    pub fn consume_registration_challenge(
221        &mut self,
222        challenge_id: &str,
223        timeout_secs: u64,
224    ) -> Result<(String, Vec<u8>), WebauthnError> {
225        let challenge = self
226            .registration_challenges
227            .remove(challenge_id)
228            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
229
230        if (self.now)() - challenge.created_at > timeout_secs as i64 {
231            return Err(WebauthnError::ChallengeExpired);
232        }
233
234        Ok((challenge.username, challenge.challenge_bytes))
235    }
236
237    /// Store a pending authentication challenge.
238    pub fn store_authentication_challenge(
239        &mut self,
240        challenge_id: &str,
241        username: &str,
242        challenge_bytes: Vec<u8>,
243        allowed_credential_ids: Vec<String>,
244    ) {
245        self.store_authentication_challenge_at(
246            challenge_id,
247            username,
248            challenge_bytes,
249            allowed_credential_ids,
250            (self.now)(),
251        );
252    }
253
254    /// Store a pending authentication challenge with an explicit creation
255    /// timestamp (Unix seconds).
256    pub fn store_authentication_challenge_at(
257        &mut self,
258        challenge_id: &str,
259        username: &str,
260        challenge_bytes: Vec<u8>,
261        allowed_credential_ids: Vec<String>,
262        created_at: i64,
263    ) {
264        self.authentication_challenges.insert(
265            challenge_id.to_string(),
266            AuthenticationChallenge {
267                username: username.to_string(),
268                challenge_bytes,
269                allowed_credential_ids,
270                created_at,
271            },
272        );
273    }
274
275    /// Consume an authentication challenge (single use), returning the
276    /// username, raw challenge bytes, and the allowed credential IDs.
277    ///
278    /// Same security semantics as [`ChallengeStore::consume_registration_challenge`].
279    ///
280    /// # Requirements
281    /// REQ-WA-108, REQ-WA-109
282    pub fn consume_authentication_challenge(
283        &mut self,
284        challenge_id: &str,
285        timeout_secs: u64,
286    ) -> Result<(String, Vec<u8>, Vec<String>), WebauthnError> {
287        let challenge = self
288            .authentication_challenges
289            .remove(challenge_id)
290            .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
291
292        if (self.now)() - challenge.created_at > timeout_secs as i64 {
293            return Err(WebauthnError::ChallengeExpired);
294        }
295
296        Ok((
297            challenge.username,
298            challenge.challenge_bytes,
299            challenge.allowed_credential_ids,
300        ))
301    }
302
303    /// Generate a fresh registration challenge and the corresponding
304    /// `navigator.credentials.create()` options.
305    ///
306    /// The advertised `pubKeyCredParams` are built from
307    /// [`WebauthnConfig::allowed_algorithms`]; unknown algorithm IDs are
308    /// skipped (advertised as "unknown" device names by the protocol layer is
309    /// not a concern here). `existing_credential_ids` are advertised as
310    /// `excludeCredentials` so compliant authenticators refuse re-registering
311    /// a credential the server already holds.
312    ///
313    /// The `residentKey` / `userVerification` / `attestation` preferences in
314    /// the options reflect the corresponding [`WebauthnConfig`] settings;
315    /// when [`ResidentKeyPolicy::Required`] is configured, the L2
316    /// `requireResidentKey = true` flag is emitted as well. These are
317    /// *preferences*: server-side enforcement lives in
318    /// [`crate::policy::CredentialPolicy`].
319    ///
320    /// Returns `(challenge_id, options)`; the challenge ID doubles as the
321    /// Base64url challenge string and the returned options embed the same
322    /// value. Store the pair via [`ChallengeStore::store_registration_challenge`].
323    ///
324    /// # Requirements
325    /// REQ-WA-005, REQ-WA-145
326    #[must_use]
327    pub fn generate_registration_challenge(
328        &self,
329        config: &WebauthnConfig,
330        username: &str,
331        display_name: &str,
332        existing_credential_ids: &[String],
333    ) -> (String, RegistrationOptions) {
334        let challenge_bytes = generate_challenge_bytes();
335        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
336
337        let mut algorithms = config.allowed_algorithms.clone();
338        algorithms.sort();
339        algorithms.dedup();
340        let pub_key_cred_params = if algorithms.is_empty() {
341            vec![
342                PubKeyCredParam {
343                    alg: -7,
344                    type_: "public-key".to_string(),
345                },
346                PubKeyCredParam {
347                    alg: -35,
348                    type_: "public-key".to_string(),
349                },
350                PubKeyCredParam {
351                    alg: -257,
352                    type_: "public-key".to_string(),
353                },
354            ]
355        } else {
356            algorithms
357                .into_iter()
358                .map(|alg| PubKeyCredParam {
359                    alg,
360                    type_: "public-key".to_string(),
361                })
362                .collect()
363        };
364
365        let resident_key_required = config.resident_key == ResidentKeyPolicy::Required;
366        let options = RegistrationOptions {
367            challenge: challenge_b64.clone(),
368            rp: RelyingParty {
369                id: config.rp_id.clone(),
370                name: config.rp_name.clone(),
371            },
372            user: WebauthnUser {
373                id: base64_encode_urlsafe(username.as_bytes()),
374                name: display_name.to_string(),
375                display_name: username.to_string(),
376            },
377            pub_key_cred_params,
378            timeout: config.challenge_timeout_secs * 1000,
379            exclude_credentials: existing_credential_ids
380                .iter()
381                .map(|id| ExcludeCredential {
382                    id: id.clone(),
383                    type_: "public-key".to_string(),
384                    transports: None,
385                })
386                .collect(),
387            attestation: config.attestation_conveyance.as_str().to_string(),
388            authenticator_selection: AuthenticatorSelection {
389                resident_key: config.resident_key.as_str().to_string(),
390                user_verification: config
391                    .credential_policy
392                    .user_verification
393                    .as_str()
394                    .to_string(),
395                require_resident_key: resident_key_required,
396            },
397        };
398
399        (challenge_b64, options)
400    }
401
402    /// Generate a fresh authentication challenge and the corresponding
403    /// `navigator.credentials.get()` options.
404    ///
405    /// The `userVerification` preference reflects the config's credential
406    /// policy; enforcement of that policy happens in
407    /// [`crate::verify_authentication`].
408    ///
409    /// Returns `(challenge_id, options)` as in
410    /// [`ChallengeStore::generate_registration_challenge`].
411    ///
412    /// # Requirements
413    /// REQ-WA-006, REQ-WA-145
414    #[must_use]
415    pub fn generate_authentication_challenge(
416        &self,
417        config: &WebauthnConfig,
418        credential_ids: Vec<String>,
419    ) -> (String, AuthenticationOptions) {
420        let challenge_bytes = generate_challenge_bytes();
421        let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
422
423        let options = AuthenticationOptions {
424            challenge: challenge_b64.clone(),
425            rp_id: config.rp_id.clone(),
426            allow_credentials: credential_ids
427                .iter()
428                .map(|id| AllowCredential {
429                    id: id.clone(),
430                    type_: "public-key".to_string(),
431                    transports: None,
432                })
433                .collect(),
434            timeout: config.challenge_timeout_secs * 1000,
435            user_verification: config
436                .credential_policy
437                .user_verification
438                .as_str()
439                .to_string(),
440        };
441
442        (challenge_b64, options)
443    }
444}
445
446// Tests exercise failure paths and invariants directly; unwrap/expect,
447// slicing, and panicking asserts are acceptable here — violations
448// surface as test failures, not production panics.
449#[allow(
450    clippy::unwrap_used,
451    clippy::expect_used,
452    clippy::indexing_slicing,
453    clippy::panic
454)]
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    fn test_config() -> WebauthnConfig {
460        WebauthnConfig {
461            rp_id: "localhost".to_string(),
462            rp_name: "Kit Test".to_string(),
463            rp_origins: vec!["http://localhost:8080".to_string()],
464            allowed_algorithms: vec![-7, -257],
465            attestation: crate::attestation::AttestationPolicy::default(),
466            credential_policy: crate::policy::CredentialPolicy::default(),
467            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
468            attestation_conveyance: crate::policy::AttestationConveyance::None,
469            challenge_timeout_secs: 300,
470        }
471    }
472
473    fn clock_at(secs: i64) -> Arc<dyn Fn() -> i64 + Send + Sync> {
474        Arc::new(move || secs)
475    }
476
477    #[test]
478    fn test_challenge_registration_flow() {
479        let mut store = ChallengeStore::new();
480        let config = test_config();
481
482        let (challenge_id, _options) =
483            store.generate_registration_challenge(&config, "alice", "Alice", &[]);
484
485        store.store_registration_challenge(&challenge_id, "alice", challenge_id.as_bytes().into());
486
487        let (username, bytes) = store
488            .consume_registration_challenge(&challenge_id, 300)
489            .unwrap();
490        assert_eq!(username, "alice");
491        assert_eq!(bytes, challenge_id.as_bytes());
492
493        // Single use: second consume must fail.
494        assert!(store
495            .consume_registration_challenge(&challenge_id, 300)
496            .is_err());
497    }
498
499    #[test]
500    fn test_challenge_expiration() {
501        let mut store = ChallengeStore::new();
502        store.store_registration_challenge_at("ch-1", "alice", vec![0u8; 32], unix_now() - 301);
503
504        let result = store.consume_registration_challenge("ch-1", 300);
505        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
506    }
507
508    #[test]
509    fn test_challenge_not_found() {
510        let mut store = ChallengeStore::new();
511        let result = store.consume_registration_challenge("nonexistent", 300);
512        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
513    }
514
515    #[test]
516    fn test_authentication_challenge_flow() {
517        let mut store = ChallengeStore::new();
518        let config = test_config();
519
520        let (challenge_id, _options) =
521            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
522
523        store.store_authentication_challenge(
524            &challenge_id,
525            "alice",
526            vec![0u8; 32],
527            vec!["cred-1".to_string()],
528        );
529
530        let (username, _bytes, allowed) = store
531            .consume_authentication_challenge(&challenge_id, 300)
532            .unwrap();
533        assert_eq!(username, "alice");
534        assert_eq!(allowed, vec!["cred-1".to_string()]);
535
536        assert!(store
537            .consume_authentication_challenge(&challenge_id, 300)
538            .is_err());
539    }
540
541    #[test]
542    fn test_consume_authentication_challenge_not_found() {
543        let mut store = ChallengeStore::new();
544        let result = store.consume_authentication_challenge("nonexistent", 300);
545        assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
546    }
547
548    #[test]
549    fn test_consume_authentication_challenge_expired() {
550        let mut store = ChallengeStore::new();
551        store.store_authentication_challenge_at(
552            "ch-1",
553            "alice",
554            vec![0u8; 32],
555            vec!["cred-1".to_string()],
556            unix_now() - 400,
557        );
558        let result = store.consume_authentication_challenge("ch-1", 300);
559        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
560    }
561
562    #[test]
563    fn test_expiry_with_injected_clock() {
564        let t0 = 1_700_000_000i64;
565        let mut store = ChallengeStore::with_clock(clock_at(t0));
566        store.store_registration_challenge_at("ch", "alice", vec![0u8; 32], t0);
567
568        let mut store = ChallengeStore::with_clock(clock_at(t0 + 301));
569        store.store_registration_challenge_at("ch2", "alice", vec![0u8; 32], t0);
570
571        let result = store.consume_registration_challenge("ch2", 300);
572        assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
573
574        // Just inside the window succeeds.
575        store.store_registration_challenge_at("ch3", "alice", vec![0u8; 32], t0 + 1);
576        let result = store.consume_registration_challenge("ch3", 300);
577        assert!(result.is_ok());
578    }
579
580    #[test]
581    fn test_registration_options_serialization() {
582        let store = ChallengeStore::new();
583        let config = test_config();
584        let (_, options) = store.generate_registration_challenge(
585            &config,
586            "alice",
587            "Alice Johnson",
588            &["existing-1".to_string()],
589        );
590
591        let json = serde_json::to_string(&options).unwrap();
592        let deser: RegistrationOptions = serde_json::from_str(&json).unwrap();
593        assert_eq!(deser.rp.id, "localhost");
594        assert_eq!(deser.user.display_name, "alice");
595        assert_eq!(deser.exclude_credentials.len(), 1);
596        assert_eq!(deser.pub_key_cred_params.len(), 2);
597    }
598
599    #[test]
600    fn test_authentication_options_serialization() {
601        let store = ChallengeStore::new();
602        let config = test_config();
603        let (_, options) =
604            store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
605
606        let json = serde_json::to_string(&options).unwrap();
607        let deser: AuthenticationOptions = serde_json::from_str(&json).unwrap();
608        assert_eq!(deser.rp_id, "localhost");
609        assert_eq!(deser.allow_credentials.len(), 1);
610    }
611
612    #[test]
613    fn test_registration_options_configurable() {
614        let store = ChallengeStore::new();
615        let config = WebauthnConfig {
616            rp_id: "custom.example.com".to_string(),
617            rp_name: "Custom App".to_string(),
618            rp_origins: vec!["https://custom.example.com".to_string()],
619            allowed_algorithms: vec![-7, -257],
620            attestation: crate::attestation::AttestationPolicy::default(),
621            credential_policy: crate::policy::CredentialPolicy::default(),
622            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
623            attestation_conveyance: crate::policy::AttestationConveyance::None,
624            challenge_timeout_secs: 600,
625        };
626        let (_, options) = store.generate_registration_challenge(
627            &config,
628            "alice",
629            "Alice",
630            &["existing-cred".to_string()],
631        );
632        assert_eq!(options.rp.id, "custom.example.com");
633        assert_eq!(options.rp.name, "Custom App");
634        assert_eq!(options.timeout, 600_000);
635        assert_eq!(options.exclude_credentials.len(), 1);
636    }
637
638    #[test]
639    fn test_authentication_options_configurable() {
640        let store = ChallengeStore::new();
641        let config = WebauthnConfig {
642            rp_id: "custom.example.com".to_string(),
643            rp_name: "Custom App".to_string(),
644            rp_origins: vec![],
645            allowed_algorithms: vec![],
646            attestation: crate::attestation::AttestationPolicy::default(),
647            credential_policy: crate::policy::CredentialPolicy::default(),
648            resident_key: crate::policy::ResidentKeyPolicy::Preferred,
649            attestation_conveyance: crate::policy::AttestationConveyance::None,
650            challenge_timeout_secs: 120,
651        };
652        let (_, options) = store
653            .generate_authentication_challenge(&config, vec!["c1".to_string(), "c2".to_string()]);
654        assert_eq!(options.rp_id, "custom.example.com");
655        assert_eq!(options.timeout, 120_000);
656        assert_eq!(options.allow_credentials.len(), 2);
657    }
658
659    /// REQ-WA-145: the config's resident-key, user-verification, and
660    /// attestation-conveyance preferences are reflected in the emitted
661    /// options, including the L2 `requireResidentKey` flag when resident
662    /// keys are required.
663    #[test]
664    fn test_policy_preferences_plumbed_into_options() {
665        let store = ChallengeStore::new();
666        let config = WebauthnConfig {
667            resident_key: crate::policy::ResidentKeyPolicy::Required,
668            attestation_conveyance: crate::policy::AttestationConveyance::Direct,
669            credential_policy: crate::policy::CredentialPolicy {
670                user_verification: crate::policy::UserVerificationPolicy::Required,
671                ..crate::policy::CredentialPolicy::default()
672            },
673            ..test_config()
674        };
675
676        let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
677        assert_eq!(reg.attestation, "direct");
678        assert_eq!(reg.authenticator_selection.resident_key, "required");
679        assert!(reg.authenticator_selection.require_resident_key);
680        assert_eq!(reg.authenticator_selection.user_verification, "required");
681
682        let (_, auth) = store.generate_authentication_challenge(&config, vec![]);
683        assert_eq!(auth.user_verification, "required");
684    }
685
686    /// REQ-WA-145: discouraging resident keys never sets the L2
687    /// `requireResidentKey` flag.
688    #[test]
689    fn test_discouraged_resident_key_leaves_require_flag_false() {
690        let store = ChallengeStore::new();
691        let config = WebauthnConfig {
692            resident_key: crate::policy::ResidentKeyPolicy::Discouraged,
693            ..test_config()
694        };
695        let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
696        assert_eq!(reg.authenticator_selection.resident_key, "discouraged");
697        assert!(!reg.authenticator_selection.require_resident_key);
698    }
699
700    #[test]
701    fn test_pub_key_cred_params_from_allowed_algorithms() {
702        let store = ChallengeStore::new();
703        let config = WebauthnConfig {
704            allowed_algorithms: vec![-257, -7, -257],
705            ..test_config()
706        };
707        let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
708        let algs: Vec<i32> = options.pub_key_cred_params.iter().map(|p| p.alg).collect();
709        assert_eq!(algs, vec![-257, -7]);
710        assert!(options
711            .pub_key_cred_params
712            .iter()
713            .all(|p| p.type_ == "public-key"));
714    }
715
716    #[test]
717    fn test_user_id_is_base64_of_username() {
718        let store = ChallengeStore::new();
719        let (_, options) =
720            store.generate_registration_challenge(&test_config(), "alice", "Alice", &[]);
721        let decoded = crate::crypto::base64_decode_urlsafe(&options.user.id).unwrap();
722        assert_eq!(decoded, b"alice");
723    }
724
725    #[test]
726    fn test_check_sign_count_first_use_zero_stored() {
727        assert!(check_sign_count(0, 0).is_ok());
728        assert!(check_sign_count(0, 42).is_ok());
729        assert!(check_sign_count(0, u32::MAX).is_ok());
730    }
731
732    #[test]
733    fn test_check_sign_count_increases() {
734        assert!(check_sign_count(1, 2).is_ok());
735        assert!(check_sign_count(5, 6).is_ok());
736        assert!(check_sign_count(u32::MAX - 1, u32::MAX).is_ok());
737    }
738
739    /// §7.2: "not greater than" is the clone signal, so an equal counter is
740    /// refused. A replayed assertion reports the counter the authenticator last
741    /// wrote, which is exactly this case.
742    #[test]
743    fn test_check_sign_count_equal_is_refused() {
744        let err = check_sign_count(5, 5).expect_err("an equal counter is a replay");
745        assert!(
746            err.to_string().contains("unchanged"),
747            "the error names the relation: {err}"
748        );
749        assert!(check_sign_count(u32::MAX, u32::MAX).is_err());
750    }
751
752    /// A stored zero means there is no baseline to compare against — first use
753    /// — and a reported zero means the authenticator implements no counter.
754    #[test]
755    fn test_check_sign_count_zero_exemptions() {
756        assert!(check_sign_count(0, 0).is_ok());
757        assert!(check_sign_count(0, 42).is_ok());
758        assert!(check_sign_count(0, u32::MAX).is_ok());
759        assert!(check_sign_count(42, 0).is_ok(), "no counter, no signal");
760        assert!(
761            check_sign_count(u32::MAX, 0).is_ok(),
762            "no counter, no signal"
763        );
764    }
765
766    #[test]
767    fn test_check_sign_count_decrease_reports_verification_failed() {
768        assert!(check_sign_count(10, 3).is_err());
769        assert!(check_sign_count(u32::MAX, 0).is_ok());
770    }
771
772    #[test]
773    fn test_check_sign_count_decrease_refused() {
774        assert!(matches!(
775            check_sign_count(10, 5),
776            Err(WebauthnError::VerificationFailed(_))
777        ));
778        assert!(matches!(
779            check_sign_count(10, 9),
780            Err(WebauthnError::VerificationFailed(_))
781        ));
782        // A *reported* zero used to be treated as a decrease here. It is not:
783        // §7.2 skips the check when either side is zero, because a zero is how
784        // an authenticator says "I have no counter", which is no freshness
785        // signal rather than a backwards one. The zero cases are covered by
786        // test_check_sign_count_zero_exemptions.
787    }
788
789    #[test]
790    fn test_debug_does_not_leak_challenge_bytes() {
791        let mut store = ChallengeStore::new();
792        store.store_registration_challenge("ch", "alice", b"secret-challenge".to_vec());
793        let dbg = format!("{store:?}");
794        assert!(!dbg.contains("secret-challenge"));
795    }
796
797    /// `Default` must behave exactly like `new()` (an empty, usable store).
798    #[test]
799    fn default_store_is_usable() {
800        let mut store = ChallengeStore::default();
801        store.store_registration_challenge("ch", "alice", vec![0u8; 32]);
802        let (username, _) = store.consume_registration_challenge("ch", 300).unwrap();
803        assert_eq!(username, "alice");
804    }
805
806    /// An empty `allowed_algorithms` config falls back to advertising the
807    /// implemented algorithms (ES256, ES384, RS256).
808    #[test]
809    fn empty_algorithms_fall_back_to_es256_rs256() {
810        let store = ChallengeStore::new();
811        let config = WebauthnConfig {
812            allowed_algorithms: vec![],
813            ..test_config()
814        };
815        let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
816        let params: Vec<(i32, &str)> = options
817            .pub_key_cred_params
818            .iter()
819            .map(|p| (p.alg, p.type_.as_str()))
820            .collect();
821        assert_eq!(
822            params,
823            vec![
824                (-7, "public-key"),
825                (-35, "public-key"),
826                (-257, "public-key")
827            ]
828        );
829    }
830
831    /// The default (real) clock path must record creation times the
832    /// expiry math treats as fresh: a challenge consumed immediately with
833    /// a generous timeout succeeds. Mutants of `unix_now` (returning 0,
834    /// 1, or -1) make every challenge look centuries old and fail here.
835    #[test]
836    fn default_clock_store_consumes_fresh_challenges() {
837        let mut store = ChallengeStore::new();
838        let config = test_config();
839
840        store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
841        store
842            .consume_registration_challenge("reg-ch", 300)
843            .expect("fresh registration challenge must consume on the real clock");
844
845        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
846        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
847        store
848            .consume_authentication_challenge(&auth_id, 300)
849            .expect("fresh authentication challenge must consume on the real clock");
850    }
851
852    /// The default clock must be the real system clock: recorded creation
853    /// times land in a ±5 s window around wall-clock now. (Store and
854    /// consume read the same injected closure, so expiry math alone cannot
855    /// distinguish a constant-time mutant — the recorded value can.)
856    #[test]
857    fn default_clock_records_current_unix_time() {
858        let mut store = ChallengeStore::new();
859        store.store_registration_challenge("c", "alice", vec![0u8; 32]);
860        let created = store.registration_challenges["c"].created_at;
861        let now = std::time::SystemTime::now()
862            .duration_since(std::time::UNIX_EPOCH)
863            .map(|d| d.as_secs() as i64)
864            .unwrap_or(0);
865        assert!(
866            (now - 5..=now + 5).contains(&created),
867            "created_at {created} must be wall-clock now (~{now})"
868        );
869    }
870
871    /// Expiry is *strictly* greater: a challenge consumed at exactly
872    /// `timeout_secs` seconds of age is still valid; one second later it
873    /// is expired.
874    #[test]
875    fn expiry_boundary_is_strictly_greater_than_timeout() {
876        let config = test_config();
877
878        let mut store = ChallengeStore::with_clock(clock_at(1_000));
879        store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
880        // Rebind the clock to exactly timeout age: elapsed == timeout.
881        store.now = Arc::new(|| 1_300);
882        store
883            .consume_registration_challenge("reg-ch", 300)
884            .expect("challenge aged exactly timeout_secs must still consume");
885
886        let mut store = ChallengeStore::with_clock(clock_at(1_000));
887        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
888        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
889        // Exactly timeout age: still valid (strict >).
890        store.now = Arc::new(|| 1_300);
891        store
892            .consume_authentication_challenge(&auth_id, 300)
893            .expect("authentication challenge aged exactly timeout_secs must still consume");
894
895        // One second past: expired.
896        let mut store = ChallengeStore::with_clock(clock_at(1_000));
897        let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
898        store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
899        store.now = Arc::new(|| 1_301);
900        assert!(
901            matches!(
902                store.consume_authentication_challenge(&auth_id, 300),
903                Err(WebauthnError::ChallengeExpired)
904            ),
905            "challenge aged timeout_secs + 1 must be expired"
906        );
907    }
908
909    /// The Debug impl deliberately omits challenge contents (secrets in
910    /// transit): rendered output shows only counters, never the pending
911    /// challenge ids or bytes.
912    #[test]
913    fn debug_rendering_omits_challenge_secrets() {
914        let mut store = ChallengeStore::with_clock(clock_at(1_000));
915        let config = test_config();
916        let (reg_id, reg_options) =
917            store.generate_registration_challenge(&config, "alice", "Alice", &[]);
918        let reg_bytes = crate::crypto::base64_decode_urlsafe(&reg_options.challenge)
919            .expect("options challenge must be base64url");
920        store.store_registration_challenge(&reg_id, "alice", reg_bytes);
921        let (auth_id, auth_options) = store.generate_authentication_challenge(&config, Vec::new());
922        store.store_authentication_challenge(&auth_id, "alice", vec![0xAB; 32], vec![]);
923
924        let rendered = format!("{store:?}");
925        assert!(rendered.contains("ChallengeStore"), "{rendered}");
926        assert!(
927            !rendered.contains(&reg_id) && !rendered.contains(&auth_id),
928            "challenge ids must not appear in Debug: {rendered}"
929        );
930        assert!(
931            !rendered.contains(&reg_options.challenge)
932                && !rendered.contains(&auth_options.challenge),
933            "challenge values must not appear in Debug: {rendered}"
934        );
935    }
936}