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
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum SignCountVerdict {
62 NoBaseline,
65 NoCounter,
68 Increased,
70 Equal,
73 Decreased,
75}
76
77impl SignCountVerdict {
78 pub fn is_regression(self) -> bool {
80 matches!(self, Self::Equal | Self::Decreased)
81 }
82
83 pub fn is_fresh(self) -> bool {
86 !self.is_regression()
87 }
88}
89
90pub fn classify_sign_count(current: u32, new: u32) -> SignCountVerdict {
92 if current == 0 {
93 SignCountVerdict::NoBaseline
94 } else if new == 0 {
95 SignCountVerdict::NoCounter
96 } else if new > current {
97 SignCountVerdict::Increased
98 } else if new == current {
99 SignCountVerdict::Equal
100 } else {
101 SignCountVerdict::Decreased
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum OnRegression {
108 Reject,
111 Signal,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct SignCountPolicy {
122 pub on_regression: OnRegression,
124}
125
126impl Default for SignCountPolicy {
127 fn default() -> Self {
130 Self {
131 on_regression: OnRegression::Reject,
132 }
133 }
134}
135
136impl SignCountPolicy {
137 pub fn signal_on_regression() -> Self {
140 Self {
141 on_regression: OnRegression::Signal,
142 }
143 }
144
145 pub fn evaluate(
147 &self,
148 current: u32,
149 new: u32,
150 ) -> (SignCountVerdict, Result<(), WebauthnError>) {
151 let verdict = classify_sign_count(current, new);
152 if verdict.is_regression() && self.on_regression == OnRegression::Reject {
153 let relation = if verdict == SignCountVerdict::Equal {
154 "unchanged"
155 } else {
156 "decreased"
157 };
158 return (
159 verdict,
160 Err(WebauthnError::VerificationFailed(format!(
161 "Sign count {relation}: {new} <= {current} (possible cloned authenticator)"
162 ))),
163 );
164 }
165 (verdict, Ok(()))
166 }
167}
168
169pub fn check_sign_count(current_sign_count: u32, new_sign_count: u32) -> Result<(), WebauthnError> {
195 SignCountPolicy::default()
196 .evaluate(current_sign_count, new_sign_count)
197 .1
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209pub struct DeferredSignCountUpdate {
210 pub current: u32,
212 pub proposed: u32,
214}
215
216impl DeferredSignCountUpdate {
217 pub fn stage(current: u32, new: u32) -> Self {
219 Self {
220 current,
221 proposed: new,
222 }
223 }
224
225 pub fn value(&self) -> Option<u32> {
230 if self.proposed == 0 {
231 None
232 } else {
233 Some(self.proposed)
234 }
235 }
236
237 pub fn commit(self) -> u32 {
242 self.value().unwrap_or(self.current)
243 }
244}
245
246#[derive(Debug, Clone)]
248struct RegistrationChallenge {
249 username: String,
250 challenge_bytes: Vec<u8>,
251 created_at: i64,
252}
253
254#[derive(Debug, Clone)]
256struct AuthenticationChallenge {
257 username: String,
258 challenge_bytes: Vec<u8>,
259 allowed_credential_ids: Vec<String>,
260 created_at: i64,
261}
262
263pub struct ChallengeStore {
273 now: Arc<dyn Fn() -> i64 + Send + Sync>,
275 registration_challenges: HashMap<String, RegistrationChallenge>,
277 authentication_challenges: HashMap<String, AuthenticationChallenge>,
279}
280
281impl Default for ChallengeStore {
282 fn default() -> Self {
283 Self::new()
284 }
285}
286
287impl std::fmt::Debug for ChallengeStore {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_struct("ChallengeStore")
291 .field("pending_registration", &self.registration_challenges.len())
292 .field(
293 "pending_authentication",
294 &self.authentication_challenges.len(),
295 )
296 .finish()
297 }
298}
299
300impl ChallengeStore {
301 #[must_use]
303 pub fn new() -> Self {
304 Self {
305 now: Arc::new(unix_now),
306 registration_challenges: HashMap::new(),
307 authentication_challenges: HashMap::new(),
308 }
309 }
310
311 #[must_use]
314 pub fn with_clock(now: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
315 Self {
316 now,
317 registration_challenges: HashMap::new(),
318 authentication_challenges: HashMap::new(),
319 }
320 }
321
322 pub fn store_registration_challenge(
328 &mut self,
329 challenge_id: &str,
330 username: &str,
331 challenge_bytes: Vec<u8>,
332 ) {
333 self.store_registration_challenge_at(challenge_id, username, challenge_bytes, (self.now)());
334 }
335
336 pub fn store_registration_challenge_at(
340 &mut self,
341 challenge_id: &str,
342 username: &str,
343 challenge_bytes: Vec<u8>,
344 created_at: i64,
345 ) {
346 self.registration_challenges.insert(
347 challenge_id.to_string(),
348 RegistrationChallenge {
349 username: username.to_string(),
350 challenge_bytes,
351 created_at,
352 },
353 );
354 }
355
356 pub fn consume_registration_challenge(
369 &mut self,
370 challenge_id: &str,
371 timeout_secs: u64,
372 ) -> Result<(String, Vec<u8>), WebauthnError> {
373 let challenge = self
374 .registration_challenges
375 .remove(challenge_id)
376 .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
377
378 if (self.now)() - challenge.created_at > timeout_secs as i64 {
379 return Err(WebauthnError::ChallengeExpired);
380 }
381
382 Ok((challenge.username, challenge.challenge_bytes))
383 }
384
385 pub fn store_authentication_challenge(
387 &mut self,
388 challenge_id: &str,
389 username: &str,
390 challenge_bytes: Vec<u8>,
391 allowed_credential_ids: Vec<String>,
392 ) {
393 self.store_authentication_challenge_at(
394 challenge_id,
395 username,
396 challenge_bytes,
397 allowed_credential_ids,
398 (self.now)(),
399 );
400 }
401
402 pub fn store_authentication_challenge_at(
405 &mut self,
406 challenge_id: &str,
407 username: &str,
408 challenge_bytes: Vec<u8>,
409 allowed_credential_ids: Vec<String>,
410 created_at: i64,
411 ) {
412 self.authentication_challenges.insert(
413 challenge_id.to_string(),
414 AuthenticationChallenge {
415 username: username.to_string(),
416 challenge_bytes,
417 allowed_credential_ids,
418 created_at,
419 },
420 );
421 }
422
423 pub fn consume_authentication_challenge(
431 &mut self,
432 challenge_id: &str,
433 timeout_secs: u64,
434 ) -> Result<(String, Vec<u8>, Vec<String>), WebauthnError> {
435 let challenge = self
436 .authentication_challenges
437 .remove(challenge_id)
438 .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
439
440 if (self.now)() - challenge.created_at > timeout_secs as i64 {
441 return Err(WebauthnError::ChallengeExpired);
442 }
443
444 Ok((
445 challenge.username,
446 challenge.challenge_bytes,
447 challenge.allowed_credential_ids,
448 ))
449 }
450
451 #[must_use]
475 pub fn generate_registration_challenge(
476 &self,
477 config: &WebauthnConfig,
478 username: &str,
479 display_name: &str,
480 existing_credential_ids: &[String],
481 ) -> (String, RegistrationOptions) {
482 let challenge_bytes = generate_challenge_bytes();
483 let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
484
485 let mut algorithms = config.allowed_algorithms.clone();
486 algorithms.sort();
487 algorithms.dedup();
488 let pub_key_cred_params = if algorithms.is_empty() {
489 vec![
490 PubKeyCredParam {
491 alg: -7,
492 type_: "public-key".to_string(),
493 },
494 PubKeyCredParam {
495 alg: -35,
496 type_: "public-key".to_string(),
497 },
498 PubKeyCredParam {
499 alg: -257,
500 type_: "public-key".to_string(),
501 },
502 ]
503 } else {
504 algorithms
505 .into_iter()
506 .map(|alg| PubKeyCredParam {
507 alg,
508 type_: "public-key".to_string(),
509 })
510 .collect()
511 };
512
513 let resident_key_required = config.resident_key == ResidentKeyPolicy::Required;
514 let options = RegistrationOptions {
515 challenge: challenge_b64.clone(),
516 rp: RelyingParty {
517 id: config.rp_id.clone(),
518 name: config.rp_name.clone(),
519 },
520 user: WebauthnUser {
521 id: base64_encode_urlsafe(username.as_bytes()),
522 name: display_name.to_string(),
523 display_name: username.to_string(),
524 },
525 pub_key_cred_params,
526 timeout: config.challenge_timeout_secs * 1000,
527 exclude_credentials: existing_credential_ids
528 .iter()
529 .map(|id| ExcludeCredential {
530 id: id.clone(),
531 type_: "public-key".to_string(),
532 transports: None,
533 })
534 .collect(),
535 attestation: config.attestation_conveyance.as_str().to_string(),
536 authenticator_selection: AuthenticatorSelection {
537 resident_key: config.resident_key.as_str().to_string(),
538 user_verification: config
539 .credential_policy
540 .user_verification
541 .as_str()
542 .to_string(),
543 require_resident_key: resident_key_required,
544 },
545 };
546
547 (challenge_b64, options)
548 }
549
550 #[must_use]
563 pub fn generate_authentication_challenge(
564 &self,
565 config: &WebauthnConfig,
566 credential_ids: Vec<String>,
567 ) -> (String, AuthenticationOptions) {
568 let challenge_bytes = generate_challenge_bytes();
569 let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
570
571 let options = AuthenticationOptions {
572 challenge: challenge_b64.clone(),
573 rp_id: config.rp_id.clone(),
574 allow_credentials: credential_ids
575 .iter()
576 .map(|id| AllowCredential {
577 id: id.clone(),
578 type_: "public-key".to_string(),
579 transports: None,
580 })
581 .collect(),
582 timeout: config.challenge_timeout_secs * 1000,
583 user_verification: config
584 .credential_policy
585 .user_verification
586 .as_str()
587 .to_string(),
588 };
589
590 (challenge_b64, options)
591 }
592}
593
594#[allow(
598 clippy::unwrap_used,
599 clippy::expect_used,
600 clippy::indexing_slicing,
601 clippy::panic
602)]
603#[cfg(test)]
604mod tests {
605 use super::*;
606
607 fn test_config() -> WebauthnConfig {
608 WebauthnConfig {
609 rp_id: "localhost".to_string(),
610 rp_name: "Kit Test".to_string(),
611 rp_origins: vec!["http://localhost:8080".to_string()],
612 allowed_algorithms: vec![-7, -257],
613 attestation: crate::attestation::AttestationPolicy::default(),
614 credential_policy: crate::policy::CredentialPolicy::default(),
615 resident_key: crate::policy::ResidentKeyPolicy::Preferred,
616 attestation_conveyance: crate::policy::AttestationConveyance::None,
617 challenge_timeout_secs: 300,
618 }
619 }
620
621 fn clock_at(secs: i64) -> Arc<dyn Fn() -> i64 + Send + Sync> {
622 Arc::new(move || secs)
623 }
624
625 #[test]
626 fn test_challenge_registration_flow() {
627 let mut store = ChallengeStore::new();
628 let config = test_config();
629
630 let (challenge_id, _options) =
631 store.generate_registration_challenge(&config, "alice", "Alice", &[]);
632
633 store.store_registration_challenge(&challenge_id, "alice", challenge_id.as_bytes().into());
634
635 let (username, bytes) = store
636 .consume_registration_challenge(&challenge_id, 300)
637 .unwrap();
638 assert_eq!(username, "alice");
639 assert_eq!(bytes, challenge_id.as_bytes());
640
641 assert!(store
643 .consume_registration_challenge(&challenge_id, 300)
644 .is_err());
645 }
646
647 #[test]
648 fn test_challenge_expiration() {
649 let mut store = ChallengeStore::new();
650 store.store_registration_challenge_at("ch-1", "alice", vec![0u8; 32], unix_now() - 301);
651
652 let result = store.consume_registration_challenge("ch-1", 300);
653 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
654 }
655
656 #[test]
657 fn test_challenge_not_found() {
658 let mut store = ChallengeStore::new();
659 let result = store.consume_registration_challenge("nonexistent", 300);
660 assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
661 }
662
663 #[test]
664 fn test_authentication_challenge_flow() {
665 let mut store = ChallengeStore::new();
666 let config = test_config();
667
668 let (challenge_id, _options) =
669 store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
670
671 store.store_authentication_challenge(
672 &challenge_id,
673 "alice",
674 vec![0u8; 32],
675 vec!["cred-1".to_string()],
676 );
677
678 let (username, _bytes, allowed) = store
679 .consume_authentication_challenge(&challenge_id, 300)
680 .unwrap();
681 assert_eq!(username, "alice");
682 assert_eq!(allowed, vec!["cred-1".to_string()]);
683
684 assert!(store
685 .consume_authentication_challenge(&challenge_id, 300)
686 .is_err());
687 }
688
689 #[test]
690 fn test_consume_authentication_challenge_not_found() {
691 let mut store = ChallengeStore::new();
692 let result = store.consume_authentication_challenge("nonexistent", 300);
693 assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
694 }
695
696 #[test]
697 fn test_consume_authentication_challenge_expired() {
698 let mut store = ChallengeStore::new();
699 store.store_authentication_challenge_at(
700 "ch-1",
701 "alice",
702 vec![0u8; 32],
703 vec!["cred-1".to_string()],
704 unix_now() - 400,
705 );
706 let result = store.consume_authentication_challenge("ch-1", 300);
707 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
708 }
709
710 #[test]
711 fn test_expiry_with_injected_clock() {
712 let t0 = 1_700_000_000i64;
713 let mut store = ChallengeStore::with_clock(clock_at(t0));
714 store.store_registration_challenge_at("ch", "alice", vec![0u8; 32], t0);
715
716 let mut store = ChallengeStore::with_clock(clock_at(t0 + 301));
717 store.store_registration_challenge_at("ch2", "alice", vec![0u8; 32], t0);
718
719 let result = store.consume_registration_challenge("ch2", 300);
720 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
721
722 store.store_registration_challenge_at("ch3", "alice", vec![0u8; 32], t0 + 1);
724 let result = store.consume_registration_challenge("ch3", 300);
725 assert!(result.is_ok());
726 }
727
728 #[test]
729 fn test_registration_options_serialization() {
730 let store = ChallengeStore::new();
731 let config = test_config();
732 let (_, options) = store.generate_registration_challenge(
733 &config,
734 "alice",
735 "Alice Johnson",
736 &["existing-1".to_string()],
737 );
738
739 let json = serde_json::to_string(&options).unwrap();
740 let deser: RegistrationOptions = serde_json::from_str(&json).unwrap();
741 assert_eq!(deser.rp.id, "localhost");
742 assert_eq!(deser.user.display_name, "alice");
743 assert_eq!(deser.exclude_credentials.len(), 1);
744 assert_eq!(deser.pub_key_cred_params.len(), 2);
745 }
746
747 #[test]
748 fn test_authentication_options_serialization() {
749 let store = ChallengeStore::new();
750 let config = test_config();
751 let (_, options) =
752 store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
753
754 let json = serde_json::to_string(&options).unwrap();
755 let deser: AuthenticationOptions = serde_json::from_str(&json).unwrap();
756 assert_eq!(deser.rp_id, "localhost");
757 assert_eq!(deser.allow_credentials.len(), 1);
758 }
759
760 #[test]
761 fn test_registration_options_configurable() {
762 let store = ChallengeStore::new();
763 let config = WebauthnConfig {
764 rp_id: "custom.example.com".to_string(),
765 rp_name: "Custom App".to_string(),
766 rp_origins: vec!["https://custom.example.com".to_string()],
767 allowed_algorithms: vec![-7, -257],
768 attestation: crate::attestation::AttestationPolicy::default(),
769 credential_policy: crate::policy::CredentialPolicy::default(),
770 resident_key: crate::policy::ResidentKeyPolicy::Preferred,
771 attestation_conveyance: crate::policy::AttestationConveyance::None,
772 challenge_timeout_secs: 600,
773 };
774 let (_, options) = store.generate_registration_challenge(
775 &config,
776 "alice",
777 "Alice",
778 &["existing-cred".to_string()],
779 );
780 assert_eq!(options.rp.id, "custom.example.com");
781 assert_eq!(options.rp.name, "Custom App");
782 assert_eq!(options.timeout, 600_000);
783 assert_eq!(options.exclude_credentials.len(), 1);
784 }
785
786 #[test]
787 fn test_authentication_options_configurable() {
788 let store = ChallengeStore::new();
789 let config = WebauthnConfig {
790 rp_id: "custom.example.com".to_string(),
791 rp_name: "Custom App".to_string(),
792 rp_origins: vec![],
793 allowed_algorithms: vec![],
794 attestation: crate::attestation::AttestationPolicy::default(),
795 credential_policy: crate::policy::CredentialPolicy::default(),
796 resident_key: crate::policy::ResidentKeyPolicy::Preferred,
797 attestation_conveyance: crate::policy::AttestationConveyance::None,
798 challenge_timeout_secs: 120,
799 };
800 let (_, options) = store
801 .generate_authentication_challenge(&config, vec!["c1".to_string(), "c2".to_string()]);
802 assert_eq!(options.rp_id, "custom.example.com");
803 assert_eq!(options.timeout, 120_000);
804 assert_eq!(options.allow_credentials.len(), 2);
805 }
806
807 #[test]
812 fn test_policy_preferences_plumbed_into_options() {
813 let store = ChallengeStore::new();
814 let config = WebauthnConfig {
815 resident_key: crate::policy::ResidentKeyPolicy::Required,
816 attestation_conveyance: crate::policy::AttestationConveyance::Direct,
817 credential_policy: crate::policy::CredentialPolicy {
818 user_verification: crate::policy::UserVerificationPolicy::Required,
819 ..crate::policy::CredentialPolicy::default()
820 },
821 ..test_config()
822 };
823
824 let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
825 assert_eq!(reg.attestation, "direct");
826 assert_eq!(reg.authenticator_selection.resident_key, "required");
827 assert!(reg.authenticator_selection.require_resident_key);
828 assert_eq!(reg.authenticator_selection.user_verification, "required");
829
830 let (_, auth) = store.generate_authentication_challenge(&config, vec![]);
831 assert_eq!(auth.user_verification, "required");
832 }
833
834 #[test]
837 fn test_discouraged_resident_key_leaves_require_flag_false() {
838 let store = ChallengeStore::new();
839 let config = WebauthnConfig {
840 resident_key: crate::policy::ResidentKeyPolicy::Discouraged,
841 ..test_config()
842 };
843 let (_, reg) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
844 assert_eq!(reg.authenticator_selection.resident_key, "discouraged");
845 assert!(!reg.authenticator_selection.require_resident_key);
846 }
847
848 #[test]
849 fn test_pub_key_cred_params_from_allowed_algorithms() {
850 let store = ChallengeStore::new();
851 let config = WebauthnConfig {
852 allowed_algorithms: vec![-257, -7, -257],
853 ..test_config()
854 };
855 let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
856 let algs: Vec<i32> = options.pub_key_cred_params.iter().map(|p| p.alg).collect();
857 assert_eq!(algs, vec![-257, -7]);
858 assert!(options
859 .pub_key_cred_params
860 .iter()
861 .all(|p| p.type_ == "public-key"));
862 }
863
864 #[test]
865 fn test_user_id_is_base64_of_username() {
866 let store = ChallengeStore::new();
867 let (_, options) =
868 store.generate_registration_challenge(&test_config(), "alice", "Alice", &[]);
869 let decoded = crate::crypto::base64_decode_urlsafe(&options.user.id).unwrap();
870 assert_eq!(decoded, b"alice");
871 }
872
873 #[test]
874 fn test_check_sign_count_first_use_zero_stored() {
875 assert!(check_sign_count(0, 0).is_ok());
876 assert!(check_sign_count(0, 42).is_ok());
877 assert!(check_sign_count(0, u32::MAX).is_ok());
878 }
879
880 #[test]
881 fn test_check_sign_count_increases() {
882 assert!(check_sign_count(1, 2).is_ok());
883 assert!(check_sign_count(5, 6).is_ok());
884 assert!(check_sign_count(u32::MAX - 1, u32::MAX).is_ok());
885 }
886
887 #[test]
891 fn test_check_sign_count_equal_is_refused() {
892 let err = check_sign_count(5, 5).expect_err("an equal counter is a replay");
893 assert!(
894 err.to_string().contains("unchanged"),
895 "the error names the relation: {err}"
896 );
897 assert!(check_sign_count(u32::MAX, u32::MAX).is_err());
898 }
899
900 #[test]
903 fn test_check_sign_count_zero_exemptions() {
904 assert!(check_sign_count(0, 0).is_ok());
905 assert!(check_sign_count(0, 42).is_ok());
906 assert!(check_sign_count(0, u32::MAX).is_ok());
907 assert!(check_sign_count(42, 0).is_ok(), "no counter, no signal");
908 assert!(
909 check_sign_count(u32::MAX, 0).is_ok(),
910 "no counter, no signal"
911 );
912 }
913
914 #[test]
919 fn verdicts_separate_the_fact_from_the_decision() {
920 assert_eq!(classify_sign_count(0, 0), SignCountVerdict::NoBaseline);
921 assert_eq!(classify_sign_count(0, 7), SignCountVerdict::NoBaseline);
922 assert_eq!(classify_sign_count(7, 0), SignCountVerdict::NoCounter);
923 assert_eq!(classify_sign_count(7, 8), SignCountVerdict::Increased);
924 assert_eq!(classify_sign_count(7, 7), SignCountVerdict::Equal);
925 assert_eq!(classify_sign_count(7, 3), SignCountVerdict::Decreased);
926
927 assert!(classify_sign_count(7, 8).is_fresh());
928 assert!(!classify_sign_count(7, 8).is_regression());
929 assert!(classify_sign_count(7, 7).is_regression());
931 assert!(classify_sign_count(7, 3).is_regression());
932 }
933
934 #[test]
938 fn out_of_order_assertions_are_signals_under_a_concurrent_policy() {
939 let stored = 10;
940 assert!(check_sign_count(stored, 11).is_ok());
942 let deferred = DeferredSignCountUpdate::stage(stored, 11);
943 let (verdict, result) = SignCountPolicy::signal_on_regression().evaluate(stored, 10);
946 assert_eq!(
947 verdict,
948 SignCountVerdict::Equal,
949 "the counter appears not to increase"
950 );
951 assert!(
952 result.is_ok(),
953 "but a concurrent verifier scores it, not fails it"
954 );
955 assert_eq!(
956 deferred.commit(),
957 11,
958 "and the stored counter advances exactly once"
959 );
960
961 assert!(
963 check_sign_count(stored, 10).is_err(),
964 "a verifier that has not opted into out-of-order handling must fail closed"
965 );
966 }
967
968 #[test]
971 fn deferred_update_does_not_erase_a_baseline() {
972 let update = DeferredSignCountUpdate::stage(41, 0);
973 assert_eq!(
974 update.value(),
975 None,
976 "a counter-less authenticator stores nothing"
977 );
978 assert_eq!(update.commit(), 41, "and the existing baseline survives");
979
980 let real = DeferredSignCountUpdate::stage(41, 42);
981 assert_eq!(real.value(), Some(42));
982 assert_eq!(real.commit(), 42);
983
984 let regression = DeferredSignCountUpdate::stage(41, 7);
986 assert_eq!(
987 regression.value(),
988 Some(7),
989 "the host decides; the helper does not"
990 );
991 }
992
993 #[test]
994 fn test_check_sign_count_decrease_reports_verification_failed() {
995 assert!(check_sign_count(10, 3).is_err());
996 assert!(check_sign_count(u32::MAX, 0).is_ok());
997 }
998
999 #[test]
1000 fn test_check_sign_count_decrease_refused() {
1001 assert!(matches!(
1002 check_sign_count(10, 5),
1003 Err(WebauthnError::VerificationFailed(_))
1004 ));
1005 assert!(matches!(
1006 check_sign_count(10, 9),
1007 Err(WebauthnError::VerificationFailed(_))
1008 ));
1009 }
1015
1016 #[test]
1017 fn test_debug_does_not_leak_challenge_bytes() {
1018 let mut store = ChallengeStore::new();
1019 store.store_registration_challenge("ch", "alice", b"secret-challenge".to_vec());
1020 let dbg = format!("{store:?}");
1021 assert!(!dbg.contains("secret-challenge"));
1022 }
1023
1024 #[test]
1026 fn default_store_is_usable() {
1027 let mut store = ChallengeStore::default();
1028 store.store_registration_challenge("ch", "alice", vec![0u8; 32]);
1029 let (username, _) = store.consume_registration_challenge("ch", 300).unwrap();
1030 assert_eq!(username, "alice");
1031 }
1032
1033 #[test]
1036 fn empty_algorithms_fall_back_to_es256_rs256() {
1037 let store = ChallengeStore::new();
1038 let config = WebauthnConfig {
1039 allowed_algorithms: vec![],
1040 ..test_config()
1041 };
1042 let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
1043 let params: Vec<(i32, &str)> = options
1044 .pub_key_cred_params
1045 .iter()
1046 .map(|p| (p.alg, p.type_.as_str()))
1047 .collect();
1048 assert_eq!(
1049 params,
1050 vec![
1051 (-7, "public-key"),
1052 (-35, "public-key"),
1053 (-257, "public-key")
1054 ]
1055 );
1056 }
1057
1058 #[test]
1063 fn default_clock_store_consumes_fresh_challenges() {
1064 let mut store = ChallengeStore::new();
1065 let config = test_config();
1066
1067 store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
1068 store
1069 .consume_registration_challenge("reg-ch", 300)
1070 .expect("fresh registration challenge must consume on the real clock");
1071
1072 let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1073 store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1074 store
1075 .consume_authentication_challenge(&auth_id, 300)
1076 .expect("fresh authentication challenge must consume on the real clock");
1077 }
1078
1079 #[test]
1084 fn default_clock_records_current_unix_time() {
1085 let mut store = ChallengeStore::new();
1086 store.store_registration_challenge("c", "alice", vec![0u8; 32]);
1087 let created = store.registration_challenges["c"].created_at;
1088 let now = std::time::SystemTime::now()
1089 .duration_since(std::time::UNIX_EPOCH)
1090 .map(|d| d.as_secs() as i64)
1091 .unwrap_or(0);
1092 assert!(
1093 (now - 5..=now + 5).contains(&created),
1094 "created_at {created} must be wall-clock now (~{now})"
1095 );
1096 }
1097
1098 #[test]
1102 fn expiry_boundary_is_strictly_greater_than_timeout() {
1103 let config = test_config();
1104
1105 let mut store = ChallengeStore::with_clock(clock_at(1_000));
1106 store.store_registration_challenge("reg-ch", "alice", vec![1u8; 32]);
1107 store.now = Arc::new(|| 1_300);
1109 store
1110 .consume_registration_challenge("reg-ch", 300)
1111 .expect("challenge aged exactly timeout_secs must still consume");
1112
1113 let mut store = ChallengeStore::with_clock(clock_at(1_000));
1114 let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1115 store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1116 store.now = Arc::new(|| 1_300);
1118 store
1119 .consume_authentication_challenge(&auth_id, 300)
1120 .expect("authentication challenge aged exactly timeout_secs must still consume");
1121
1122 let mut store = ChallengeStore::with_clock(clock_at(1_000));
1124 let (auth_id, _options) = store.generate_authentication_challenge(&config, Vec::new());
1125 store.store_authentication_challenge(&auth_id, "alice", vec![0u8; 32], vec![]);
1126 store.now = Arc::new(|| 1_301);
1127 assert!(
1128 matches!(
1129 store.consume_authentication_challenge(&auth_id, 300),
1130 Err(WebauthnError::ChallengeExpired)
1131 ),
1132 "challenge aged timeout_secs + 1 must be expired"
1133 );
1134 }
1135
1136 #[test]
1140 fn debug_rendering_omits_challenge_secrets() {
1141 let mut store = ChallengeStore::with_clock(clock_at(1_000));
1142 let config = test_config();
1143 let (reg_id, reg_options) =
1144 store.generate_registration_challenge(&config, "alice", "Alice", &[]);
1145 let reg_bytes = crate::crypto::base64_decode_urlsafe(®_options.challenge)
1146 .expect("options challenge must be base64url");
1147 store.store_registration_challenge(®_id, "alice", reg_bytes);
1148 let (auth_id, auth_options) = store.generate_authentication_challenge(&config, Vec::new());
1149 store.store_authentication_challenge(&auth_id, "alice", vec![0xAB; 32], vec![]);
1150
1151 let rendered = format!("{store:?}");
1152 assert!(rendered.contains("ChallengeStore"), "{rendered}");
1153 assert!(
1154 !rendered.contains(®_id) && !rendered.contains(&auth_id),
1155 "challenge ids must not appear in Debug: {rendered}"
1156 );
1157 assert!(
1158 !rendered.contains(®_options.challenge)
1159 && !rendered.contains(&auth_options.challenge),
1160 "challenge values must not appear in Debug: {rendered}"
1161 );
1162 }
1163}