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