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> {
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#[derive(Debug, Clone)]
87struct RegistrationChallenge {
88 username: String,
89 challenge_bytes: Vec<u8>,
90 created_at: i64,
91}
92
93#[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
102pub struct ChallengeStore {
112 now: Arc<dyn Fn() -> i64 + Send + Sync>,
114 registration_challenges: HashMap<String, RegistrationChallenge>,
116 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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#[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 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 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 #[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 #[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()); 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 #[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 #[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}