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