1use crate::attestation::{verify_attestation, AttestationPolicy};
29use crate::challenge::check_sign_count;
30use crate::credential::{AuthenticationResult, RegistrationResult};
31use crate::crypto::{
32 alg_to_name, base64_decode_urlsafe, base64_encode_urlsafe, cbor_bytes, parse_cose_key,
33 verify_cose_signature,
34};
35use crate::error::WebauthnError;
36use crate::policy::{BackupPolicy, CredentialPolicy, UserVerificationPolicy};
37
38#[derive(Debug, Clone)]
40struct AuthenticatorData {
41 rp_id_hash: Vec<u8>,
43 flags: u8,
45 sign_count: u32,
47 credential_public_key_cose: Option<Vec<u8>>,
49 credential_id: Option<Vec<u8>>,
51 aaguid: Option<[u8; 16]>,
53 backup_eligible: bool,
56 backup_state: bool,
58}
59
60const FLAG_UP: u8 = 0x01;
62const FLAG_UV: u8 = 0x04;
64const FLAG_BE: u8 = 0x08;
66const FLAG_BS: u8 = 0x10;
68const FLAG_AT: u8 = 0x40;
70#[allow(dead_code)]
72const FLAG_ED: u8 = 0x80;
73
74const AUTH_DATA_MIN_LEN: usize = 37;
76
77const ATTESTED_CREDENTIAL_PREFIX_LEN: usize = 18;
79
80fn parse_authenticator_data(auth_data: &[u8]) -> Result<AuthenticatorData, WebauthnError> {
96 if auth_data.len() < AUTH_DATA_MIN_LEN {
97 return Err(WebauthnError::VerificationFailed(format!(
98 "Authenticator data too short: {} bytes (minimum {})",
99 auth_data.len(),
100 AUTH_DATA_MIN_LEN
101 )));
102 }
103
104 let truncated =
108 || WebauthnError::VerificationFailed("Authenticator data truncated".to_string());
109 let rp_id_hash = auth_data.get(..32).ok_or_else(truncated)?.to_vec();
110 let flags = *auth_data.get(32).ok_or_else(truncated)?;
111 let sign_count = u32::from_be_bytes(
112 auth_data
113 .get(33..37)
114 .ok_or_else(truncated)?
115 .try_into()
116 .map_err(|_| truncated())?,
117 );
118
119 let mut offset = 37;
120 let mut credential_id = None;
121 let mut credential_public_key_cose = None;
122 let mut aaguid = None;
123
124 if flags & FLAG_AT != 0 {
125 if auth_data.len() < offset + ATTESTED_CREDENTIAL_PREFIX_LEN {
126 return Err(WebauthnError::VerificationFailed(
127 "Attested credential data truncated (AAGUID + length)".to_string(),
128 ));
129 }
130 let aaguid_bytes = auth_data
131 .get(offset..offset + 16)
132 .ok_or_else(truncated)?
133 .to_vec();
134 let aaguid_arr: [u8; 16] = aaguid_bytes.try_into().map_err(|_| truncated())?;
135 aaguid = Some(aaguid_arr);
136 offset += 16;
137
138 let len_hi = *auth_data.get(offset).ok_or_else(truncated)?;
139 let len_lo = *auth_data.get(offset + 1).ok_or_else(truncated)?;
140 let cred_id_len = u16::from_be_bytes([len_hi, len_lo]) as usize;
141 offset += 2;
142
143 if auth_data.len() < offset + cred_id_len {
144 return Err(WebauthnError::VerificationFailed(
145 "Attested credential data truncated (credential ID)".to_string(),
146 ));
147 }
148 credential_id = Some(
149 auth_data
150 .get(offset..offset + cred_id_len)
151 .ok_or_else(truncated)?
152 .to_vec(),
153 );
154 offset += cred_id_len;
155
156 if offset >= auth_data.len() {
157 return Err(WebauthnError::VerificationFailed(
158 "Attested credential data truncated (public key)".to_string(),
159 ));
160 }
161 credential_public_key_cose = Some(auth_data.get(offset..).ok_or_else(truncated)?.to_vec());
162 }
163
164 Ok(AuthenticatorData {
165 rp_id_hash,
166 flags,
167 sign_count,
168 credential_public_key_cose,
169 credential_id,
170 aaguid,
171 backup_eligible: flags & FLAG_BE != 0,
172 backup_state: flags & FLAG_BS != 0,
173 })
174}
175
176fn enforce_flag_policy(flags: u8, policy: &CredentialPolicy) -> Result<(), WebauthnError> {
190 if policy.user_verification == UserVerificationPolicy::Required && flags & FLAG_UV == 0 {
191 return Err(WebauthnError::UserVerificationRequired);
192 }
193 if policy.backup == BackupPolicy::RequireDeviceBound && flags & FLAG_BE != 0 {
194 return Err(WebauthnError::PolicyViolation(
195 "backup policy requires device-bound credentials, but the credential \
196 is backup-eligible (BE=1)"
197 .to_string(),
198 ));
199 }
200 Ok(())
201}
202
203fn enforce_algorithm_allowlist(alg: i32, policy: &CredentialPolicy) -> Result<(), WebauthnError> {
210 if !policy.allowed_algorithms.is_empty() && !policy.allowed_algorithms.contains(&alg) {
211 return Err(WebauthnError::UnsupportedAlgorithm(alg));
212 }
213 Ok(())
214}
215
216fn validate_client_data(
224 client_data_json_b64: &str,
225 challenge_bytes: &[u8],
226 expected_type: &str,
227 rp_id: &str,
228 rp_origins: &[String],
229) -> Result<Vec<u8>, WebauthnError> {
230 let client_data_bytes = base64_decode_urlsafe(client_data_json_b64)?;
231 let client_data: serde_json::Value = serde_json::from_slice(&client_data_bytes)
232 .map_err(|e| WebauthnError::VerificationFailed(format!("client data parse error: {e}")))?;
233
234 let client_challenge = client_data
235 .get("challenge")
236 .and_then(|v| v.as_str())
237 .ok_or_else(|| {
238 WebauthnError::VerificationFailed("missing challenge in client data".to_string())
239 })?;
240 let client_challenge_bytes = base64_decode_urlsafe(client_challenge)?;
241 if client_challenge_bytes != challenge_bytes {
242 return Err(WebauthnError::VerificationFailed(
243 "challenge mismatch".to_string(),
244 ));
245 }
246
247 let typ = client_data
248 .get("type")
249 .and_then(|v| v.as_str())
250 .ok_or_else(|| {
251 WebauthnError::VerificationFailed("missing type in client data".to_string())
252 })?;
253 if typ != expected_type {
254 return Err(WebauthnError::VerificationFailed(format!(
255 "wrong type: {typ}"
256 )));
257 }
258
259 let origin = client_data
260 .get("origin")
261 .and_then(|v| v.as_str())
262 .ok_or_else(|| {
263 WebauthnError::VerificationFailed("missing origin in client data".to_string())
264 })?;
265 if !rp_origins.iter().any(|o| o == origin) {
266 return Err(WebauthnError::VerificationFailed(format!(
267 "origin '{origin}' not allowed"
268 )));
269 }
270
271 if let Some(rp) = client_data.get("rpId").and_then(|v| v.as_str()) {
273 if rp != rp_id {
274 return Err(WebauthnError::VerificationFailed(format!(
275 "rpId mismatch: client sent '{rp}', expected '{rp_id}'"
276 )));
277 }
278 }
279
280 Ok(client_data_bytes)
281}
282
283#[allow(clippy::too_many_arguments)]
316pub fn verify_registration(
317 challenge_bytes: &[u8],
318 client_data_json_b64: &str,
319 attestation_object_b64: &str,
320 existing_credential_id: &str,
321 rp_id: &str,
322 rp_origins: &[String],
323 attestation_policy: &AttestationPolicy,
324 credential_policy: &CredentialPolicy,
325) -> Result<RegistrationResult, WebauthnError> {
326 let client_data_bytes = validate_client_data(
327 client_data_json_b64,
328 challenge_bytes,
329 "webauthn.create",
330 rp_id,
331 rp_origins,
332 )?;
333
334 let attestation_bytes = base64_decode_urlsafe(attestation_object_b64)?;
335 let attestation_val: ciborium::Value = ciborium::de::from_reader(&attestation_bytes[..])
336 .map_err(|e| {
337 WebauthnError::AttestationError(format!("attestation object CBOR parse error: {e}"))
338 })?;
339
340 let attestation_entries = match &attestation_val {
343 ciborium::Value::Map(entries) => entries.clone(),
344 other => {
345 let hex = |b: &[u8]| {
346 let mut out = String::new();
347 for x in b.iter().take(8) {
348 use std::fmt::Write as _;
349 let _ = write!(out, "{x:02x}");
350 }
351 out
352 };
353 return Err(WebauthnError::AttestationError(format!(
354 "attestation object is not a CBOR map (decoded {} bytes, first: {}, value kind: {:?})",
355 attestation_bytes.len(),
356 hex(attestation_bytes.get(..8).unwrap_or(&attestation_bytes)),
357 match other {
358 ciborium::Value::Bytes(_) => "bytes",
359 ciborium::Value::Text(_) => "text",
360 ciborium::Value::Array(_) => "array",
361 ciborium::Value::Integer(_) => "integer",
362 ciborium::Value::Bool(_) => "bool",
363 _ => "other",
364 },
365 )));
366 }
367 };
368
369 let mut fmt: Option<String> = None;
370 let mut auth_data_bytes: Option<Vec<u8>> = None;
371 let mut att_stmt: Option<ciborium::Value> = None;
372
373 for (key, val) in &attestation_entries {
374 if let ciborium::Value::Text(k) = key {
375 match k.as_str() {
376 "fmt" => {
377 if let ciborium::Value::Text(s) = val {
378 fmt = Some(s.clone());
379 }
380 }
381 "authData" => {
382 if let Some(b) = cbor_bytes(val) {
383 auth_data_bytes = Some(b);
384 }
385 }
386 "attStmt" => {
387 att_stmt = Some(val.clone());
388 }
389 _ => {}
390 }
391 }
392 }
393
394 let fmt = fmt.unwrap_or_else(|| "none".to_string());
395 let auth_data_bytes = auth_data_bytes.ok_or_else(|| {
396 WebauthnError::AttestationError("missing authData in attestation object".to_string())
397 })?;
398
399 let auth_data = parse_authenticator_data(&auth_data_bytes)?;
400
401 use sha2::Digest;
402 let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
403 if auth_data.rp_id_hash != computed_rp_id_hash {
404 return Err(WebauthnError::VerificationFailed(format!(
405 "rpId hash mismatch: computed {:x?}, got {:x?}",
406 computed_rp_id_hash, auth_data.rp_id_hash
407 )));
408 }
409
410 if auth_data.flags & FLAG_UP == 0 {
411 return Err(WebauthnError::VerificationFailed(
412 "User Present flag not set".to_string(),
413 ));
414 }
415
416 if auth_data.flags & FLAG_AT == 0 {
417 return Err(WebauthnError::VerificationFailed(
418 "Attested Credential Data flag not set during registration".to_string(),
419 ));
420 }
421
422 enforce_flag_policy(auth_data.flags, credential_policy)?;
424
425 let credential_id = auth_data.credential_id.ok_or_else(|| {
426 WebauthnError::VerificationFailed("no credential ID in attested data".to_string())
427 })?;
428 let credential_id_b64 = base64_encode_urlsafe(&credential_id);
429
430 if existing_credential_id == credential_id_b64 {
431 return Err(WebauthnError::DuplicateCredential(credential_id_b64));
432 }
433
434 let public_key_cose = auth_data.credential_public_key_cose.ok_or_else(|| {
435 WebauthnError::VerificationFailed("no public key in attested data".to_string())
436 })?;
437
438 let (alg, cose_key) = parse_cose_key(&public_key_cose)?;
439
440 enforce_algorithm_allowlist(alg, credential_policy)?;
442
443 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
447 let aaguid = auth_data.aaguid.unwrap_or([0u8; 16]);
448 let attestation = verify_attestation(
449 &fmt,
450 att_stmt.as_ref(),
451 &auth_data_bytes,
452 &client_data_hash,
453 &credential_id,
454 alg,
455 &cose_key,
456 aaguid,
457 attestation_policy,
458 )?;
459
460 let user_verified = auth_data.flags & FLAG_UV != 0;
461
462 Ok(RegistrationResult {
463 credential_id: credential_id_b64,
464 device_name: format!("WebAuthn ({})", alg_to_name(alg)),
465 attestation_format: fmt,
466 attestation,
467 user_verified,
468 backup_eligible: auth_data.backup_eligible,
469 backup_state: auth_data.backup_state,
470 })
471}
472
473#[derive(Debug, Clone)]
478pub struct AuthenticationParams {
479 pub challenge_bytes: Vec<u8>,
481 pub client_data_json_b64: String,
483 pub authenticator_data_b64: String,
485 pub signature_b64: String,
487 pub credential_id_b64: String,
489 pub public_key_cose: Vec<u8>,
491 pub current_sign_count: u32,
493 pub allowed_credential_ids: Vec<String>,
495 pub rp_id: String,
497 pub rp_origins: Vec<String>,
499 pub policy: CredentialPolicy,
508}
509
510pub fn verify_authentication(
540 params: &AuthenticationParams,
541) -> Result<AuthenticationResult, WebauthnError> {
542 let AuthenticationParams {
543 challenge_bytes,
544 client_data_json_b64,
545 authenticator_data_b64,
546 signature_b64,
547 credential_id_b64,
548 public_key_cose,
549 current_sign_count,
550 allowed_credential_ids,
551 rp_id,
552 rp_origins,
553 policy,
554 } = params;
555
556 if !allowed_credential_ids.contains(credential_id_b64) {
557 return Err(WebauthnError::VerificationFailed(
558 "credential ID not in allowed list".to_string(),
559 ));
560 }
561
562 let client_data_bytes = validate_client_data(
563 client_data_json_b64,
564 challenge_bytes,
565 "webauthn.get",
566 rp_id,
567 rp_origins,
568 )?;
569
570 let authenticator_data = base64_decode_urlsafe(authenticator_data_b64)?;
571 let auth_data = parse_authenticator_data(&authenticator_data)?;
572
573 use sha2::Digest;
574 let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
575 if auth_data.rp_id_hash != computed_rp_id_hash {
576 return Err(WebauthnError::VerificationFailed(format!(
577 "rpId hash mismatch: computed {:x?}, got {:x?}",
578 computed_rp_id_hash, auth_data.rp_id_hash
579 )));
580 }
581
582 if auth_data.flags & FLAG_UP == 0 {
583 return Err(WebauthnError::VerificationFailed(
584 "User Present flag not set".to_string(),
585 ));
586 }
587
588 enforce_flag_policy(auth_data.flags, policy)?;
590
591 check_sign_count(*current_sign_count, auth_data.sign_count)?;
592
593 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
594
595 let mut signed_data = Vec::with_capacity(authenticator_data.len() + 32);
596 signed_data.extend_from_slice(&authenticator_data);
597 signed_data.extend_from_slice(&client_data_hash);
598
599 let signature = base64_decode_urlsafe(signature_b64)?;
600
601 let (alg, cose_key) = parse_cose_key(public_key_cose)?;
602 verify_cose_signature(alg, &cose_key, &signed_data, &signature)?;
603
604 let user_verified = auth_data.flags & FLAG_UV != 0;
605 let new_sign_count = auth_data.sign_count;
606
607 Ok(AuthenticationResult {
608 credential_id: credential_id_b64.clone(),
609 new_sign_count,
610 user_verified,
611 backup_state: auth_data.backup_state,
612 backup_eligible: auth_data.backup_eligible,
613 })
614}
615
616#[allow(
620 clippy::unwrap_used,
621 clippy::expect_used,
622 clippy::indexing_slicing,
623 clippy::panic
624)]
625#[cfg(test)]
626mod tests {
627 use super::*;
628 use crate::crypto::tests::{build_cose_ec2_key, build_cose_rsa_key};
629 use crate::crypto::{base64_encode_urlsafe, generate_challenge_bytes, COSE_ALG_RS256};
630 use ring::signature::KeyPair as _;
631
632 fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
633 use ciborium::Value;
634 let map = vec![
636 (
637 Value::Text("fmt".to_string()),
638 Value::Text("none".to_string()),
639 ),
640 (Value::Text("attStmt".to_string()), Value::Map(vec![])),
641 (
642 Value::Text("authData".to_string()),
643 Value::Bytes(auth_data.to_vec()),
644 ),
645 ];
646 let mut buf = Vec::new();
647 ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
648 buf
649 }
650
651 fn build_auth_data_with_credential(
652 rp_id: &str,
653 flags: u8,
654 sign_count: u32,
655 credential_id: &[u8],
656 cose_key: &[u8],
657 ) -> Vec<u8> {
658 use sha2::Digest;
659 let rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
660 let mut auth_data = Vec::new();
661 auth_data.extend_from_slice(&rp_id_hash);
662 auth_data.push(flags);
663 auth_data.extend_from_slice(&sign_count.to_be_bytes());
664
665 if flags & FLAG_AT != 0 {
666 auth_data.extend_from_slice(&[0u8; 16]);
667 auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
668 auth_data.extend_from_slice(credential_id);
669 auth_data.extend_from_slice(cose_key);
670 }
671
672 auth_data
673 }
674
675 #[test]
676 fn test_parse_authenticator_data_minimal() {
677 let mut auth_data = vec![0u8; 37];
678 auth_data[..32].copy_from_slice(&[0xAA; 32]);
679 auth_data[32] = FLAG_UP;
680 auth_data[33..37].copy_from_slice(&1u32.to_be_bytes());
681
682 let parsed = parse_authenticator_data(&auth_data).unwrap();
683 assert_eq!(parsed.rp_id_hash, vec![0xAA; 32]);
684 assert_eq!(parsed.flags, FLAG_UP);
685 assert_eq!(parsed.sign_count, 1);
686 assert!(parsed.credential_public_key_cose.is_none());
687 assert!(parsed.credential_id.is_none());
688 }
689
690 #[test]
691 fn test_parse_authenticator_data_with_attested_credential() {
692 let credential_id = vec![0x01, 0x02, 0x03, 0x04];
693 let public_key_cose = vec![0x10, 0x20, 0x30];
694
695 let total_len = 37 + 16 + 2 + credential_id.len() + public_key_cose.len();
696 let mut auth_data = vec![0u8; total_len];
697 auth_data[32] = FLAG_UP | FLAG_AT;
698 auth_data[33..37].copy_from_slice(&5u32.to_be_bytes());
699
700 let offset = 37 + 16;
701 auth_data[offset..offset + 2].copy_from_slice(&(credential_id.len() as u16).to_be_bytes());
702 auth_data[offset + 2..offset + 2 + credential_id.len()].copy_from_slice(&credential_id);
703 let pk_offset = offset + 2 + credential_id.len();
704 auth_data[pk_offset..].copy_from_slice(&public_key_cose);
705
706 let parsed = parse_authenticator_data(&auth_data).unwrap();
707 assert_eq!(parsed.flags, FLAG_UP | FLAG_AT);
708 assert_eq!(parsed.sign_count, 5);
709 assert_eq!(parsed.credential_id, Some(credential_id));
710 assert_eq!(parsed.credential_public_key_cose, Some(public_key_cose));
711 }
712
713 #[test]
714 fn test_parse_authenticator_data_too_short() {
715 let auth_data = vec![0u8; 10];
716 let result = parse_authenticator_data(&auth_data);
717 assert!(result.is_err());
718 }
719
720 #[test]
721 fn test_verify_registration_valid_es256() {
722 use ring::signature::EcdsaKeyPair;
723
724 let rng = ring::rand::SystemRandom::new();
725 let pkcs8 =
726 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
727 .unwrap();
728 let key_pair = EcdsaKeyPair::from_pkcs8(
729 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
730 pkcs8.as_ref(),
731 &rng,
732 )
733 .unwrap();
734
735 let pub_bytes = key_pair.public_key().as_ref().to_vec();
736 let x = pub_bytes[1..33].to_vec();
737 let y = pub_bytes[33..65].to_vec();
738 let cose_key = build_cose_ec2_key(&x, &y);
739
740 let credential_id = vec![0x01, 0x02, 0x03, 0x04];
741 let auth_data = build_auth_data_with_credential(
742 "localhost",
743 FLAG_UP | FLAG_AT,
744 0,
745 &credential_id,
746 &cose_key,
747 );
748 let att_obj = build_attestation_object(&auth_data);
749
750 let challenge = generate_challenge_bytes();
751 let challenge_b64 = base64_encode_urlsafe(&challenge);
752
753 let client_data = serde_json::json!({
754 "type": "webauthn.create",
755 "challenge": challenge_b64,
756 "origin": "http://localhost:8080",
757 });
758 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
759 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
760
761 let result = verify_registration(
762 &challenge,
763 &client_data_b64,
764 &att_obj_b64,
765 "different-id",
766 "localhost",
767 &["http://localhost:8080".to_string()],
768 &AttestationPolicy::default(),
769 &CredentialPolicy::default(),
770 )
771 .unwrap();
772
773 assert_eq!(result.credential_id, base64_encode_urlsafe(&credential_id));
774 assert!(result.attestation_format == "none");
775 }
776
777 #[test]
778 fn test_verify_registration_challenge_mismatch() {
779 let auth_data = build_auth_data_with_credential(
780 "localhost",
781 FLAG_UP | FLAG_AT,
782 0,
783 &[0x01],
784 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
785 );
786 let att_obj = build_attestation_object(&auth_data);
787
788 let challenge = generate_challenge_bytes();
789 let _challenge_b64 = base64_encode_urlsafe(&challenge);
790
791 let client_data = serde_json::json!({
792 "type": "webauthn.create",
793 "challenge": base64_encode_urlsafe(&[1u8; 32]),
794 "origin": "http://localhost:8080",
795 });
796 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
797 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
798
799 let result = verify_registration(
800 &challenge,
801 &client_data_b64,
802 &att_obj_b64,
803 "different",
804 "localhost",
805 &["http://localhost:8080".to_string()],
806 &AttestationPolicy::default(),
807 &CredentialPolicy::default(),
808 );
809 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
810 }
811
812 #[test]
813 fn test_verify_registration_wrong_type() {
814 let auth_data = build_auth_data_with_credential(
815 "localhost",
816 FLAG_UP | FLAG_AT,
817 0,
818 &[0x01],
819 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
820 );
821 let att_obj = build_attestation_object(&auth_data);
822
823 let challenge = generate_challenge_bytes();
824 let challenge_b64 = base64_encode_urlsafe(&challenge);
825
826 let client_data = serde_json::json!({
827 "type": "webauthn.get",
828 "challenge": challenge_b64,
829 "origin": "http://localhost:8080",
830 });
831 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
832 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
833
834 let result = verify_registration(
835 &challenge,
836 &client_data_b64,
837 &att_obj_b64,
838 "different",
839 "localhost",
840 &["http://localhost:8080".to_string()],
841 &AttestationPolicy::default(),
842 &CredentialPolicy::default(),
843 );
844 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
845 }
846
847 #[test]
848 fn test_verify_registration_wrong_origin() {
849 let auth_data = build_auth_data_with_credential(
850 "localhost",
851 FLAG_UP | FLAG_AT,
852 0,
853 &[0x01],
854 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
855 );
856 let att_obj = build_attestation_object(&auth_data);
857
858 let challenge = generate_challenge_bytes();
859 let challenge_b64 = base64_encode_urlsafe(&challenge);
860
861 let client_data = serde_json::json!({
862 "type": "webauthn.create",
863 "challenge": challenge_b64,
864 "origin": "http://evil.com",
865 });
866 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
867 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
868
869 let result = verify_registration(
870 &challenge,
871 &client_data_b64,
872 &att_obj_b64,
873 "different",
874 "localhost",
875 &["http://localhost:8080".to_string()],
876 &AttestationPolicy::default(),
877 &CredentialPolicy::default(),
878 );
879 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
880 }
881
882 #[test]
883 fn test_verify_registration_duplicate() {
884 let auth_data = build_auth_data_with_credential(
885 "localhost",
886 FLAG_UP | FLAG_AT,
887 0,
888 &[0x01, 0x02, 0x03],
889 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
890 );
891 let att_obj = build_attestation_object(&auth_data);
892
893 let challenge = generate_challenge_bytes();
894 let challenge_b64 = base64_encode_urlsafe(&challenge);
895
896 let client_data = serde_json::json!({
897 "type": "webauthn.create",
898 "challenge": challenge_b64,
899 "origin": "http://localhost:8080",
900 });
901 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
902 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
903 let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02, 0x03]);
904
905 let result = verify_registration(
906 &challenge,
907 &client_data_b64,
908 &att_obj_b64,
909 &cred_id_b64,
910 "localhost",
911 &["http://localhost:8080".to_string()],
912 &AttestationPolicy::default(),
913 &CredentialPolicy::default(),
914 );
915 assert!(matches!(result, Err(WebauthnError::DuplicateCredential(_))));
916 }
917
918 #[test]
919 fn test_verify_registration_wrong_rp_id_hash() {
920 let credential_id = vec![0x01];
921 let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
922 let auth_data = build_auth_data_with_credential(
923 "evil.com",
924 FLAG_UP | FLAG_AT,
925 0,
926 &credential_id,
927 &cose_key,
928 );
929 let att_obj = build_attestation_object(&auth_data);
930
931 let challenge = generate_challenge_bytes();
932 let challenge_b64 = base64_encode_urlsafe(&challenge);
933
934 let client_data = serde_json::json!({
935 "type": "webauthn.create",
936 "challenge": challenge_b64,
937 "origin": "http://localhost:8080",
938 });
939 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
940 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
941
942 let result = verify_registration(
943 &challenge,
944 &client_data_b64,
945 &att_obj_b64,
946 "different",
947 "localhost",
948 &["http://localhost:8080".to_string()],
949 &AttestationPolicy::default(),
950 &CredentialPolicy::default(),
951 );
952 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
953 }
954
955 #[test]
956 fn test_verify_registration_missing_up_flag() {
957 let credential_id = vec![0x01];
958 let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
959 let auth_data =
960 build_auth_data_with_credential("localhost", FLAG_AT, 0, &credential_id, &cose_key);
961 let att_obj = build_attestation_object(&auth_data);
962
963 let challenge = generate_challenge_bytes();
964 let challenge_b64 = base64_encode_urlsafe(&challenge);
965
966 let client_data = serde_json::json!({
967 "type": "webauthn.create",
968 "challenge": challenge_b64,
969 "origin": "http://localhost:8080",
970 });
971 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
972 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
973
974 let result = verify_registration(
975 &challenge,
976 &client_data_b64,
977 &att_obj_b64,
978 "different",
979 "localhost",
980 &["http://localhost:8080".to_string()],
981 &AttestationPolicy::default(),
982 &CredentialPolicy::default(),
983 );
984 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
985 }
986
987 #[test]
991 fn registration_rejects_missing_at_flag() {
992 let auth_data = build_auth_data_with_credential(
993 "localhost",
994 FLAG_UP, 0,
996 &[0x01],
997 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
998 );
999 let att_obj = build_attestation_object(&auth_data);
1000
1001 let challenge = generate_challenge_bytes();
1002 let challenge_b64 = base64_encode_urlsafe(&challenge);
1003
1004 let client_data = serde_json::json!({
1005 "type": "webauthn.create",
1006 "challenge": challenge_b64,
1007 "origin": "http://localhost:8080",
1008 });
1009 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1010 let att_obj_b64 = base64_encode_urlsafe(&att_obj);
1011
1012 let result = verify_registration(
1013 &challenge,
1014 &client_data_b64,
1015 &att_obj_b64,
1016 "different",
1017 "localhost",
1018 &["http://localhost:8080".to_string()],
1019 &AttestationPolicy::default(),
1020 &CredentialPolicy::default(),
1021 );
1022 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1023 }
1024
1025 #[test]
1026 fn test_verify_authentication_valid_es256() {
1027 use ring::signature::EcdsaKeyPair;
1028
1029 let rng = ring::rand::SystemRandom::new();
1030 let pkcs8 =
1031 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1032 .unwrap();
1033 let key_pair = EcdsaKeyPair::from_pkcs8(
1034 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1035 pkcs8.as_ref(),
1036 &rng,
1037 )
1038 .unwrap();
1039
1040 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1041 let x = pub_bytes[1..33].to_vec();
1042 let y = pub_bytes[33..65].to_vec();
1043 let cose_key = build_cose_ec2_key(&x, &y);
1044
1045 let challenge = generate_challenge_bytes();
1046 let challenge_b64 = base64_encode_urlsafe(&challenge);
1047
1048 use sha2::Digest;
1049 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1050 let mut auth_data = Vec::new();
1051 auth_data.extend_from_slice(&rp_id_hash);
1052 auth_data.push(FLAG_UP);
1053 auth_data.extend_from_slice(&1u32.to_be_bytes());
1054
1055 let client_data = serde_json::json!({
1056 "type": "webauthn.get",
1057 "challenge": challenge_b64,
1058 "origin": "http://localhost:8080",
1059 });
1060 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1061 let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1062 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1063
1064 let mut signed_data = Vec::new();
1065 signed_data.extend_from_slice(&auth_data);
1066 signed_data.extend_from_slice(&client_data_hash);
1067
1068 let signature = key_pair.sign(&rng, &signed_data).unwrap();
1069
1070 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1071 let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1072 let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02]);
1073
1074 let result = verify_authentication(&AuthenticationParams {
1075 challenge_bytes: challenge.clone(),
1076 client_data_json_b64: client_data_b64,
1077 authenticator_data_b64: auth_data_b64,
1078 signature_b64: sig_b64,
1079 credential_id_b64: cred_id_b64.clone(),
1080 public_key_cose: cose_key,
1081 current_sign_count: 0,
1082 allowed_credential_ids: vec![cred_id_b64.clone()],
1083 rp_id: "localhost".to_string(),
1084 rp_origins: vec!["http://localhost:8080".to_string()],
1085 policy: CredentialPolicy::default(),
1086 })
1087 .unwrap();
1088
1089 assert_eq!(result.credential_id, cred_id_b64);
1090 assert_eq!(result.new_sign_count, 1);
1091 }
1092
1093 #[test]
1094 fn test_verify_authentication_wrong_signature() {
1095 use ring::signature::EcdsaKeyPair;
1096
1097 let rng = ring::rand::SystemRandom::new();
1098 let pkcs8 =
1099 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1100 .unwrap();
1101 let key_pair = EcdsaKeyPair::from_pkcs8(
1102 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1103 pkcs8.as_ref(),
1104 &rng,
1105 )
1106 .unwrap();
1107
1108 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1109 let x = pub_bytes[1..33].to_vec();
1110 let y = pub_bytes[33..65].to_vec();
1111 let cose_key = build_cose_ec2_key(&x, &y);
1112
1113 let challenge = generate_challenge_bytes();
1114 let challenge_b64 = base64_encode_urlsafe(&challenge);
1115
1116 use sha2::Digest;
1117 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1118 let mut auth_data = Vec::new();
1119 auth_data.extend_from_slice(&rp_id_hash);
1120 auth_data.push(FLAG_UP);
1121 auth_data.extend_from_slice(&1u32.to_be_bytes());
1122
1123 let client_data = serde_json::json!({
1124 "type": "webauthn.get",
1125 "challenge": challenge_b64,
1126 "origin": "http://localhost:8080",
1127 });
1128 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1129 let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1130 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1131
1132 let mut signed_data = Vec::new();
1133 signed_data.extend_from_slice(&auth_data);
1134 signed_data.extend_from_slice(&client_data_hash);
1135
1136 let mut wrong_sig = vec![0u8; 64];
1137 wrong_sig[0] = 0xFF;
1138
1139 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1140 let sig_b64 = base64_encode_urlsafe(&wrong_sig);
1141 let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1142
1143 let result = verify_authentication(&AuthenticationParams {
1144 challenge_bytes: challenge.clone(),
1145 client_data_json_b64: client_data_b64,
1146 authenticator_data_b64: auth_data_b64,
1147 signature_b64: sig_b64,
1148 credential_id_b64: cred_id_b64.clone(),
1149 public_key_cose: cose_key,
1150 current_sign_count: 0,
1151 allowed_credential_ids: vec![cred_id_b64],
1152 rp_id: "localhost".to_string(),
1153 rp_origins: vec!["http://localhost:8080".to_string()],
1154 policy: CredentialPolicy::default(),
1155 });
1156 assert!(matches!(
1157 result,
1158 Err(WebauthnError::SignatureVerificationFailed)
1159 ));
1160 }
1161
1162 #[test]
1163 fn test_verify_authentication_credential_not_allowed() {
1164 let challenge = generate_challenge_bytes();
1165 let challenge_b64 = base64_encode_urlsafe(&challenge);
1166
1167 let client_data = serde_json::json!({
1168 "type": "webauthn.get",
1169 "challenge": challenge_b64,
1170 "origin": "http://localhost:8080",
1171 });
1172 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1173
1174 use sha2::Digest;
1175 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1176 let mut auth_data = Vec::new();
1177 auth_data.extend_from_slice(&rp_id_hash);
1178 auth_data.push(FLAG_UP);
1179 auth_data.extend_from_slice(&0u32.to_be_bytes());
1180 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1181
1182 let result = verify_authentication(&AuthenticationParams {
1183 challenge_bytes: challenge.clone(),
1184 client_data_json_b64: client_data_b64,
1185 authenticator_data_b64: auth_data_b64,
1186 signature_b64: "sig".to_string(),
1187 credential_id_b64: "unauthorized-cred".to_string(),
1188 public_key_cose: vec![0x10, 0x20],
1189 current_sign_count: 0,
1190 allowed_credential_ids: vec!["allowed-cred".to_string()],
1191 rp_id: "localhost".to_string(),
1192 rp_origins: vec!["http://localhost:8080".to_string()],
1193 policy: CredentialPolicy::default(),
1194 });
1195 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1196 }
1197
1198 #[test]
1199 fn test_verify_authentication_wrong_origin() {
1200 let challenge = generate_challenge_bytes();
1201 let challenge_b64 = base64_encode_urlsafe(&challenge);
1202
1203 let client_data = serde_json::json!({
1204 "type": "webauthn.get",
1205 "challenge": challenge_b64,
1206 "origin": "http://evil.com",
1207 });
1208 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1209
1210 use sha2::Digest;
1211 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1212 let mut auth_data = Vec::new();
1213 auth_data.extend_from_slice(&rp_id_hash);
1214 auth_data.push(FLAG_UP);
1215 auth_data.extend_from_slice(&0u32.to_be_bytes());
1216 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1217
1218 let result = verify_authentication(&AuthenticationParams {
1219 challenge_bytes: challenge.clone(),
1220 client_data_json_b64: client_data_b64,
1221 authenticator_data_b64: auth_data_b64,
1222 signature_b64: "sig".to_string(),
1223 credential_id_b64: "cred".to_string(),
1224 public_key_cose: vec![0x10],
1225 current_sign_count: 0,
1226 allowed_credential_ids: vec!["cred".to_string()],
1227 rp_id: "localhost".to_string(),
1228 rp_origins: vec!["http://localhost:8080".to_string()],
1229 policy: CredentialPolicy::default(),
1230 });
1231 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1232 }
1233
1234 #[test]
1235 fn test_verify_authentication_wrong_type() {
1236 let challenge = generate_challenge_bytes();
1237 let challenge_b64 = base64_encode_urlsafe(&challenge);
1238
1239 let client_data = serde_json::json!({
1240 "type": "webauthn.create",
1241 "challenge": challenge_b64,
1242 "origin": "http://localhost:8080",
1243 });
1244 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1245
1246 use sha2::Digest;
1247 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1248 let mut auth_data = Vec::new();
1249 auth_data.extend_from_slice(&rp_id_hash);
1250 auth_data.push(FLAG_UP);
1251 auth_data.extend_from_slice(&0u32.to_be_bytes());
1252 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1253
1254 let result = verify_authentication(&AuthenticationParams {
1255 challenge_bytes: challenge.clone(),
1256 client_data_json_b64: client_data_b64,
1257 authenticator_data_b64: auth_data_b64,
1258 signature_b64: "sig".to_string(),
1259 credential_id_b64: "cred".to_string(),
1260 public_key_cose: vec![0x10],
1261 current_sign_count: 0,
1262 allowed_credential_ids: vec!["cred".to_string()],
1263 rp_id: "localhost".to_string(),
1264 rp_origins: vec!["http://localhost:8080".to_string()],
1265 policy: CredentialPolicy::default(),
1266 });
1267 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1268 }
1269
1270 #[test]
1271 fn test_verify_authentication_sign_count_decrease() {
1272 use ring::signature::EcdsaKeyPair;
1273
1274 let rng = ring::rand::SystemRandom::new();
1275 let pkcs8 =
1276 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1277 .unwrap();
1278 let key_pair = EcdsaKeyPair::from_pkcs8(
1279 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1280 pkcs8.as_ref(),
1281 &rng,
1282 )
1283 .unwrap();
1284
1285 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1286 let x = pub_bytes[1..33].to_vec();
1287 let y = pub_bytes[33..65].to_vec();
1288 let cose_key = build_cose_ec2_key(&x, &y);
1289
1290 let challenge = generate_challenge_bytes();
1291 let challenge_b64 = base64_encode_urlsafe(&challenge);
1292
1293 use sha2::Digest;
1294 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1295 let mut auth_data = Vec::new();
1296 auth_data.extend_from_slice(&rp_id_hash);
1297 auth_data.push(FLAG_UP);
1298 auth_data.extend_from_slice(&5u32.to_be_bytes());
1299
1300 let client_data = serde_json::json!({
1301 "type": "webauthn.get",
1302 "challenge": challenge_b64,
1303 "origin": "http://localhost:8080",
1304 });
1305 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1306 let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1307 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1308
1309 let mut signed_data = Vec::new();
1310 signed_data.extend_from_slice(&auth_data);
1311 signed_data.extend_from_slice(&client_data_hash);
1312
1313 let signature = key_pair.sign(&rng, &signed_data).unwrap();
1314
1315 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1316 let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1317 let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1318
1319 let result = verify_authentication(&AuthenticationParams {
1320 challenge_bytes: challenge.clone(),
1321 client_data_json_b64: client_data_b64,
1322 authenticator_data_b64: auth_data_b64,
1323 signature_b64: sig_b64,
1324 credential_id_b64: cred_id_b64.clone(),
1325 public_key_cose: cose_key,
1326 current_sign_count: 10,
1327 allowed_credential_ids: vec![cred_id_b64],
1328 rp_id: "localhost".to_string(),
1329 rp_origins: vec!["http://localhost:8080".to_string()],
1330 policy: CredentialPolicy::default(),
1331 });
1332 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1333 }
1334
1335 #[test]
1336 fn test_verify_authentication_wrong_rp_id_hash() {
1337 let challenge = generate_challenge_bytes();
1338 let challenge_b64 = base64_encode_urlsafe(&challenge);
1339
1340 let client_data = serde_json::json!({
1341 "type": "webauthn.get",
1342 "challenge": challenge_b64,
1343 "origin": "http://localhost:8080",
1344 });
1345 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1346
1347 use sha2::Digest;
1348 let rp_id_hash = sha2::Sha256::digest(b"evil.com").to_vec();
1349 let mut auth_data = Vec::new();
1350 auth_data.extend_from_slice(&rp_id_hash);
1351 auth_data.push(FLAG_UP);
1352 auth_data.extend_from_slice(&0u32.to_be_bytes());
1353 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1354
1355 let result = verify_authentication(&AuthenticationParams {
1356 challenge_bytes: challenge.clone(),
1357 client_data_json_b64: client_data_b64,
1358 authenticator_data_b64: auth_data_b64,
1359 signature_b64: "sig".to_string(),
1360 credential_id_b64: "cred".to_string(),
1361 public_key_cose: vec![0x10],
1362 current_sign_count: 0,
1363 allowed_credential_ids: vec!["cred".to_string()],
1364 rp_id: "localhost".to_string(),
1365 rp_origins: vec!["http://localhost:8080".to_string()],
1366 policy: CredentialPolicy::default(),
1367 });
1368 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1369 }
1370
1371 #[test]
1372 fn test_verify_authentication_missing_up_flag() {
1373 use ring::signature::EcdsaKeyPair;
1374
1375 let rng = ring::rand::SystemRandom::new();
1376 let pkcs8 =
1377 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1378 .unwrap();
1379 let key_pair = EcdsaKeyPair::from_pkcs8(
1380 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1381 pkcs8.as_ref(),
1382 &rng,
1383 )
1384 .unwrap();
1385
1386 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1387 let x = pub_bytes[1..33].to_vec();
1388 let y = pub_bytes[33..65].to_vec();
1389 let cose_key = build_cose_ec2_key(&x, &y);
1390
1391 let challenge = generate_challenge_bytes();
1392 let challenge_b64 = base64_encode_urlsafe(&challenge);
1393
1394 use sha2::Digest;
1395 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1396 let mut auth_data = Vec::new();
1397 auth_data.extend_from_slice(&rp_id_hash);
1398 auth_data.push(0);
1399 auth_data.extend_from_slice(&1u32.to_be_bytes());
1400
1401 let client_data = serde_json::json!({
1402 "type": "webauthn.get",
1403 "challenge": challenge_b64,
1404 "origin": "http://localhost:8080",
1405 });
1406 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1407 let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1408 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1409
1410 let mut signed_data = Vec::new();
1411 signed_data.extend_from_slice(&auth_data);
1412 signed_data.extend_from_slice(&client_data_hash);
1413
1414 let signature = key_pair.sign(&rng, &signed_data).unwrap();
1415
1416 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1417 let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1418 let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1419
1420 let result = verify_authentication(&AuthenticationParams {
1421 challenge_bytes: challenge.clone(),
1422 client_data_json_b64: client_data_b64,
1423 authenticator_data_b64: auth_data_b64,
1424 signature_b64: sig_b64,
1425 credential_id_b64: cred_id_b64.clone(),
1426 public_key_cose: cose_key,
1427 current_sign_count: 0,
1428 allowed_credential_ids: vec![cred_id_b64],
1429 rp_id: "localhost".to_string(),
1430 rp_origins: vec!["http://localhost:8080".to_string()],
1431 policy: CredentialPolicy::default(),
1432 });
1433 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1434 }
1435
1436 #[test]
1437 fn test_verify_authentication_wrong_rp_id_in_client_data() {
1438 use ring::signature::EcdsaKeyPair;
1439
1440 let rng = ring::rand::SystemRandom::new();
1441 let pkcs8 =
1442 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1443 .unwrap();
1444 let key_pair = EcdsaKeyPair::from_pkcs8(
1445 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1446 pkcs8.as_ref(),
1447 &rng,
1448 )
1449 .unwrap();
1450
1451 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1452 let x = pub_bytes[1..33].to_vec();
1453 let y = pub_bytes[33..65].to_vec();
1454 let cose_key = build_cose_ec2_key(&x, &y);
1455
1456 let challenge = generate_challenge_bytes();
1457 let challenge_b64 = base64_encode_urlsafe(&challenge);
1458
1459 let client_data = serde_json::json!({
1460 "type": "webauthn.get",
1461 "challenge": challenge_b64,
1462 "origin": "http://localhost:8080",
1463 "rpId": "evil.com",
1464 });
1465 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1466 let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1467
1468 use sha2::Digest;
1469 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1470 let mut auth_data = Vec::new();
1471 auth_data.extend_from_slice(&rp_id_hash);
1472 auth_data.push(FLAG_UP);
1473 auth_data.extend_from_slice(&1u32.to_be_bytes());
1474
1475 let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1476 let mut signed_data = Vec::new();
1477 signed_data.extend_from_slice(&auth_data);
1478 signed_data.extend_from_slice(&client_data_hash);
1479
1480 let signature = key_pair.sign(&rng, &signed_data).unwrap();
1481
1482 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1483 let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1484 let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1485
1486 let result = verify_authentication(&AuthenticationParams {
1487 challenge_bytes: challenge.clone(),
1488 client_data_json_b64: client_data_b64,
1489 authenticator_data_b64: auth_data_b64,
1490 signature_b64: sig_b64,
1491 credential_id_b64: cred_id_b64.clone(),
1492 public_key_cose: cose_key,
1493 current_sign_count: 0,
1494 allowed_credential_ids: vec![cred_id_b64],
1495 rp_id: "localhost".to_string(),
1496 rp_origins: vec!["http://localhost:8080".to_string()],
1497 policy: CredentialPolicy::default(),
1498 });
1499 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1500 }
1501
1502 #[test]
1503 fn test_verify_authentication_challenge_mismatch() {
1504 let challenge = generate_challenge_bytes();
1505 let _challenge_b64 = base64_encode_urlsafe(&challenge);
1506
1507 let client_data = serde_json::json!({
1508 "type": "webauthn.get",
1509 "challenge": base64_encode_urlsafe(&[99u8; 32]),
1510 "origin": "http://localhost:8080",
1511 });
1512 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1513
1514 use sha2::Digest;
1515 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1516 let mut auth_data = Vec::new();
1517 auth_data.extend_from_slice(&rp_id_hash);
1518 auth_data.push(FLAG_UP);
1519 auth_data.extend_from_slice(&0u32.to_be_bytes());
1520 let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1521
1522 let result = verify_authentication(&AuthenticationParams {
1523 challenge_bytes: challenge.clone(),
1524 client_data_json_b64: client_data_b64,
1525 authenticator_data_b64: auth_data_b64,
1526 signature_b64: "sig".to_string(),
1527 credential_id_b64: "cred".to_string(),
1528 public_key_cose: vec![0x10],
1529 current_sign_count: 0,
1530 allowed_credential_ids: vec!["cred".to_string()],
1531 rp_id: "localhost".to_string(),
1532 rp_origins: vec!["http://localhost:8080".to_string()],
1533 policy: CredentialPolicy::default(),
1534 });
1535 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1536 }
1537
1538 #[test]
1539 fn test_verify_authentication_rs256_roundtrip() {
1540 let cose_key = build_cose_rsa_key(&[0xAA; 256], &[0x01, 0x00, 0x01]);
1545 let (alg, key) = crate::crypto::parse_cose_key(&cose_key).unwrap();
1546 assert_eq!(alg, COSE_ALG_RS256);
1547 let result = verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]);
1548 assert!(matches!(
1549 result,
1550 Err(WebauthnError::SignatureVerificationFailed)
1551 ));
1552 }
1553
1554 fn valid_registration(rp_id_field: bool, extra_att_key: bool) -> (Vec<u8>, String, String) {
1558 use ring::signature::EcdsaKeyPair;
1559
1560 let rng = ring::rand::SystemRandom::new();
1561 let pkcs8 =
1562 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1563 .unwrap();
1564 let key_pair = EcdsaKeyPair::from_pkcs8(
1565 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1566 pkcs8.as_ref(),
1567 &rng,
1568 )
1569 .unwrap();
1570 let pub_bytes = key_pair.public_key().as_ref().to_vec();
1571 let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
1572
1573 let credential_id = vec![0x42u8; 8];
1574 let auth_data = build_auth_data_with_credential(
1575 "localhost",
1576 FLAG_UP | FLAG_AT,
1577 0,
1578 &credential_id,
1579 &cose_key,
1580 );
1581
1582 let challenge = generate_challenge_bytes();
1583 let challenge_b64 = base64_encode_urlsafe(&challenge);
1584
1585 let mut client_data = serde_json::json!({
1586 "type": "webauthn.create",
1587 "challenge": challenge_b64,
1588 "origin": "http://localhost:8080",
1589 });
1590 if rp_id_field {
1591 client_data["rpId"] = serde_json::json!("localhost");
1592 }
1593 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1594
1595 let mut entries = vec![
1596 (
1597 ciborium::Value::Integer(1.into()),
1598 ciborium::Value::Text("none".to_string()),
1599 ),
1600 (
1601 ciborium::Value::Text("authData".to_string()),
1602 ciborium::Value::Bytes(auth_data),
1603 ),
1604 ];
1605 if extra_att_key {
1606 entries.push((
1607 ciborium::Value::Text("unknownKey".to_string()),
1608 ciborium::Value::Bool(true),
1609 ));
1610 }
1611 entries.push((
1612 ciborium::Value::Text("attStmt".to_string()),
1613 ciborium::Value::Map(vec![]),
1614 ));
1615 let mut buf = Vec::new();
1616 ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
1617
1618 (challenge, client_data_b64, base64_encode_urlsafe(&buf))
1619 }
1620
1621 #[test]
1624 fn registration_accepts_rpid_field_and_unknown_keys() {
1625 let (challenge, client_data_b64, att_obj_b64) = valid_registration(true, true);
1626 let result = verify_registration(
1627 &challenge,
1628 &client_data_b64,
1629 &att_obj_b64,
1630 "",
1631 "localhost",
1632 &["http://localhost:8080".to_string()],
1633 &AttestationPolicy::default(),
1634 &CredentialPolicy::default(),
1635 )
1636 .unwrap();
1637 assert_eq!(result.attestation_format, "none");
1638 assert_eq!(result.credential_id, base64_encode_urlsafe(&[0x42u8; 8]));
1639 }
1640
1641 #[test]
1642 fn registration_rejects_missing_type_in_client_data() {
1643 let challenge = generate_challenge_bytes();
1644 let client_data = serde_json::json!({
1645 "challenge": base64_encode_urlsafe(&challenge),
1646 "origin": "http://localhost:8080",
1647 });
1648 let result = verify_registration(
1649 &challenge,
1650 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1651 &base64_encode_urlsafe(&[0xFF; 8]),
1652 "",
1653 "localhost",
1654 &["http://localhost:8080".to_string()],
1655 &AttestationPolicy::default(),
1656 &CredentialPolicy::default(),
1657 );
1658 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1659 }
1660
1661 #[test]
1662 fn registration_rejects_missing_origin_in_client_data() {
1663 let challenge = generate_challenge_bytes();
1664 let client_data = serde_json::json!({
1665 "type": "webauthn.create",
1666 "challenge": base64_encode_urlsafe(&challenge),
1667 });
1668 let result = verify_registration(
1669 &challenge,
1670 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1671 &base64_encode_urlsafe(&[0xFF; 8]),
1672 "",
1673 "localhost",
1674 &["http://localhost:8080".to_string()],
1675 &AttestationPolicy::default(),
1676 &CredentialPolicy::default(),
1677 );
1678 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1679 }
1680
1681 #[test]
1685 fn parse_auth_data_rejects_truncated_public_key() {
1686 let mut auth_data = vec![0u8; 37 + 16 + 2 + 2];
1687 auth_data[32] = FLAG_UP | FLAG_AT;
1688 auth_data[53..55].copy_from_slice(&2u16.to_be_bytes());
1689 let result = parse_authenticator_data(&auth_data);
1690 assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1691 }
1692
1693 use crate::crypto::tests::{build_cose_ec2_key_alg, build_cose_okp_key};
1696 use crate::crypto::{COSE_ALG_ES256, COSE_ALG_ES384};
1697
1698 #[test]
1700 fn registration_roundtrip_es384() {
1701 use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1702
1703 let rng = ring::rand::SystemRandom::new();
1704 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1705 let key_pair =
1706 EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1707 .unwrap();
1708 let pub_bytes = key_pair.public_key().as_ref();
1709 let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1710
1711 let credential_id = vec![0xE3u8; 8];
1712 let auth_data = build_auth_data_with_credential(
1713 "localhost",
1714 FLAG_UP | FLAG_AT | FLAG_UV | FLAG_BE | FLAG_BS,
1715 0,
1716 &credential_id,
1717 &cose_key,
1718 );
1719 let att_obj = build_attestation_object(&auth_data);
1720
1721 let challenge = generate_challenge_bytes();
1722 let client_data = serde_json::json!({
1723 "type": "webauthn.create",
1724 "challenge": base64_encode_urlsafe(&challenge),
1725 "origin": "http://localhost:8080",
1726 });
1727 let result = verify_registration(
1728 &challenge,
1729 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1730 &base64_encode_urlsafe(&att_obj),
1731 "",
1732 "localhost",
1733 &["http://localhost:8080".to_string()],
1734 &AttestationPolicy::default(),
1735 &CredentialPolicy::default(),
1736 )
1737 .unwrap();
1738
1739 assert!(result.device_name.contains("ES384"));
1740 assert!(result.user_verified);
1741 assert!(result.backup_eligible);
1742 assert!(result.backup_state);
1743 }
1744
1745 #[test]
1747 fn registration_authentication_roundtrip_eddsa() {
1748 use ring::signature::{Ed25519KeyPair, KeyPair as _};
1749
1750 let rng = ring::rand::SystemRandom::new();
1751 let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
1752 let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1753 let cose_key = build_cose_okp_key(key_pair.public_key().as_ref());
1754
1755 let credential_id = vec![0xEDu8; 8];
1757 let auth_data = build_auth_data_with_credential(
1758 "localhost",
1759 FLAG_UP | FLAG_AT,
1760 0,
1761 &credential_id,
1762 &cose_key,
1763 );
1764 let att_obj = build_attestation_object(&auth_data);
1765 let challenge = generate_challenge_bytes();
1766 let client_data = serde_json::json!({
1767 "type": "webauthn.create",
1768 "challenge": base64_encode_urlsafe(&challenge),
1769 "origin": "http://localhost:8080",
1770 });
1771 let registration = verify_registration(
1772 &challenge,
1773 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1774 &base64_encode_urlsafe(&att_obj),
1775 "",
1776 "localhost",
1777 &["http://localhost:8080".to_string()],
1778 &AttestationPolicy::default(),
1779 &CredentialPolicy::default(),
1780 )
1781 .unwrap();
1782 assert!(registration.device_name.contains("EdDSA"));
1783 assert!(!registration.backup_eligible);
1784
1785 let auth_challenge = generate_challenge_bytes();
1787 let assertion_client_data = serde_json::json!({
1788 "type": "webauthn.get",
1789 "challenge": base64_encode_urlsafe(&auth_challenge),
1790 "origin": "http://localhost:8080",
1791 });
1792 let assertion_client_data_bytes = serde_json::to_vec(&assertion_client_data).unwrap();
1793 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1794 let mut assertion_auth_data = rp_id_hash;
1795 assertion_auth_data.push(FLAG_UP | FLAG_BS);
1796 assertion_auth_data.extend_from_slice(&1u32.to_be_bytes());
1797
1798 use sha2::Digest as _;
1799 let mut signed_data = assertion_auth_data.clone();
1800 signed_data.extend_from_slice(&sha2::Sha256::digest(&assertion_client_data_bytes));
1801 let signature = key_pair.sign(&signed_data);
1802
1803 let cred_b64 = base64_encode_urlsafe(&credential_id);
1804 let result = verify_authentication(&AuthenticationParams {
1805 challenge_bytes: auth_challenge,
1806 client_data_json_b64: base64_encode_urlsafe(&assertion_client_data_bytes),
1807 authenticator_data_b64: base64_encode_urlsafe(&assertion_auth_data),
1808 signature_b64: base64_encode_urlsafe(signature.as_ref()),
1809 credential_id_b64: cred_b64.clone(),
1810 public_key_cose: cose_key,
1811 current_sign_count: 0,
1812 allowed_credential_ids: vec![cred_b64],
1813 rp_id: "localhost".to_string(),
1814 rp_origins: vec!["http://localhost:8080".to_string()],
1815 policy: CredentialPolicy::default(),
1816 })
1817 .unwrap();
1818 assert_eq!(result.new_sign_count, 1);
1819 assert!(result.backup_state);
1820 assert!(!result.backup_eligible);
1821 }
1822
1823 #[test]
1827 fn registration_rejects_p256_key_claiming_es384() {
1828 let cose_key = build_cose_ec2_key_alg(1, -35, &[0xAA; 32], &[0xBB; 32]); let auth_data =
1830 build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1831 let att_obj = build_attestation_object(&auth_data);
1832 let challenge = generate_challenge_bytes();
1833 let client_data = serde_json::json!({
1834 "type": "webauthn.create",
1835 "challenge": base64_encode_urlsafe(&challenge),
1836 "origin": "http://localhost:8080",
1837 });
1838 let result = verify_registration(
1839 &challenge,
1840 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1841 &base64_encode_urlsafe(&att_obj),
1842 "",
1843 "localhost",
1844 &["http://localhost:8080".to_string()],
1845 &AttestationPolicy::default(),
1846 &CredentialPolicy::default(),
1847 );
1848 assert!(matches!(
1849 result,
1850 Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1851 ));
1852 }
1853
1854 fn registration_inputs(flags: u8) -> (Vec<u8>, String, String) {
1859 let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
1860 let auth_data =
1861 build_auth_data_with_credential("localhost", flags, 0, &[0x01, 0x02], &cose_key);
1862 let challenge = generate_challenge_bytes();
1863 let client_data = serde_json::json!({
1864 "type": "webauthn.create",
1865 "challenge": base64_encode_urlsafe(&challenge),
1866 "origin": "http://localhost:8080",
1867 });
1868 (
1869 challenge,
1870 base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1871 base64_encode_urlsafe(&build_attestation_object(&auth_data)),
1872 )
1873 }
1874
1875 #[test]
1876 fn uv_required_rejects_clear_flag_and_accepts_set() {
1877 use crate::policy::UserVerificationPolicy;
1878 let uv_required = CredentialPolicy {
1879 user_verification: UserVerificationPolicy::Required,
1880 ..CredentialPolicy::default()
1881 };
1882
1883 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1885 let result = verify_registration(
1886 &challenge,
1887 &cd,
1888 &ao,
1889 "",
1890 "localhost",
1891 &["http://localhost:8080".to_string()],
1892 &AttestationPolicy::default(),
1893 &uv_required,
1894 );
1895 assert!(matches!(
1896 result,
1897 Err(WebauthnError::UserVerificationRequired)
1898 ));
1899
1900 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_UV);
1902 let result = verify_registration(
1903 &challenge,
1904 &cd,
1905 &ao,
1906 "",
1907 "localhost",
1908 &["http://localhost:8080".to_string()],
1909 &AttestationPolicy::default(),
1910 &uv_required,
1911 );
1912 assert!(result.is_ok());
1913 }
1914
1915 #[test]
1917 fn uv_preferred_and_discouraged_report_only() {
1918 use crate::policy::UserVerificationPolicy;
1919 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1921
1922 for uv in [
1923 UserVerificationPolicy::Preferred,
1924 UserVerificationPolicy::Discouraged,
1925 ] {
1926 let policy = CredentialPolicy {
1927 user_verification: uv,
1928 ..CredentialPolicy::default()
1929 };
1930 let result = verify_registration(
1931 &challenge,
1932 &cd,
1933 &ao,
1934 "",
1935 "localhost",
1936 &["http://localhost:8080".to_string()],
1937 &AttestationPolicy::default(),
1938 &policy,
1939 )
1940 .unwrap();
1941 assert!(!result.user_verified, "{uv:?} must report, not reject");
1942 }
1943 }
1944
1945 #[test]
1950 fn registration_rejects_algorithm_outside_allowlist() {
1951 use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1953 let rng = ring::rand::SystemRandom::new();
1954 let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1955 let key_pair =
1956 EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1957 .unwrap();
1958 let pub_bytes = key_pair.public_key().as_ref();
1959 let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1960
1961 let auth_data =
1962 build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1963 let att_obj = build_attestation_object(&auth_data);
1964 let challenge = generate_challenge_bytes();
1965 let client_data = serde_json::json!({
1966 "type": "webauthn.create",
1967 "challenge": base64_encode_urlsafe(&challenge),
1968 "origin": "http://localhost:8080",
1969 });
1970 let common = (
1971 challenge,
1972 base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1973 base64_encode_urlsafe(&att_obj),
1974 );
1975
1976 let es256_only = CredentialPolicy {
1978 allowed_algorithms: vec![COSE_ALG_ES256],
1979 ..CredentialPolicy::default()
1980 };
1981 let result = verify_registration(
1982 &common.0,
1983 &common.1,
1984 &common.2,
1985 "",
1986 "localhost",
1987 &["http://localhost:8080".to_string()],
1988 &AttestationPolicy::default(),
1989 &es256_only,
1990 );
1991 assert!(matches!(
1992 result,
1993 Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1994 ));
1995
1996 let with_es384 = CredentialPolicy {
1998 allowed_algorithms: vec![COSE_ALG_ES256, COSE_ALG_ES384],
1999 ..CredentialPolicy::default()
2000 };
2001 let result = verify_registration(
2002 &common.0,
2003 &common.1,
2004 &common.2,
2005 "",
2006 "localhost",
2007 &["http://localhost:8080".to_string()],
2008 &AttestationPolicy::default(),
2009 &with_es384,
2010 );
2011 assert!(result.is_ok());
2012
2013 let result = verify_registration(
2015 &common.0,
2016 &common.1,
2017 &common.2,
2018 "",
2019 "localhost",
2020 &["http://localhost:8080".to_string()],
2021 &AttestationPolicy::default(),
2022 &CredentialPolicy::default(),
2023 );
2024 assert!(result.is_ok());
2025 }
2026
2027 #[test]
2032 fn backup_policy_device_bound_enforced_at_registration() {
2033 use crate::policy::BackupPolicy;
2034 let device_bound = CredentialPolicy {
2035 backup: BackupPolicy::RequireDeviceBound,
2036 ..CredentialPolicy::default()
2037 };
2038
2039 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE);
2041 let result = verify_registration(
2042 &challenge,
2043 &cd,
2044 &ao,
2045 "",
2046 "localhost",
2047 &["http://localhost:8080".to_string()],
2048 &AttestationPolicy::default(),
2049 &device_bound,
2050 );
2051 assert!(matches!(result, Err(WebauthnError::PolicyViolation(_))));
2052
2053 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
2055 let result = verify_registration(
2056 &challenge,
2057 &cd,
2058 &ao,
2059 "",
2060 "localhost",
2061 &["http://localhost:8080".to_string()],
2062 &AttestationPolicy::default(),
2063 &device_bound,
2064 );
2065 let registration = result.unwrap();
2066 assert!(!registration.backup_eligible);
2067 assert!(!registration.backup_state);
2068 }
2069
2070 #[test]
2072 fn backup_policy_allow_reports_flags() {
2073 let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE | FLAG_BS);
2074 let registration = verify_registration(
2075 &challenge,
2076 &cd,
2077 &ao,
2078 "",
2079 "localhost",
2080 &["http://localhost:8080".to_string()],
2081 &AttestationPolicy::default(),
2082 &CredentialPolicy::default(),
2083 )
2084 .unwrap();
2085 assert!(registration.backup_eligible);
2086 assert!(registration.backup_state);
2087 }
2088
2089 #[test]
2091 fn backup_policy_device_bound_enforced_at_authentication() {
2092 use crate::policy::BackupPolicy;
2093 use ring::signature::{EcdsaKeyPair, KeyPair as _};
2094
2095 let rng = ring::rand::SystemRandom::new();
2096 let pkcs8 =
2097 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2098 .unwrap();
2099 let key_pair = EcdsaKeyPair::from_pkcs8(
2100 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2101 pkcs8.as_ref(),
2102 &rng,
2103 )
2104 .unwrap();
2105 let pub_bytes = key_pair.public_key().as_ref();
2106 let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2107
2108 let challenge = generate_challenge_bytes();
2109 let client_data = serde_json::json!({
2110 "type": "webauthn.get",
2111 "challenge": base64_encode_urlsafe(&challenge),
2112 "origin": "http://localhost:8080",
2113 });
2114 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2115 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2116 let mut auth_data = rp_id_hash;
2117 auth_data.push(FLAG_UP | FLAG_BE); auth_data.extend_from_slice(&1u32.to_be_bytes());
2119
2120 use sha2::Digest as _;
2121 let mut signed_data = auth_data.clone();
2122 signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2123 let signature = key_pair.sign(&rng, &signed_data).unwrap();
2124
2125 let cred_b64 = base64_encode_urlsafe(&[0x01]);
2126 let params = AuthenticationParams {
2127 challenge_bytes: challenge,
2128 client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2129 authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2130 signature_b64: base64_encode_urlsafe(signature.as_ref()),
2131 credential_id_b64: cred_b64.clone(),
2132 public_key_cose: cose_key,
2133 current_sign_count: 0,
2134 allowed_credential_ids: vec![cred_b64],
2135 rp_id: "localhost".to_string(),
2136 rp_origins: vec!["http://localhost:8080".to_string()],
2137 policy: CredentialPolicy {
2138 backup: BackupPolicy::RequireDeviceBound,
2139 ..CredentialPolicy::default()
2140 },
2141 };
2142 assert!(matches!(
2143 verify_authentication(¶ms),
2144 Err(WebauthnError::PolicyViolation(_))
2145 ));
2146 }
2147
2148 #[test]
2152 fn uv_required_enforced_at_authentication() {
2153 use crate::policy::UserVerificationPolicy;
2154 use ring::signature::{EcdsaKeyPair, KeyPair as _};
2155
2156 let rng = ring::rand::SystemRandom::new();
2157 let pkcs8 =
2158 EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2159 .unwrap();
2160 let key_pair = EcdsaKeyPair::from_pkcs8(
2161 &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2162 pkcs8.as_ref(),
2163 &rng,
2164 )
2165 .unwrap();
2166 let pub_bytes = key_pair.public_key().as_ref();
2167 let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2168
2169 let challenge = generate_challenge_bytes();
2170 let client_data = serde_json::json!({
2171 "type": "webauthn.get",
2172 "challenge": base64_encode_urlsafe(&challenge),
2173 "origin": "http://localhost:8080",
2174 });
2175 let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2176 let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2177 let mut auth_data = rp_id_hash;
2178 auth_data.push(FLAG_UP); auth_data.extend_from_slice(&1u32.to_be_bytes());
2180
2181 use sha2::Digest as _;
2182 let mut signed_data = auth_data.clone();
2183 signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2184 let signature = key_pair.sign(&rng, &signed_data).unwrap();
2185
2186 let cred_b64 = base64_encode_urlsafe(&[0x01]);
2187 let params = AuthenticationParams {
2188 challenge_bytes: challenge,
2189 client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2190 authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2191 signature_b64: base64_encode_urlsafe(signature.as_ref()),
2192 credential_id_b64: cred_b64.clone(),
2193 public_key_cose: cose_key,
2194 current_sign_count: 0,
2195 allowed_credential_ids: vec![cred_b64],
2196 rp_id: "localhost".to_string(),
2197 rp_origins: vec!["http://localhost:8080".to_string()],
2198 policy: CredentialPolicy {
2199 user_verification: UserVerificationPolicy::Required,
2200 ..CredentialPolicy::default()
2201 },
2202 };
2203 assert!(matches!(
2204 verify_authentication(¶ms),
2205 Err(WebauthnError::UserVerificationRequired)
2206 ));
2207
2208 let mut default_params = params.clone();
2210 default_params.policy = CredentialPolicy::default();
2211 assert!(verify_authentication(&default_params).is_ok());
2212 }
2213
2214 #[test]
2217 fn registration_propagates_attestation_statement_errors() {
2218 let auth_data = build_auth_data_with_credential(
2219 "localhost",
2220 FLAG_UP | FLAG_AT,
2221 0,
2222 &[0x01],
2223 &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
2224 );
2225 let entries = vec![
2226 (
2227 ciborium::Value::Integer(1.into()),
2228 ciborium::Value::Text("packed".to_string()),
2229 ),
2230 (
2231 ciborium::Value::Integer(2.into()),
2232 ciborium::Value::Bytes(auth_data),
2233 ),
2234 (
2235 ciborium::Value::Integer(3.into()),
2236 ciborium::Value::Map(vec![]), ),
2238 ];
2239 let mut buf = Vec::new();
2240 ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
2241
2242 let challenge = generate_challenge_bytes();
2243 let client_data = serde_json::json!({
2244 "type": "webauthn.create",
2245 "challenge": base64_encode_urlsafe(&challenge),
2246 "origin": "http://localhost:8080",
2247 });
2248 let result = verify_registration(
2249 &challenge,
2250 &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
2251 &base64_encode_urlsafe(&buf),
2252 "",
2253 "localhost",
2254 &["http://localhost:8080".to_string()],
2255 &AttestationPolicy::default(),
2256 &CredentialPolicy::default(),
2257 );
2258 assert!(matches!(result, Err(WebauthnError::AttestationError(_))));
2259 }
2260
2261 #[test]
2266 fn authenticator_data_truncation_boundaries_are_exact() {
2267 let at_minimum = build_auth_data_with_credential("localhost", FLAG_AT, 0, &[], &[]);
2270 assert_eq!(at_minimum.len(), 55);
2271 let err = parse_authenticator_data(&at_minimum)
2272 .unwrap_err()
2273 .to_string();
2274 assert!(
2275 err.contains("public key"),
2276 "exact-prefix blob must reach the public-key stage: {err}"
2277 );
2278
2279 let mut short = vec![0u8; 32];
2282 short.push(FLAG_AT);
2283 short.extend_from_slice(&[0u8; 7]);
2284 let err = parse_authenticator_data(&short).unwrap_err().to_string();
2285 assert!(
2286 err.contains("AAGUID + length"),
2287 "40-byte AT blob must fail at the prefix stage: {err}"
2288 );
2289
2290 let cred_exact =
2293 build_auth_data_with_credential("localhost", FLAG_AT, 0, &[0x01, 0x02], &[]);
2294 assert_eq!(cred_exact.len(), 57);
2295 let err = parse_authenticator_data(&cred_exact)
2296 .unwrap_err()
2297 .to_string();
2298 assert!(
2299 err.contains("public key"),
2300 "exact-fit credential id must reach the public-key stage: {err}"
2301 );
2302 }
2303
2304 #[test]
2308 fn registration_without_attested_credential_flag_names_the_missing_flag() {
2309 let auth_data = build_auth_data_with_credential(
2310 "localhost",
2311 FLAG_UP, 0,
2313 &[],
2314 &[],
2315 );
2316 let att_obj = build_attestation_object(&auth_data);
2317
2318 let challenge = generate_challenge_bytes();
2319 let client_data = serde_json::json!({
2320 "type": "webauthn.create",
2321 "challenge": base64_encode_urlsafe(&challenge),
2322 "origin": "http://localhost:8080",
2323 });
2324 let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
2325
2326 let result = verify_registration(
2327 &challenge,
2328 &client_data_b64,
2329 &base64_encode_urlsafe(&att_obj),
2330 "",
2331 "localhost",
2332 &["http://localhost:8080".to_string()],
2333 &AttestationPolicy::default(),
2334 &CredentialPolicy::default(),
2335 );
2336 let err = result.unwrap_err().to_string();
2337 assert!(
2338 err.contains("Attested Credential Data flag not set"),
2339 "AT-less registration must be rejected at the flag gate: {err}"
2340 );
2341 }
2342}