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;
41
42fn 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
51pub 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#[derive(Debug, Clone)]
86struct RegistrationChallenge {
87 username: String,
88 challenge_bytes: Vec<u8>,
89 created_at: i64,
90}
91
92#[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
101pub struct ChallengeStore {
111 now: Arc<dyn Fn() -> i64 + Send + Sync>,
113 registration_challenges: HashMap<String, RegistrationChallenge>,
115 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 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 #[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 #[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 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 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 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 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 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 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 #[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 #[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#[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 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 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()); 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}