Skip to main content

webauthn_kit/
protocol.rs

1//! CTAP2 protocol parsing and ceremony verification.
2//!
3//! Implements the two server-side `WebAuthn` ceremonies per the W3C
4//! `WebAuthn` Level 2/3 model and CTAP2 authenticator data layout:
5//!
6//! - [`verify_registration`]: parse and validate an attestation object,
7//!   extract the new credential (ID + COSE public key) and flags.
8//! - [`verify_authentication`]: validate an assertion (challenge, origin,
9//!   RP ID hash, user presence, sign-count freshness) and verify the
10//!   signature over `authenticatorData || SHA-256(clientDataJSON)`.
11//!
12//! # Threat assumptions
13//!
14//! - All base64/BOR/JSON/CBOR inputs originate from the client and are
15//!   hostile: parsers return `Err` instead of panicking on arbitrary bytes
16//!   (fuzz-tested in `tests/fuzz.rs`).
17//! - `challenge_bytes` MUST come from a single-use, server-side source (see
18//!   [`crate::ChallengeStore`]); this module only checks that the client
19//!   echoed them inside `clientDataJSON`.
20//! - Attestation statement signatures are **not** verified (attestation is
21//!   parsed but ignored); the registration ceremony establishes key
22//!   possession, not device provenance.
23
24use crate::challenge::check_sign_count;
25use crate::credential::{AuthenticationResult, RegistrationResult};
26use crate::crypto::{
27    alg_to_name, base64_decode_urlsafe, base64_encode_urlsafe, cbor_bytes, cbor_map_entries,
28    parse_cose_key, verify_cose_signature,
29};
30use crate::error::WebauthnError;
31
32/// Parsed authenticator data structure (CTAP2 §6.1).
33#[derive(Debug, Clone)]
34struct AuthenticatorData {
35    /// SHA-256 of the expected RP ID, as claimed by the authenticator.
36    rp_id_hash: Vec<u8>,
37    /// Flag byte (UP/UV/AT/ED bits).
38    flags: u8,
39    /// Big-endian signature counter.
40    sign_count: u32,
41    /// COSE-encoded credential public key (present when AT flag set).
42    credential_public_key_cose: Option<Vec<u8>>,
43    /// Raw credential ID (present when AT flag set).
44    credential_id: Option<Vec<u8>>,
45}
46
47/// Authenticator data flag bit: User Present.
48const FLAG_UP: u8 = 0x01;
49/// Authenticator data flag bit: User Verified.
50const FLAG_UV: u8 = 0x04;
51/// Authenticator data flag bit: Attested Credential Data included.
52const FLAG_AT: u8 = 0x40;
53/// Authenticator data flag bit: Extension Data included (not parsed).
54#[allow(dead_code)]
55const FLAG_ED: u8 = 0x80;
56
57/// Minimum authenticator data length: rpIdHash(32) + flags(1) + signCount(4) = 37.
58const AUTH_DATA_MIN_LEN: usize = 37;
59
60/// Fixed 18-byte AAGUID + credential-length prefix in attested credential data.
61const ATTESTED_CREDENTIAL_PREFIX_LEN: usize = 18;
62
63/// Parse authenticator data from raw bytes (CTAP2 §6.1).
64///
65/// # Security notes / threat assumptions
66///
67/// - `auth_data` is attacker-controlled; any truncation or malformed length
68///   field yields `Err`, never a panic or out-of-bounds read.
69/// - When the AT flag is set, everything after the credential ID is taken as
70///   the COSE public key. If the ED flag is set, extension bytes would be
71///   appended by the authenticator *after* the key; this parser does not
72///   validate extensions and downstream CBOR parsing reads only the first
73///   top-level map, so trailing extension data is ignored rather than
74///   misinterpreted as key material.
75fn parse_authenticator_data(auth_data: &[u8]) -> Result<AuthenticatorData, WebauthnError> {
76    if auth_data.len() < AUTH_DATA_MIN_LEN {
77        return Err(WebauthnError::VerificationFailed(format!(
78            "Authenticator data too short: {} bytes (minimum {})",
79            auth_data.len(),
80            AUTH_DATA_MIN_LEN
81        )));
82    }
83
84    let rp_id_hash = auth_data[..32].to_vec();
85    let flags = auth_data[32];
86    let sign_count =
87        u32::from_be_bytes(auth_data[33..37].try_into().map_err(|_| {
88            WebauthnError::VerificationFailed("sign count bytes invalid".to_string())
89        })?);
90
91    let mut offset = 37;
92    let mut credential_id = None;
93    let mut credential_public_key_cose = None;
94
95    if flags & FLAG_AT != 0 {
96        if auth_data.len() < offset + ATTESTED_CREDENTIAL_PREFIX_LEN {
97            return Err(WebauthnError::VerificationFailed(
98                "Attested credential data truncated (AAGUID + length)".to_string(),
99            ));
100        }
101        offset += 16; // skip AAGUID
102
103        let cred_id_len = u16::from_be_bytes([auth_data[offset], auth_data[offset + 1]]) as usize;
104        offset += 2;
105
106        if auth_data.len() < offset + cred_id_len {
107            return Err(WebauthnError::VerificationFailed(
108                "Attested credential data truncated (credential ID)".to_string(),
109            ));
110        }
111        credential_id = Some(auth_data[offset..offset + cred_id_len].to_vec());
112        offset += cred_id_len;
113
114        if offset >= auth_data.len() {
115            return Err(WebauthnError::VerificationFailed(
116                "Attested credential data truncated (public key)".to_string(),
117            ));
118        }
119        credential_public_key_cose = Some(auth_data[offset..].to_vec());
120    }
121
122    Ok(AuthenticatorData {
123        rp_id_hash,
124        flags,
125        sign_count,
126        credential_public_key_cose,
127        credential_id,
128    })
129}
130
131/// Shared `clientDataJSON` validation: parse, challenge echo, ceremony type,
132/// origin allow-list, and optional `rpId` cross-check.
133///
134/// Returns the raw decoded client data bytes (needed for the auth signature).
135fn validate_client_data(
136    client_data_json_b64: &str,
137    challenge_bytes: &[u8],
138    expected_type: &str,
139    rp_id: &str,
140    rp_origins: &[String],
141) -> Result<Vec<u8>, WebauthnError> {
142    let client_data_bytes = base64_decode_urlsafe(client_data_json_b64)?;
143    let client_data: serde_json::Value = serde_json::from_slice(&client_data_bytes)
144        .map_err(|e| WebauthnError::VerificationFailed(format!("client data parse error: {e}")))?;
145
146    let client_challenge = client_data
147        .get("challenge")
148        .and_then(|v| v.as_str())
149        .ok_or_else(|| {
150            WebauthnError::VerificationFailed("missing challenge in client data".to_string())
151        })?;
152    let client_challenge_bytes = base64_decode_urlsafe(client_challenge)?;
153    if client_challenge_bytes != challenge_bytes {
154        return Err(WebauthnError::VerificationFailed(
155            "challenge mismatch".to_string(),
156        ));
157    }
158
159    let typ = client_data
160        .get("type")
161        .and_then(|v| v.as_str())
162        .ok_or_else(|| {
163            WebauthnError::VerificationFailed("missing type in client data".to_string())
164        })?;
165    if typ != expected_type {
166        return Err(WebauthnError::VerificationFailed(format!(
167            "wrong type: {typ}"
168        )));
169    }
170
171    let origin = client_data
172        .get("origin")
173        .and_then(|v| v.as_str())
174        .ok_or_else(|| {
175            WebauthnError::VerificationFailed("missing origin in client data".to_string())
176        })?;
177    if !rp_origins.iter().any(|o| o == origin) {
178        return Err(WebauthnError::VerificationFailed(format!(
179            "origin '{origin}' not allowed"
180        )));
181    }
182
183    // Note: nested `if let` (not a let-chain) — this crate targets edition 2021.
184    if let Some(rp) = client_data.get("rpId").and_then(|v| v.as_str()) {
185        if rp != rp_id {
186            return Err(WebauthnError::VerificationFailed(format!(
187                "rpId mismatch: client sent '{rp}', expected '{rp_id}'"
188            )));
189        }
190    }
191
192    Ok(client_data_bytes)
193}
194
195/// Verify a registration response with full CTAP2/COSE verification.
196///
197/// # Security notes / threat assumptions
198///
199/// - `challenge_bytes` must be the server-generated, single-use challenge for
200///   this ceremony (obtain via [`crate::ChallengeStore::consume_registration_challenge`]
201///   immediately before calling).
202/// - `rp_origins` is an exact-match allow-list; `rp_id` is SHA-256-hashed and
203///   compared against the authenticator's `rpIdHash`, which binds the
204///   credential to this RP.
205/// - The UP and AT flags are mandatory; the UV flag is reported (not
206///   required) so the caller can enforce its own user-verification policy.
207/// - Attestation statements are parsed but **not** verified — registration
208///   proves key possession, not device provenance (see crate docs).
209/// - Re-registration of an existing credential is rejected when
210///   `existing_credential_id` matches the presented credential ID.
211///
212/// Returns the verified registration data to persist.
213pub fn verify_registration(
214    challenge_bytes: &[u8],
215    client_data_json_b64: &str,
216    attestation_object_b64: &str,
217    existing_credential_id: &str,
218    rp_id: &str,
219    rp_origins: &[String],
220) -> Result<RegistrationResult, WebauthnError> {
221    validate_client_data(
222        client_data_json_b64,
223        challenge_bytes,
224        "webauthn.create",
225        rp_id,
226        rp_origins,
227    )?;
228
229    let attestation_bytes = base64_decode_urlsafe(attestation_object_b64)?;
230    let attestation_val: ciborium::Value = ciborium::de::from_reader(&attestation_bytes[..])
231        .map_err(|e| {
232            WebauthnError::AttestationError(format!("attestation object CBOR parse error: {e}"))
233        })?;
234
235    let attestation_entries = cbor_map_entries(&attestation_val).ok_or_else(|| {
236        WebauthnError::AttestationError("attestation object is not a CBOR map".to_string())
237    })?;
238
239    let mut fmt: Option<String> = None;
240    let mut auth_data_bytes: Option<Vec<u8>> = None;
241
242    for (key, val) in &attestation_entries {
243        match *key {
244            1 => {
245                if let ciborium::Value::Text(s) = val {
246                    fmt = Some(s.clone());
247                }
248            }
249            2 => {
250                if let Some(b) = cbor_bytes(val) {
251                    auth_data_bytes = Some(b);
252                }
253            }
254            _ => {}
255        }
256    }
257
258    let fmt = fmt.unwrap_or_else(|| "none".to_string());
259    let auth_data_bytes = auth_data_bytes.ok_or_else(|| {
260        WebauthnError::AttestationError("missing authData in attestation object".to_string())
261    })?;
262
263    let auth_data = parse_authenticator_data(&auth_data_bytes)?;
264
265    use sha2::Digest;
266    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
267    if auth_data.rp_id_hash != computed_rp_id_hash {
268        return Err(WebauthnError::VerificationFailed(format!(
269            "rpId hash mismatch: computed {:x?}, got {:x?}",
270            computed_rp_id_hash, auth_data.rp_id_hash
271        )));
272    }
273
274    if auth_data.flags & FLAG_UP == 0 {
275        return Err(WebauthnError::VerificationFailed(
276            "User Present flag not set".to_string(),
277        ));
278    }
279
280    if auth_data.flags & FLAG_AT == 0 {
281        return Err(WebauthnError::VerificationFailed(
282            "Attested Credential Data flag not set during registration".to_string(),
283        ));
284    }
285
286    let credential_id = auth_data.credential_id.ok_or_else(|| {
287        WebauthnError::VerificationFailed("no credential ID in attested data".to_string())
288    })?;
289    let credential_id_b64 = base64_encode_urlsafe(&credential_id);
290
291    if existing_credential_id == credential_id_b64 {
292        return Err(WebauthnError::DuplicateCredential(credential_id_b64));
293    }
294
295    let public_key_cose = auth_data.credential_public_key_cose.ok_or_else(|| {
296        WebauthnError::VerificationFailed("no public key in attested data".to_string())
297    })?;
298
299    let (alg, _cose_key) = parse_cose_key(&public_key_cose)?;
300
301    let user_verified = auth_data.flags & FLAG_UV != 0;
302
303    Ok(RegistrationResult {
304        credential_id: credential_id_b64,
305        device_name: format!("WebAuthn ({})", alg_to_name(alg)),
306        attestation_format: fmt,
307        user_verified,
308    })
309}
310
311/// Parameters for verifying an authentication response.
312///
313/// All base64 fields are client-supplied and hostile; `public_key_cose` and
314/// `current_sign_count` are the server-side stored credential state.
315#[derive(Debug, Clone)]
316pub struct AuthenticationParams {
317    /// The raw challenge bytes that were stored server-side.
318    pub challenge_bytes: Vec<u8>,
319    /// Base64url-encoded client data JSON.
320    pub client_data_json_b64: String,
321    /// Base64url-encoded authenticator data.
322    pub authenticator_data_b64: String,
323    /// Base64url-encoded signature.
324    pub signature_b64: String,
325    /// Base64url-encoded credential ID presented by the authenticator.
326    pub credential_id_b64: String,
327    /// COSE-encoded public key for this credential.
328    pub public_key_cose: Vec<u8>,
329    /// Current sign count stored server-side for this credential.
330    pub current_sign_count: u32,
331    /// Allowed credential IDs for this authentication session.
332    pub allowed_credential_ids: Vec<String>,
333    /// Expected relying party ID.
334    pub rp_id: String,
335    /// Allowed origins.
336    pub rp_origins: Vec<String>,
337}
338
339/// Verify an authentication response with full CTAP2/COSE signature
340/// verification.
341///
342/// Verification steps, in order:
343///
344/// 1. Credential ID membership in `allowed_credential_ids`.
345/// 2. `clientDataJSON` validation (challenge echo, `type == "webauthn.get"`,
346///    origin allow-list, optional `rpId` cross-check).
347/// 3. Authenticator data parsing and `rpIdHash` comparison.
348/// 4. User Presence flag.
349/// 5. Sign-count freshness ([`check_sign_count`]).
350/// 6. `ring` signature verification over `authenticatorData || SHA-256(clientDataJSON)`.
351///
352/// # Security notes / threat assumptions
353///
354/// - `challenge_bytes` must be consumed from single-use storage before this
355///   call; this module does not (and cannot) detect a challenge used twice.
356/// - On success the caller MUST persist [`AuthenticationResult::new_sign_count`]
357///   (ideally compare-and-swap) before considering the user logged in.
358/// - The UV flag is reported, not required; enforce your own policy on
359///   [`AuthenticationResult::user_verified`].
360///
361/// Returns the verified authentication data.
362pub fn verify_authentication(
363    params: &AuthenticationParams,
364) -> Result<AuthenticationResult, WebauthnError> {
365    let AuthenticationParams {
366        challenge_bytes,
367        client_data_json_b64,
368        authenticator_data_b64,
369        signature_b64,
370        credential_id_b64,
371        public_key_cose,
372        current_sign_count,
373        allowed_credential_ids,
374        rp_id,
375        rp_origins,
376    } = params;
377
378    if !allowed_credential_ids.contains(credential_id_b64) {
379        return Err(WebauthnError::VerificationFailed(
380            "credential ID not in allowed list".to_string(),
381        ));
382    }
383
384    let client_data_bytes = validate_client_data(
385        client_data_json_b64,
386        challenge_bytes,
387        "webauthn.get",
388        rp_id,
389        rp_origins,
390    )?;
391
392    let authenticator_data = base64_decode_urlsafe(authenticator_data_b64)?;
393    let auth_data = parse_authenticator_data(&authenticator_data)?;
394
395    use sha2::Digest;
396    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
397    if auth_data.rp_id_hash != computed_rp_id_hash {
398        return Err(WebauthnError::VerificationFailed(format!(
399            "rpId hash mismatch: computed {:x?}, got {:x?}",
400            computed_rp_id_hash, auth_data.rp_id_hash
401        )));
402    }
403
404    if auth_data.flags & FLAG_UP == 0 {
405        return Err(WebauthnError::VerificationFailed(
406            "User Present flag not set".to_string(),
407        ));
408    }
409
410    check_sign_count(*current_sign_count, auth_data.sign_count)?;
411
412    let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
413
414    let mut signed_data = Vec::with_capacity(authenticator_data.len() + 32);
415    signed_data.extend_from_slice(&authenticator_data);
416    signed_data.extend_from_slice(&client_data_hash);
417
418    let signature = base64_decode_urlsafe(signature_b64)?;
419
420    let (alg, cose_key) = parse_cose_key(public_key_cose)?;
421    verify_cose_signature(alg, &cose_key, &signed_data, &signature)?;
422
423    let user_verified = auth_data.flags & FLAG_UV != 0;
424    let new_sign_count = auth_data.sign_count;
425
426    Ok(AuthenticationResult {
427        credential_id: credential_id_b64.clone(),
428        new_sign_count,
429        user_verified,
430    })
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use crate::crypto::tests::{build_cose_ec2_key, build_cose_rsa_key};
437    use crate::crypto::{base64_encode_urlsafe, generate_challenge_bytes, COSE_ALG_RS256};
438    use ring::signature::KeyPair as _;
439
440    fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
441        use ciborium::Value;
442        let map = vec![
443            (Value::Integer(1.into()), Value::Text("none".to_string())),
444            (Value::Integer(2.into()), Value::Bytes(auth_data.to_vec())),
445            (Value::Integer(3.into()), Value::Map(vec![])),
446        ];
447        let mut buf = Vec::new();
448        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
449        buf
450    }
451
452    fn build_auth_data_with_credential(
453        rp_id: &str,
454        flags: u8,
455        sign_count: u32,
456        credential_id: &[u8],
457        cose_key: &[u8],
458    ) -> Vec<u8> {
459        use sha2::Digest;
460        let rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
461        let mut auth_data = Vec::new();
462        auth_data.extend_from_slice(&rp_id_hash);
463        auth_data.push(flags);
464        auth_data.extend_from_slice(&sign_count.to_be_bytes());
465
466        if flags & FLAG_AT != 0 {
467            auth_data.extend_from_slice(&[0u8; 16]);
468            auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
469            auth_data.extend_from_slice(credential_id);
470            auth_data.extend_from_slice(cose_key);
471        }
472
473        auth_data
474    }
475
476    #[test]
477    fn test_parse_authenticator_data_minimal() {
478        let mut auth_data = vec![0u8; 37];
479        auth_data[..32].copy_from_slice(&[0xAA; 32]);
480        auth_data[32] = FLAG_UP;
481        auth_data[33..37].copy_from_slice(&1u32.to_be_bytes());
482
483        let parsed = parse_authenticator_data(&auth_data).unwrap();
484        assert_eq!(parsed.rp_id_hash, vec![0xAA; 32]);
485        assert_eq!(parsed.flags, FLAG_UP);
486        assert_eq!(parsed.sign_count, 1);
487        assert!(parsed.credential_public_key_cose.is_none());
488        assert!(parsed.credential_id.is_none());
489    }
490
491    #[test]
492    fn test_parse_authenticator_data_with_attested_credential() {
493        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
494        let public_key_cose = vec![0x10, 0x20, 0x30];
495
496        let total_len = 37 + 16 + 2 + credential_id.len() + public_key_cose.len();
497        let mut auth_data = vec![0u8; total_len];
498        auth_data[32] = FLAG_UP | FLAG_AT;
499        auth_data[33..37].copy_from_slice(&5u32.to_be_bytes());
500
501        let offset = 37 + 16;
502        auth_data[offset..offset + 2].copy_from_slice(&(credential_id.len() as u16).to_be_bytes());
503        auth_data[offset + 2..offset + 2 + credential_id.len()].copy_from_slice(&credential_id);
504        let pk_offset = offset + 2 + credential_id.len();
505        auth_data[pk_offset..].copy_from_slice(&public_key_cose);
506
507        let parsed = parse_authenticator_data(&auth_data).unwrap();
508        assert_eq!(parsed.flags, FLAG_UP | FLAG_AT);
509        assert_eq!(parsed.sign_count, 5);
510        assert_eq!(parsed.credential_id, Some(credential_id));
511        assert_eq!(parsed.credential_public_key_cose, Some(public_key_cose));
512    }
513
514    #[test]
515    fn test_parse_authenticator_data_too_short() {
516        let auth_data = vec![0u8; 10];
517        let result = parse_authenticator_data(&auth_data);
518        assert!(result.is_err());
519    }
520
521    #[test]
522    fn test_verify_registration_valid_es256() {
523        use ring::signature::EcdsaKeyPair;
524
525        let rng = ring::rand::SystemRandom::new();
526        let pkcs8 =
527            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
528                .unwrap();
529        let key_pair = EcdsaKeyPair::from_pkcs8(
530            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
531            pkcs8.as_ref(),
532            &rng,
533        )
534        .unwrap();
535
536        let pub_bytes = key_pair.public_key().as_ref().to_vec();
537        let x = pub_bytes[1..33].to_vec();
538        let y = pub_bytes[33..65].to_vec();
539        let cose_key = build_cose_ec2_key(&x, &y);
540
541        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
542        let auth_data = build_auth_data_with_credential(
543            "localhost",
544            FLAG_UP | FLAG_AT,
545            0,
546            &credential_id,
547            &cose_key,
548        );
549        let att_obj = build_attestation_object(&auth_data);
550
551        let challenge = generate_challenge_bytes();
552        let challenge_b64 = base64_encode_urlsafe(&challenge);
553
554        let client_data = serde_json::json!({
555            "type": "webauthn.create",
556            "challenge": challenge_b64,
557            "origin": "http://localhost:8080",
558        });
559        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
560        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
561
562        let result = verify_registration(
563            &challenge,
564            &client_data_b64,
565            &att_obj_b64,
566            "different-id",
567            "localhost",
568            &["http://localhost:8080".to_string()],
569        )
570        .unwrap();
571
572        assert_eq!(result.credential_id, base64_encode_urlsafe(&credential_id));
573        assert!(result.attestation_format == "none");
574    }
575
576    #[test]
577    fn test_verify_registration_challenge_mismatch() {
578        let auth_data = build_auth_data_with_credential(
579            "localhost",
580            FLAG_UP | FLAG_AT,
581            0,
582            &[0x01],
583            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
584        );
585        let att_obj = build_attestation_object(&auth_data);
586
587        let challenge = generate_challenge_bytes();
588        let _challenge_b64 = base64_encode_urlsafe(&challenge);
589
590        let client_data = serde_json::json!({
591            "type": "webauthn.create",
592            "challenge": base64_encode_urlsafe(&[1u8; 32]),
593            "origin": "http://localhost:8080",
594        });
595        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
596        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
597
598        let result = verify_registration(
599            &challenge,
600            &client_data_b64,
601            &att_obj_b64,
602            "different",
603            "localhost",
604            &["http://localhost:8080".to_string()],
605        );
606        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
607    }
608
609    #[test]
610    fn test_verify_registration_wrong_type() {
611        let auth_data = build_auth_data_with_credential(
612            "localhost",
613            FLAG_UP | FLAG_AT,
614            0,
615            &[0x01],
616            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
617        );
618        let att_obj = build_attestation_object(&auth_data);
619
620        let challenge = generate_challenge_bytes();
621        let challenge_b64 = base64_encode_urlsafe(&challenge);
622
623        let client_data = serde_json::json!({
624            "type": "webauthn.get",
625            "challenge": challenge_b64,
626            "origin": "http://localhost:8080",
627        });
628        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
629        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
630
631        let result = verify_registration(
632            &challenge,
633            &client_data_b64,
634            &att_obj_b64,
635            "different",
636            "localhost",
637            &["http://localhost:8080".to_string()],
638        );
639        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
640    }
641
642    #[test]
643    fn test_verify_registration_wrong_origin() {
644        let auth_data = build_auth_data_with_credential(
645            "localhost",
646            FLAG_UP | FLAG_AT,
647            0,
648            &[0x01],
649            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
650        );
651        let att_obj = build_attestation_object(&auth_data);
652
653        let challenge = generate_challenge_bytes();
654        let challenge_b64 = base64_encode_urlsafe(&challenge);
655
656        let client_data = serde_json::json!({
657            "type": "webauthn.create",
658            "challenge": challenge_b64,
659            "origin": "http://evil.com",
660        });
661        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
662        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
663
664        let result = verify_registration(
665            &challenge,
666            &client_data_b64,
667            &att_obj_b64,
668            "different",
669            "localhost",
670            &["http://localhost:8080".to_string()],
671        );
672        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
673    }
674
675    #[test]
676    fn test_verify_registration_duplicate() {
677        let auth_data = build_auth_data_with_credential(
678            "localhost",
679            FLAG_UP | FLAG_AT,
680            0,
681            &[0x01, 0x02, 0x03],
682            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
683        );
684        let att_obj = build_attestation_object(&auth_data);
685
686        let challenge = generate_challenge_bytes();
687        let challenge_b64 = base64_encode_urlsafe(&challenge);
688
689        let client_data = serde_json::json!({
690            "type": "webauthn.create",
691            "challenge": challenge_b64,
692            "origin": "http://localhost:8080",
693        });
694        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
695        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
696        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02, 0x03]);
697
698        let result = verify_registration(
699            &challenge,
700            &client_data_b64,
701            &att_obj_b64,
702            &cred_id_b64,
703            "localhost",
704            &["http://localhost:8080".to_string()],
705        );
706        assert!(matches!(result, Err(WebauthnError::DuplicateCredential(_))));
707    }
708
709    #[test]
710    fn test_verify_registration_wrong_rp_id_hash() {
711        let credential_id = vec![0x01];
712        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
713        let auth_data = build_auth_data_with_credential(
714            "evil.com",
715            FLAG_UP | FLAG_AT,
716            0,
717            &credential_id,
718            &cose_key,
719        );
720        let att_obj = build_attestation_object(&auth_data);
721
722        let challenge = generate_challenge_bytes();
723        let challenge_b64 = base64_encode_urlsafe(&challenge);
724
725        let client_data = serde_json::json!({
726            "type": "webauthn.create",
727            "challenge": challenge_b64,
728            "origin": "http://localhost:8080",
729        });
730        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
731        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
732
733        let result = verify_registration(
734            &challenge,
735            &client_data_b64,
736            &att_obj_b64,
737            "different",
738            "localhost",
739            &["http://localhost:8080".to_string()],
740        );
741        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
742    }
743
744    #[test]
745    fn test_verify_registration_missing_up_flag() {
746        let credential_id = vec![0x01];
747        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
748        let auth_data =
749            build_auth_data_with_credential("localhost", FLAG_AT, 0, &credential_id, &cose_key);
750        let att_obj = build_attestation_object(&auth_data);
751
752        let challenge = generate_challenge_bytes();
753        let challenge_b64 = base64_encode_urlsafe(&challenge);
754
755        let client_data = serde_json::json!({
756            "type": "webauthn.create",
757            "challenge": challenge_b64,
758            "origin": "http://localhost:8080",
759        });
760        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
761        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
762
763        let result = verify_registration(
764            &challenge,
765            &client_data_b64,
766            &att_obj_b64,
767            "different",
768            "localhost",
769            &["http://localhost:8080".to_string()],
770        );
771        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
772    }
773
774    #[test]
775    fn test_verify_authentication_valid_es256() {
776        use ring::signature::EcdsaKeyPair;
777
778        let rng = ring::rand::SystemRandom::new();
779        let pkcs8 =
780            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
781                .unwrap();
782        let key_pair = EcdsaKeyPair::from_pkcs8(
783            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
784            pkcs8.as_ref(),
785            &rng,
786        )
787        .unwrap();
788
789        let pub_bytes = key_pair.public_key().as_ref().to_vec();
790        let x = pub_bytes[1..33].to_vec();
791        let y = pub_bytes[33..65].to_vec();
792        let cose_key = build_cose_ec2_key(&x, &y);
793
794        let challenge = generate_challenge_bytes();
795        let challenge_b64 = base64_encode_urlsafe(&challenge);
796
797        use sha2::Digest;
798        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
799        let mut auth_data = Vec::new();
800        auth_data.extend_from_slice(&rp_id_hash);
801        auth_data.push(FLAG_UP);
802        auth_data.extend_from_slice(&1u32.to_be_bytes());
803
804        let client_data = serde_json::json!({
805            "type": "webauthn.get",
806            "challenge": challenge_b64,
807            "origin": "http://localhost:8080",
808        });
809        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
810        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
811        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
812
813        let mut signed_data = Vec::new();
814        signed_data.extend_from_slice(&auth_data);
815        signed_data.extend_from_slice(&client_data_hash);
816
817        let signature = key_pair.sign(&rng, &signed_data).unwrap();
818
819        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
820        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
821        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02]);
822
823        let result = verify_authentication(&AuthenticationParams {
824            challenge_bytes: challenge.clone(),
825            client_data_json_b64: client_data_b64,
826            authenticator_data_b64: auth_data_b64,
827            signature_b64: sig_b64,
828            credential_id_b64: cred_id_b64.clone(),
829            public_key_cose: cose_key,
830            current_sign_count: 0,
831            allowed_credential_ids: vec![cred_id_b64.clone()],
832            rp_id: "localhost".to_string(),
833            rp_origins: vec!["http://localhost:8080".to_string()],
834        })
835        .unwrap();
836
837        assert_eq!(result.credential_id, cred_id_b64);
838        assert_eq!(result.new_sign_count, 1);
839    }
840
841    #[test]
842    fn test_verify_authentication_wrong_signature() {
843        use ring::signature::EcdsaKeyPair;
844
845        let rng = ring::rand::SystemRandom::new();
846        let pkcs8 =
847            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
848                .unwrap();
849        let key_pair = EcdsaKeyPair::from_pkcs8(
850            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
851            pkcs8.as_ref(),
852            &rng,
853        )
854        .unwrap();
855
856        let pub_bytes = key_pair.public_key().as_ref().to_vec();
857        let x = pub_bytes[1..33].to_vec();
858        let y = pub_bytes[33..65].to_vec();
859        let cose_key = build_cose_ec2_key(&x, &y);
860
861        let challenge = generate_challenge_bytes();
862        let challenge_b64 = base64_encode_urlsafe(&challenge);
863
864        use sha2::Digest;
865        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
866        let mut auth_data = Vec::new();
867        auth_data.extend_from_slice(&rp_id_hash);
868        auth_data.push(FLAG_UP);
869        auth_data.extend_from_slice(&1u32.to_be_bytes());
870
871        let client_data = serde_json::json!({
872            "type": "webauthn.get",
873            "challenge": challenge_b64,
874            "origin": "http://localhost:8080",
875        });
876        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
877        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
878        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
879
880        let mut signed_data = Vec::new();
881        signed_data.extend_from_slice(&auth_data);
882        signed_data.extend_from_slice(&client_data_hash);
883
884        let mut wrong_sig = vec![0u8; 64];
885        wrong_sig[0] = 0xFF;
886
887        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
888        let sig_b64 = base64_encode_urlsafe(&wrong_sig);
889        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
890
891        let result = verify_authentication(&AuthenticationParams {
892            challenge_bytes: challenge.clone(),
893            client_data_json_b64: client_data_b64,
894            authenticator_data_b64: auth_data_b64,
895            signature_b64: sig_b64,
896            credential_id_b64: cred_id_b64.clone(),
897            public_key_cose: cose_key,
898            current_sign_count: 0,
899            allowed_credential_ids: vec![cred_id_b64],
900            rp_id: "localhost".to_string(),
901            rp_origins: vec!["http://localhost:8080".to_string()],
902        });
903        assert!(matches!(
904            result,
905            Err(WebauthnError::SignatureVerificationFailed)
906        ));
907    }
908
909    #[test]
910    fn test_verify_authentication_credential_not_allowed() {
911        let challenge = generate_challenge_bytes();
912        let challenge_b64 = base64_encode_urlsafe(&challenge);
913
914        let client_data = serde_json::json!({
915            "type": "webauthn.get",
916            "challenge": challenge_b64,
917            "origin": "http://localhost:8080",
918        });
919        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
920
921        use sha2::Digest;
922        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
923        let mut auth_data = Vec::new();
924        auth_data.extend_from_slice(&rp_id_hash);
925        auth_data.push(FLAG_UP);
926        auth_data.extend_from_slice(&0u32.to_be_bytes());
927        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
928
929        let result = verify_authentication(&AuthenticationParams {
930            challenge_bytes: challenge.clone(),
931            client_data_json_b64: client_data_b64,
932            authenticator_data_b64: auth_data_b64,
933            signature_b64: "sig".to_string(),
934            credential_id_b64: "unauthorized-cred".to_string(),
935            public_key_cose: vec![0x10, 0x20],
936            current_sign_count: 0,
937            allowed_credential_ids: vec!["allowed-cred".to_string()],
938            rp_id: "localhost".to_string(),
939            rp_origins: vec!["http://localhost:8080".to_string()],
940        });
941        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
942    }
943
944    #[test]
945    fn test_verify_authentication_wrong_origin() {
946        let challenge = generate_challenge_bytes();
947        let challenge_b64 = base64_encode_urlsafe(&challenge);
948
949        let client_data = serde_json::json!({
950            "type": "webauthn.get",
951            "challenge": challenge_b64,
952            "origin": "http://evil.com",
953        });
954        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
955
956        use sha2::Digest;
957        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
958        let mut auth_data = Vec::new();
959        auth_data.extend_from_slice(&rp_id_hash);
960        auth_data.push(FLAG_UP);
961        auth_data.extend_from_slice(&0u32.to_be_bytes());
962        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
963
964        let result = verify_authentication(&AuthenticationParams {
965            challenge_bytes: challenge.clone(),
966            client_data_json_b64: client_data_b64,
967            authenticator_data_b64: auth_data_b64,
968            signature_b64: "sig".to_string(),
969            credential_id_b64: "cred".to_string(),
970            public_key_cose: vec![0x10],
971            current_sign_count: 0,
972            allowed_credential_ids: vec!["cred".to_string()],
973            rp_id: "localhost".to_string(),
974            rp_origins: vec!["http://localhost:8080".to_string()],
975        });
976        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
977    }
978
979    #[test]
980    fn test_verify_authentication_wrong_type() {
981        let challenge = generate_challenge_bytes();
982        let challenge_b64 = base64_encode_urlsafe(&challenge);
983
984        let client_data = serde_json::json!({
985            "type": "webauthn.create",
986            "challenge": challenge_b64,
987            "origin": "http://localhost:8080",
988        });
989        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
990
991        use sha2::Digest;
992        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
993        let mut auth_data = Vec::new();
994        auth_data.extend_from_slice(&rp_id_hash);
995        auth_data.push(FLAG_UP);
996        auth_data.extend_from_slice(&0u32.to_be_bytes());
997        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
998
999        let result = verify_authentication(&AuthenticationParams {
1000            challenge_bytes: challenge.clone(),
1001            client_data_json_b64: client_data_b64,
1002            authenticator_data_b64: auth_data_b64,
1003            signature_b64: "sig".to_string(),
1004            credential_id_b64: "cred".to_string(),
1005            public_key_cose: vec![0x10],
1006            current_sign_count: 0,
1007            allowed_credential_ids: vec!["cred".to_string()],
1008            rp_id: "localhost".to_string(),
1009            rp_origins: vec!["http://localhost:8080".to_string()],
1010        });
1011        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1012    }
1013
1014    #[test]
1015    fn test_verify_authentication_sign_count_decrease() {
1016        use ring::signature::EcdsaKeyPair;
1017
1018        let rng = ring::rand::SystemRandom::new();
1019        let pkcs8 =
1020            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1021                .unwrap();
1022        let key_pair = EcdsaKeyPair::from_pkcs8(
1023            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1024            pkcs8.as_ref(),
1025            &rng,
1026        )
1027        .unwrap();
1028
1029        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1030        let x = pub_bytes[1..33].to_vec();
1031        let y = pub_bytes[33..65].to_vec();
1032        let cose_key = build_cose_ec2_key(&x, &y);
1033
1034        let challenge = generate_challenge_bytes();
1035        let challenge_b64 = base64_encode_urlsafe(&challenge);
1036
1037        use sha2::Digest;
1038        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1039        let mut auth_data = Vec::new();
1040        auth_data.extend_from_slice(&rp_id_hash);
1041        auth_data.push(FLAG_UP);
1042        auth_data.extend_from_slice(&5u32.to_be_bytes());
1043
1044        let client_data = serde_json::json!({
1045            "type": "webauthn.get",
1046            "challenge": challenge_b64,
1047            "origin": "http://localhost:8080",
1048        });
1049        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1050        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1051        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1052
1053        let mut signed_data = Vec::new();
1054        signed_data.extend_from_slice(&auth_data);
1055        signed_data.extend_from_slice(&client_data_hash);
1056
1057        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1058
1059        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1060        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1061        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1062
1063        let result = verify_authentication(&AuthenticationParams {
1064            challenge_bytes: challenge.clone(),
1065            client_data_json_b64: client_data_b64,
1066            authenticator_data_b64: auth_data_b64,
1067            signature_b64: sig_b64,
1068            credential_id_b64: cred_id_b64.clone(),
1069            public_key_cose: cose_key,
1070            current_sign_count: 10,
1071            allowed_credential_ids: vec![cred_id_b64],
1072            rp_id: "localhost".to_string(),
1073            rp_origins: vec!["http://localhost:8080".to_string()],
1074        });
1075        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1076    }
1077
1078    #[test]
1079    fn test_verify_authentication_wrong_rp_id_hash() {
1080        let challenge = generate_challenge_bytes();
1081        let challenge_b64 = base64_encode_urlsafe(&challenge);
1082
1083        let client_data = serde_json::json!({
1084            "type": "webauthn.get",
1085            "challenge": challenge_b64,
1086            "origin": "http://localhost:8080",
1087        });
1088        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1089
1090        use sha2::Digest;
1091        let rp_id_hash = sha2::Sha256::digest(b"evil.com").to_vec();
1092        let mut auth_data = Vec::new();
1093        auth_data.extend_from_slice(&rp_id_hash);
1094        auth_data.push(FLAG_UP);
1095        auth_data.extend_from_slice(&0u32.to_be_bytes());
1096        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1097
1098        let result = verify_authentication(&AuthenticationParams {
1099            challenge_bytes: challenge.clone(),
1100            client_data_json_b64: client_data_b64,
1101            authenticator_data_b64: auth_data_b64,
1102            signature_b64: "sig".to_string(),
1103            credential_id_b64: "cred".to_string(),
1104            public_key_cose: vec![0x10],
1105            current_sign_count: 0,
1106            allowed_credential_ids: vec!["cred".to_string()],
1107            rp_id: "localhost".to_string(),
1108            rp_origins: vec!["http://localhost:8080".to_string()],
1109        });
1110        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1111    }
1112
1113    #[test]
1114    fn test_verify_authentication_missing_up_flag() {
1115        use ring::signature::EcdsaKeyPair;
1116
1117        let rng = ring::rand::SystemRandom::new();
1118        let pkcs8 =
1119            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1120                .unwrap();
1121        let key_pair = EcdsaKeyPair::from_pkcs8(
1122            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1123            pkcs8.as_ref(),
1124            &rng,
1125        )
1126        .unwrap();
1127
1128        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1129        let x = pub_bytes[1..33].to_vec();
1130        let y = pub_bytes[33..65].to_vec();
1131        let cose_key = build_cose_ec2_key(&x, &y);
1132
1133        let challenge = generate_challenge_bytes();
1134        let challenge_b64 = base64_encode_urlsafe(&challenge);
1135
1136        use sha2::Digest;
1137        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1138        let mut auth_data = Vec::new();
1139        auth_data.extend_from_slice(&rp_id_hash);
1140        auth_data.push(0);
1141        auth_data.extend_from_slice(&1u32.to_be_bytes());
1142
1143        let client_data = serde_json::json!({
1144            "type": "webauthn.get",
1145            "challenge": challenge_b64,
1146            "origin": "http://localhost:8080",
1147        });
1148        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1149        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1150        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1151
1152        let mut signed_data = Vec::new();
1153        signed_data.extend_from_slice(&auth_data);
1154        signed_data.extend_from_slice(&client_data_hash);
1155
1156        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1157
1158        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1159        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1160        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1161
1162        let result = verify_authentication(&AuthenticationParams {
1163            challenge_bytes: challenge.clone(),
1164            client_data_json_b64: client_data_b64,
1165            authenticator_data_b64: auth_data_b64,
1166            signature_b64: sig_b64,
1167            credential_id_b64: cred_id_b64.clone(),
1168            public_key_cose: cose_key,
1169            current_sign_count: 0,
1170            allowed_credential_ids: vec![cred_id_b64],
1171            rp_id: "localhost".to_string(),
1172            rp_origins: vec!["http://localhost:8080".to_string()],
1173        });
1174        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1175    }
1176
1177    #[test]
1178    fn test_verify_authentication_wrong_rp_id_in_client_data() {
1179        use ring::signature::EcdsaKeyPair;
1180
1181        let rng = ring::rand::SystemRandom::new();
1182        let pkcs8 =
1183            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1184                .unwrap();
1185        let key_pair = EcdsaKeyPair::from_pkcs8(
1186            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1187            pkcs8.as_ref(),
1188            &rng,
1189        )
1190        .unwrap();
1191
1192        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1193        let x = pub_bytes[1..33].to_vec();
1194        let y = pub_bytes[33..65].to_vec();
1195        let cose_key = build_cose_ec2_key(&x, &y);
1196
1197        let challenge = generate_challenge_bytes();
1198        let challenge_b64 = base64_encode_urlsafe(&challenge);
1199
1200        let client_data = serde_json::json!({
1201            "type": "webauthn.get",
1202            "challenge": challenge_b64,
1203            "origin": "http://localhost:8080",
1204            "rpId": "evil.com",
1205        });
1206        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1207        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1208
1209        use sha2::Digest;
1210        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1211        let mut auth_data = Vec::new();
1212        auth_data.extend_from_slice(&rp_id_hash);
1213        auth_data.push(FLAG_UP);
1214        auth_data.extend_from_slice(&1u32.to_be_bytes());
1215
1216        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1217        let mut signed_data = Vec::new();
1218        signed_data.extend_from_slice(&auth_data);
1219        signed_data.extend_from_slice(&client_data_hash);
1220
1221        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1222
1223        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1224        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1225        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1226
1227        let result = verify_authentication(&AuthenticationParams {
1228            challenge_bytes: challenge.clone(),
1229            client_data_json_b64: client_data_b64,
1230            authenticator_data_b64: auth_data_b64,
1231            signature_b64: sig_b64,
1232            credential_id_b64: cred_id_b64.clone(),
1233            public_key_cose: cose_key,
1234            current_sign_count: 0,
1235            allowed_credential_ids: vec![cred_id_b64],
1236            rp_id: "localhost".to_string(),
1237            rp_origins: vec!["http://localhost:8080".to_string()],
1238        });
1239        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1240    }
1241
1242    #[test]
1243    fn test_verify_authentication_challenge_mismatch() {
1244        let challenge = generate_challenge_bytes();
1245        let _challenge_b64 = base64_encode_urlsafe(&challenge);
1246
1247        let client_data = serde_json::json!({
1248            "type": "webauthn.get",
1249            "challenge": base64_encode_urlsafe(&[99u8; 32]),
1250            "origin": "http://localhost:8080",
1251        });
1252        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1253
1254        use sha2::Digest;
1255        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1256        let mut auth_data = Vec::new();
1257        auth_data.extend_from_slice(&rp_id_hash);
1258        auth_data.push(FLAG_UP);
1259        auth_data.extend_from_slice(&0u32.to_be_bytes());
1260        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1261
1262        let result = verify_authentication(&AuthenticationParams {
1263            challenge_bytes: challenge.clone(),
1264            client_data_json_b64: client_data_b64,
1265            authenticator_data_b64: auth_data_b64,
1266            signature_b64: "sig".to_string(),
1267            credential_id_b64: "cred".to_string(),
1268            public_key_cose: vec![0x10],
1269            current_sign_count: 0,
1270            allowed_credential_ids: vec!["cred".to_string()],
1271            rp_id: "localhost".to_string(),
1272            rp_origins: vec!["http://localhost:8080".to_string()],
1273        });
1274        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1275    }
1276
1277    #[test]
1278    fn test_verify_authentication_rs256_roundtrip() {
1279        // Synthetic fixed-size RSA test key is impractical here; verify the
1280        // RS256 *dispatch* path instead: an ES256 key claiming RS256 is
1281        // rejected (key type/algorithm cross-check), proving the RS256 arm
1282        // requires RSA material.
1283        let cose_key = build_cose_rsa_key(&[0xAA; 256], &[0x01, 0x00, 0x01]);
1284        let (alg, key) = crate::crypto::parse_cose_key(&cose_key).unwrap();
1285        assert_eq!(alg, COSE_ALG_RS256);
1286        let result = verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]);
1287        assert!(matches!(
1288            result,
1289            Err(WebauthnError::SignatureVerificationFailed)
1290        ));
1291    }
1292}