1use 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
43fn 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
52pub fn check_sign_count(current_sign_count: u32, new_sign_count: u32) -> Result<(), WebauthnError> {
82 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#[derive(Debug, Clone)]
100struct RegistrationChallenge {
101 username: String,
102 challenge_bytes: Vec<u8>,
103 created_at: i64,
104}
105
106#[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
115pub struct ChallengeStore {
125 now: Arc<dyn Fn() -> i64 + Send + Sync>,
127 registration_challenges: HashMap<String, RegistrationChallenge>,
129 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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#[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 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 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 #[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 #[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 #[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 #[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 }
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 #[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 #[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 #[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 #[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 #[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 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 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 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 #[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(®_options.challenge)
919 .expect("options challenge must be base64url");
920 store.store_registration_challenge(®_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(®_id) && !rendered.contains(&auth_id),
928 "challenge ids must not appear in Debug: {rendered}"
929 );
930 assert!(
931 !rendered.contains(®_options.challenge)
932 && !rendered.contains(&auth_options.challenge),
933 "challenge values must not appear in Debug: {rendered}"
934 );
935 }
936}