Skip to main content

webauthn_kit/
challenge.rs

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