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> {
73 if current_sign_count != 0 && new_sign_count < current_sign_count {
74 return Err(WebauthnError::VerificationFailed(format!(
75 "Sign count decreased: {new_sign_count} < {current_sign_count} (possible cloned authenticator)"
76 )));
77 }
78 Ok(())
79}
80
81#[derive(Debug, Clone)]
83struct RegistrationChallenge {
84 username: String,
85 challenge_bytes: Vec<u8>,
86 created_at: i64,
87}
88
89#[derive(Debug, Clone)]
91struct AuthenticationChallenge {
92 username: String,
93 challenge_bytes: Vec<u8>,
94 allowed_credential_ids: Vec<String>,
95 created_at: i64,
96}
97
98pub struct ChallengeStore {
105 now: Arc<dyn Fn() -> i64 + Send + Sync>,
107 registration_challenges: HashMap<String, RegistrationChallenge>,
109 authentication_challenges: HashMap<String, AuthenticationChallenge>,
111}
112
113impl Default for ChallengeStore {
114 fn default() -> Self {
115 Self::new()
116 }
117}
118
119impl std::fmt::Debug for ChallengeStore {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 f.debug_struct("ChallengeStore")
123 .field("pending_registration", &self.registration_challenges.len())
124 .field(
125 "pending_authentication",
126 &self.authentication_challenges.len(),
127 )
128 .finish()
129 }
130}
131
132impl ChallengeStore {
133 #[must_use]
135 pub fn new() -> Self {
136 Self {
137 now: Arc::new(unix_now),
138 registration_challenges: HashMap::new(),
139 authentication_challenges: HashMap::new(),
140 }
141 }
142
143 #[must_use]
146 pub fn with_clock(now: Arc<dyn Fn() -> i64 + Send + Sync>) -> Self {
147 Self {
148 now,
149 registration_challenges: HashMap::new(),
150 authentication_challenges: HashMap::new(),
151 }
152 }
153
154 pub fn store_registration_challenge(
160 &mut self,
161 challenge_id: &str,
162 username: &str,
163 challenge_bytes: Vec<u8>,
164 ) {
165 self.store_registration_challenge_at(challenge_id, username, challenge_bytes, (self.now)());
166 }
167
168 pub fn store_registration_challenge_at(
172 &mut self,
173 challenge_id: &str,
174 username: &str,
175 challenge_bytes: Vec<u8>,
176 created_at: i64,
177 ) {
178 self.registration_challenges.insert(
179 challenge_id.to_string(),
180 RegistrationChallenge {
181 username: username.to_string(),
182 challenge_bytes,
183 created_at,
184 },
185 );
186 }
187
188 pub fn consume_registration_challenge(
198 &mut self,
199 challenge_id: &str,
200 timeout_secs: u64,
201 ) -> Result<(String, Vec<u8>), WebauthnError> {
202 let challenge = self
203 .registration_challenges
204 .remove(challenge_id)
205 .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
206
207 if (self.now)() - challenge.created_at > timeout_secs as i64 {
208 return Err(WebauthnError::ChallengeExpired);
209 }
210
211 Ok((challenge.username, challenge.challenge_bytes))
212 }
213
214 pub fn store_authentication_challenge(
216 &mut self,
217 challenge_id: &str,
218 username: &str,
219 challenge_bytes: Vec<u8>,
220 allowed_credential_ids: Vec<String>,
221 ) {
222 self.store_authentication_challenge_at(
223 challenge_id,
224 username,
225 challenge_bytes,
226 allowed_credential_ids,
227 (self.now)(),
228 );
229 }
230
231 pub fn store_authentication_challenge_at(
234 &mut self,
235 challenge_id: &str,
236 username: &str,
237 challenge_bytes: Vec<u8>,
238 allowed_credential_ids: Vec<String>,
239 created_at: i64,
240 ) {
241 self.authentication_challenges.insert(
242 challenge_id.to_string(),
243 AuthenticationChallenge {
244 username: username.to_string(),
245 challenge_bytes,
246 allowed_credential_ids,
247 created_at,
248 },
249 );
250 }
251
252 pub fn consume_authentication_challenge(
257 &mut self,
258 challenge_id: &str,
259 timeout_secs: u64,
260 ) -> Result<(String, Vec<u8>, Vec<String>), WebauthnError> {
261 let challenge = self
262 .authentication_challenges
263 .remove(challenge_id)
264 .ok_or_else(|| WebauthnError::InvalidChallenge("not found".to_string()))?;
265
266 if (self.now)() - challenge.created_at > timeout_secs as i64 {
267 return Err(WebauthnError::ChallengeExpired);
268 }
269
270 Ok((
271 challenge.username,
272 challenge.challenge_bytes,
273 challenge.allowed_credential_ids,
274 ))
275 }
276
277 #[must_use]
291 pub fn generate_registration_challenge(
292 &self,
293 config: &WebauthnConfig,
294 username: &str,
295 display_name: &str,
296 existing_credential_ids: &[String],
297 ) -> (String, RegistrationOptions) {
298 let challenge_bytes = generate_challenge_bytes();
299 let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
300
301 let mut algorithms = config.allowed_algorithms.clone();
302 algorithms.sort();
303 algorithms.dedup();
304 let pub_key_cred_params = if algorithms.is_empty() {
305 vec![
306 PubKeyCredParam {
307 alg: -7,
308 type_: "public-key".to_string(),
309 },
310 PubKeyCredParam {
311 alg: -257,
312 type_: "public-key".to_string(),
313 },
314 ]
315 } else {
316 algorithms
317 .into_iter()
318 .map(|alg| PubKeyCredParam {
319 alg,
320 type_: "public-key".to_string(),
321 })
322 .collect()
323 };
324
325 let options = RegistrationOptions {
326 challenge: challenge_b64.clone(),
327 rp: RelyingParty {
328 id: config.rp_id.clone(),
329 name: config.rp_name.clone(),
330 },
331 user: WebauthnUser {
332 id: base64_encode_urlsafe(username.as_bytes()),
333 name: display_name.to_string(),
334 display_name: username.to_string(),
335 },
336 pub_key_cred_params,
337 timeout: config.challenge_timeout_secs * 1000,
338 exclude_credentials: existing_credential_ids
339 .iter()
340 .map(|id| ExcludeCredential {
341 id: id.clone(),
342 type_: "public-key".to_string(),
343 transports: None,
344 })
345 .collect(),
346 attestation: "none".to_string(),
347 authenticator_selection: AuthenticatorSelection {
348 resident_key: "preferred".to_string(),
349 user_verification: "preferred".to_string(),
350 },
351 };
352
353 (challenge_b64, options)
354 }
355
356 #[must_use]
362 pub fn generate_authentication_challenge(
363 &self,
364 config: &WebauthnConfig,
365 credential_ids: Vec<String>,
366 ) -> (String, AuthenticationOptions) {
367 let challenge_bytes = generate_challenge_bytes();
368 let challenge_b64 = base64_encode_urlsafe(&challenge_bytes);
369
370 let options = AuthenticationOptions {
371 challenge: challenge_b64.clone(),
372 rp_id: config.rp_id.clone(),
373 allow_credentials: credential_ids
374 .iter()
375 .map(|id| AllowCredential {
376 id: id.clone(),
377 type_: "public-key".to_string(),
378 transports: None,
379 })
380 .collect(),
381 timeout: config.challenge_timeout_secs * 1000,
382 user_verification: "preferred".to_string(),
383 };
384
385 (challenge_b64, options)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 fn test_config() -> WebauthnConfig {
394 WebauthnConfig {
395 rp_id: "localhost".to_string(),
396 rp_name: "Kit Test".to_string(),
397 rp_origins: vec!["http://localhost:8080".to_string()],
398 allowed_algorithms: vec![-7, -257],
399 challenge_timeout_secs: 300,
400 }
401 }
402
403 fn clock_at(secs: i64) -> Arc<dyn Fn() -> i64 + Send + Sync> {
404 Arc::new(move || secs)
405 }
406
407 #[test]
408 fn test_challenge_registration_flow() {
409 let mut store = ChallengeStore::new();
410 let config = test_config();
411
412 let (challenge_id, _options) =
413 store.generate_registration_challenge(&config, "alice", "Alice", &[]);
414
415 store.store_registration_challenge(&challenge_id, "alice", challenge_id.as_bytes().into());
416
417 let (username, bytes) = store
418 .consume_registration_challenge(&challenge_id, 300)
419 .unwrap();
420 assert_eq!(username, "alice");
421 assert_eq!(bytes, challenge_id.as_bytes());
422
423 assert!(store
425 .consume_registration_challenge(&challenge_id, 300)
426 .is_err());
427 }
428
429 #[test]
430 fn test_challenge_expiration() {
431 let mut store = ChallengeStore::new();
432 store.store_registration_challenge_at("ch-1", "alice", vec![0u8; 32], unix_now() - 301);
433
434 let result = store.consume_registration_challenge("ch-1", 300);
435 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
436 }
437
438 #[test]
439 fn test_challenge_not_found() {
440 let mut store = ChallengeStore::new();
441 let result = store.consume_registration_challenge("nonexistent", 300);
442 assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
443 }
444
445 #[test]
446 fn test_authentication_challenge_flow() {
447 let mut store = ChallengeStore::new();
448 let config = test_config();
449
450 let (challenge_id, _options) =
451 store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
452
453 store.store_authentication_challenge(
454 &challenge_id,
455 "alice",
456 vec![0u8; 32],
457 vec!["cred-1".to_string()],
458 );
459
460 let (username, _bytes, allowed) = store
461 .consume_authentication_challenge(&challenge_id, 300)
462 .unwrap();
463 assert_eq!(username, "alice");
464 assert_eq!(allowed, vec!["cred-1".to_string()]);
465
466 assert!(store
467 .consume_authentication_challenge(&challenge_id, 300)
468 .is_err());
469 }
470
471 #[test]
472 fn test_consume_authentication_challenge_not_found() {
473 let mut store = ChallengeStore::new();
474 let result = store.consume_authentication_challenge("nonexistent", 300);
475 assert!(matches!(result, Err(WebauthnError::InvalidChallenge(_))));
476 }
477
478 #[test]
479 fn test_consume_authentication_challenge_expired() {
480 let mut store = ChallengeStore::new();
481 store.store_authentication_challenge_at(
482 "ch-1",
483 "alice",
484 vec![0u8; 32],
485 vec!["cred-1".to_string()],
486 unix_now() - 400,
487 );
488 let result = store.consume_authentication_challenge("ch-1", 300);
489 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
490 }
491
492 #[test]
493 fn test_expiry_with_injected_clock() {
494 let t0 = 1_700_000_000i64;
495 let mut store = ChallengeStore::with_clock(clock_at(t0));
496 store.store_registration_challenge_at("ch", "alice", vec![0u8; 32], t0);
497
498 let mut store = ChallengeStore::with_clock(clock_at(t0 + 301));
499 store.store_registration_challenge_at("ch2", "alice", vec![0u8; 32], t0);
500
501 let result = store.consume_registration_challenge("ch2", 300);
502 assert!(matches!(result, Err(WebauthnError::ChallengeExpired)));
503
504 store.store_registration_challenge_at("ch3", "alice", vec![0u8; 32], t0 + 1);
506 let result = store.consume_registration_challenge("ch3", 300);
507 assert!(result.is_ok());
508 }
509
510 #[test]
511 fn test_registration_options_serialization() {
512 let store = ChallengeStore::new();
513 let config = test_config();
514 let (_, options) = store.generate_registration_challenge(
515 &config,
516 "alice",
517 "Alice Johnson",
518 &["existing-1".to_string()],
519 );
520
521 let json = serde_json::to_string(&options).unwrap();
522 let deser: RegistrationOptions = serde_json::from_str(&json).unwrap();
523 assert_eq!(deser.rp.id, "localhost");
524 assert_eq!(deser.user.display_name, "alice");
525 assert_eq!(deser.exclude_credentials.len(), 1);
526 assert_eq!(deser.pub_key_cred_params.len(), 2);
527 }
528
529 #[test]
530 fn test_authentication_options_serialization() {
531 let store = ChallengeStore::new();
532 let config = test_config();
533 let (_, options) =
534 store.generate_authentication_challenge(&config, vec!["cred-1".to_string()]);
535
536 let json = serde_json::to_string(&options).unwrap();
537 let deser: AuthenticationOptions = serde_json::from_str(&json).unwrap();
538 assert_eq!(deser.rp_id, "localhost");
539 assert_eq!(deser.allow_credentials.len(), 1);
540 }
541
542 #[test]
543 fn test_registration_options_configurable() {
544 let store = ChallengeStore::new();
545 let config = WebauthnConfig {
546 rp_id: "custom.example.com".to_string(),
547 rp_name: "Custom App".to_string(),
548 rp_origins: vec!["https://custom.example.com".to_string()],
549 allowed_algorithms: vec![-7, -257],
550 challenge_timeout_secs: 600,
551 };
552 let (_, options) = store.generate_registration_challenge(
553 &config,
554 "alice",
555 "Alice",
556 &["existing-cred".to_string()],
557 );
558 assert_eq!(options.rp.id, "custom.example.com");
559 assert_eq!(options.rp.name, "Custom App");
560 assert_eq!(options.timeout, 600_000);
561 assert_eq!(options.exclude_credentials.len(), 1);
562 }
563
564 #[test]
565 fn test_authentication_options_configurable() {
566 let store = ChallengeStore::new();
567 let config = WebauthnConfig {
568 rp_id: "custom.example.com".to_string(),
569 rp_name: "Custom App".to_string(),
570 rp_origins: vec![],
571 allowed_algorithms: vec![],
572 challenge_timeout_secs: 120,
573 };
574 let (_, options) = store
575 .generate_authentication_challenge(&config, vec!["c1".to_string(), "c2".to_string()]);
576 assert_eq!(options.rp_id, "custom.example.com");
577 assert_eq!(options.timeout, 120_000);
578 assert_eq!(options.allow_credentials.len(), 2);
579 }
580
581 #[test]
582 fn test_pub_key_cred_params_from_allowed_algorithms() {
583 let store = ChallengeStore::new();
584 let config = WebauthnConfig {
585 allowed_algorithms: vec![-257, -7, -257],
586 ..test_config()
587 };
588 let (_, options) = store.generate_registration_challenge(&config, "alice", "Alice", &[]);
589 let algs: Vec<i32> = options.pub_key_cred_params.iter().map(|p| p.alg).collect();
590 assert_eq!(algs, vec![-257, -7]);
591 assert!(options
592 .pub_key_cred_params
593 .iter()
594 .all(|p| p.type_ == "public-key"));
595 }
596
597 #[test]
598 fn test_user_id_is_base64_of_username() {
599 let store = ChallengeStore::new();
600 let (_, options) =
601 store.generate_registration_challenge(&test_config(), "alice", "Alice", &[]);
602 let decoded = crate::crypto::base64_decode_urlsafe(&options.user.id).unwrap();
603 assert_eq!(decoded, b"alice");
604 }
605
606 #[test]
607 fn test_check_sign_count_first_use_zero_stored() {
608 assert!(check_sign_count(0, 0).is_ok());
609 assert!(check_sign_count(0, 42).is_ok());
610 assert!(check_sign_count(0, u32::MAX).is_ok());
611 }
612
613 #[test]
614 fn test_check_sign_count_monotonic() {
615 assert!(check_sign_count(1, 2).is_ok());
616 assert!(check_sign_count(5, 5).is_ok()); assert!(check_sign_count(u32::MAX, u32::MAX).is_ok());
618 assert!(check_sign_count(u32::MAX - 1, u32::MAX).is_ok());
619 }
620
621 #[test]
622 fn test_check_sign_count_decrease_rejected() {
623 assert!(matches!(
624 check_sign_count(10, 5),
625 Err(WebauthnError::VerificationFailed(_))
626 ));
627 assert!(matches!(
628 check_sign_count(u32::MAX, 0),
629 Err(WebauthnError::VerificationFailed(_))
630 ));
631 assert!(matches!(
632 check_sign_count(1, 0),
633 Err(WebauthnError::VerificationFailed(_))
634 ));
635 }
636
637 #[test]
638 fn test_debug_does_not_leak_challenge_bytes() {
639 let mut store = ChallengeStore::new();
640 store.store_registration_challenge("ch", "alice", b"secret-challenge".to_vec());
641 let dbg = format!("{store:?}");
642 assert!(!dbg.contains("secret-challenge"));
643 }
644}