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;
36use crate::policy::{BackupPolicy, CredentialPolicy, UserVerificationPolicy};
37
38/// Parsed authenticator data structure (CTAP2 §6.1).
39#[derive(Debug, Clone)]
40struct AuthenticatorData {
41    /// SHA-256 of the expected RP ID, as claimed by the authenticator.
42    rp_id_hash: Vec<u8>,
43    /// Flag byte (UP/UV/BE/BS/AT/ED bits).
44    flags: u8,
45    /// Big-endian signature counter.
46    sign_count: u32,
47    /// COSE-encoded credential public key (present when AT flag set).
48    credential_public_key_cose: Option<Vec<u8>>,
49    /// Raw credential ID (present when AT flag set).
50    credential_id: Option<Vec<u8>>,
51    /// 16-byte AAGUID (present when AT flag set).
52    aaguid: Option<[u8; 16]>,
53    /// Backup eligibility (BE): the credential may be backed up / synced
54    /// (multi-device credential). Creation-time property, immutable.
55    backup_eligible: bool,
56    /// Backup state (BS): the credential is currently backed up. Volatile.
57    backup_state: bool,
58}
59
60/// Authenticator data flag bit: User Present.
61const FLAG_UP: u8 = 0x01;
62/// Authenticator data flag bit: User Verified.
63const FLAG_UV: u8 = 0x04;
64/// Authenticator data flag bit: Backup Eligibility (WebAuthn L3 §6.1).
65const FLAG_BE: u8 = 0x08;
66/// Authenticator data flag bit: Backup State (WebAuthn L3 §6.1).
67const FLAG_BS: u8 = 0x10;
68/// Authenticator data flag bit: Attested Credential Data included.
69const FLAG_AT: u8 = 0x40;
70/// Authenticator data flag bit: Extension Data included (not parsed).
71#[allow(dead_code)]
72const FLAG_ED: u8 = 0x80;
73
74/// Minimum authenticator data length: rpIdHash(32) + flags(1) + signCount(4) = 37.
75const AUTH_DATA_MIN_LEN: usize = 37;
76
77/// Fixed 18-byte AAGUID + credential-length prefix in attested credential data.
78const ATTESTED_CREDENTIAL_PREFIX_LEN: usize = 18;
79
80/// Parse authenticator data from raw bytes (CTAP2 §6.1).
81///
82/// # Security notes / threat assumptions
83///
84/// - `auth_data` is attacker-controlled; any truncation or malformed length
85///   field yields `Err`, never a panic or out-of-bounds read.
86/// - When the AT flag is set, everything after the credential ID is taken as
87///   the COSE public key. If the ED flag is set, extension bytes would be
88///   appended by the authenticator *after* the key; this parser does not
89///   validate extensions and downstream CBOR parsing reads only the first
90///   top-level map, so trailing extension data is ignored rather than
91///   misinterpreted as key material.
92///
93/// # Requirements
94/// REQ-WA-100, REQ-WA-201
95fn parse_authenticator_data(auth_data: &[u8]) -> Result<AuthenticatorData, WebauthnError> {
96    if auth_data.len() < AUTH_DATA_MIN_LEN {
97        return Err(WebauthnError::VerificationFailed(format!(
98            "Authenticator data too short: {} bytes (minimum {})",
99            auth_data.len(),
100            AUTH_DATA_MIN_LEN
101        )));
102    }
103
104    // The length pre-check above makes these infallible, but `get` keeps
105    // the parser total on any future layout change (attacker-controlled
106    // input must never panic).
107    let truncated =
108        || WebauthnError::VerificationFailed("Authenticator data truncated".to_string());
109    let rp_id_hash = auth_data.get(..32).ok_or_else(truncated)?.to_vec();
110    let flags = *auth_data.get(32).ok_or_else(truncated)?;
111    let sign_count = u32::from_be_bytes(
112        auth_data
113            .get(33..37)
114            .ok_or_else(truncated)?
115            .try_into()
116            .map_err(|_| truncated())?,
117    );
118
119    let mut offset = 37;
120    let mut credential_id = None;
121    let mut credential_public_key_cose = None;
122    let mut aaguid = None;
123
124    if flags & FLAG_AT != 0 {
125        if auth_data.len() < offset + ATTESTED_CREDENTIAL_PREFIX_LEN {
126            return Err(WebauthnError::VerificationFailed(
127                "Attested credential data truncated (AAGUID + length)".to_string(),
128            ));
129        }
130        let aaguid_bytes = auth_data
131            .get(offset..offset + 16)
132            .ok_or_else(truncated)?
133            .to_vec();
134        let aaguid_arr: [u8; 16] = aaguid_bytes.try_into().map_err(|_| truncated())?;
135        aaguid = Some(aaguid_arr);
136        offset += 16;
137
138        let len_hi = *auth_data.get(offset).ok_or_else(truncated)?;
139        let len_lo = *auth_data.get(offset + 1).ok_or_else(truncated)?;
140        let cred_id_len = u16::from_be_bytes([len_hi, len_lo]) as usize;
141        offset += 2;
142
143        if auth_data.len() < offset + cred_id_len {
144            return Err(WebauthnError::VerificationFailed(
145                "Attested credential data truncated (credential ID)".to_string(),
146            ));
147        }
148        credential_id = Some(
149            auth_data
150                .get(offset..offset + cred_id_len)
151                .ok_or_else(truncated)?
152                .to_vec(),
153        );
154        offset += cred_id_len;
155
156        if offset >= auth_data.len() {
157            return Err(WebauthnError::VerificationFailed(
158                "Attested credential data truncated (public key)".to_string(),
159            ));
160        }
161        credential_public_key_cose = Some(auth_data.get(offset..).ok_or_else(truncated)?.to_vec());
162    }
163
164    Ok(AuthenticatorData {
165        rp_id_hash,
166        flags,
167        sign_count,
168        credential_public_key_cose,
169        credential_id,
170        aaguid,
171        backup_eligible: flags & FLAG_BE != 0,
172        backup_state: flags & FLAG_BS != 0,
173    })
174}
175
176/// Enforce the flag-based portion of a [`CredentialPolicy`]: user
177/// verification and backup eligibility.
178///
179/// - `UserVerificationPolicy::Required` with a clear UV flag →
180///   [`WebauthnError::UserVerificationRequired`] (REQ-WA-142).
181/// - `BackupPolicy::RequireDeviceBound` with BE set →
182///   [`WebauthnError::PolicyViolation`] (REQ-WA-144).
183///
184/// Preferred/Discouraged/Allow never reject; the flags are reported by the
185/// caller from the parsed data.
186///
187/// # Requirements
188/// REQ-WA-142, REQ-WA-144
189fn enforce_flag_policy(flags: u8, policy: &CredentialPolicy) -> Result<(), WebauthnError> {
190    if policy.user_verification == UserVerificationPolicy::Required && flags & FLAG_UV == 0 {
191        return Err(WebauthnError::UserVerificationRequired);
192    }
193    if policy.backup == BackupPolicy::RequireDeviceBound && flags & FLAG_BE != 0 {
194        return Err(WebauthnError::PolicyViolation(
195            "backup policy requires device-bound credentials, but the credential \
196             is backup-eligible (BE=1)"
197                .to_string(),
198        ));
199    }
200    Ok(())
201}
202
203/// Enforce the registration algorithm allowlist (REQ-WA-143): a non-empty
204/// `allowed_algorithms` rejects any credential whose parsed algorithm is
205/// absent.
206///
207/// # Requirements
208/// REQ-WA-143
209fn enforce_algorithm_allowlist(alg: i32, policy: &CredentialPolicy) -> Result<(), WebauthnError> {
210    if !policy.allowed_algorithms.is_empty() && !policy.allowed_algorithms.contains(&alg) {
211        return Err(WebauthnError::UnsupportedAlgorithm(alg));
212    }
213    Ok(())
214}
215
216/// Shared `clientDataJSON` validation: parse, challenge echo, ceremony type,
217/// origin allow-list, and optional `rpId` cross-check.
218///
219/// # Requirements
220/// REQ-WA-100, REQ-WA-101, REQ-WA-102, REQ-WA-103
221///
222/// Returns the raw decoded client data bytes (needed for the auth signature).
223fn validate_client_data(
224    client_data_json_b64: &str,
225    challenge_bytes: &[u8],
226    expected_type: &str,
227    rp_id: &str,
228    rp_origins: &[String],
229) -> Result<Vec<u8>, WebauthnError> {
230    let client_data_bytes = base64_decode_urlsafe(client_data_json_b64)?;
231    let client_data: serde_json::Value = serde_json::from_slice(&client_data_bytes)
232        .map_err(|e| WebauthnError::VerificationFailed(format!("client data parse error: {e}")))?;
233
234    let client_challenge = client_data
235        .get("challenge")
236        .and_then(|v| v.as_str())
237        .ok_or_else(|| {
238            WebauthnError::VerificationFailed("missing challenge in client data".to_string())
239        })?;
240    let client_challenge_bytes = base64_decode_urlsafe(client_challenge)?;
241    if client_challenge_bytes != challenge_bytes {
242        return Err(WebauthnError::VerificationFailed(
243            "challenge mismatch".to_string(),
244        ));
245    }
246
247    let typ = client_data
248        .get("type")
249        .and_then(|v| v.as_str())
250        .ok_or_else(|| {
251            WebauthnError::VerificationFailed("missing type in client data".to_string())
252        })?;
253    if typ != expected_type {
254        return Err(WebauthnError::VerificationFailed(format!(
255            "wrong type: {typ}"
256        )));
257    }
258
259    let origin = client_data
260        .get("origin")
261        .and_then(|v| v.as_str())
262        .ok_or_else(|| {
263            WebauthnError::VerificationFailed("missing origin in client data".to_string())
264        })?;
265    if !rp_origins.iter().any(|o| o == origin) {
266        return Err(WebauthnError::VerificationFailed(format!(
267            "origin '{origin}' not allowed"
268        )));
269    }
270
271    // Note: nested `if let` (not a let-chain) — this crate targets edition 2021.
272    if let Some(rp) = client_data.get("rpId").and_then(|v| v.as_str()) {
273        if rp != rp_id {
274            return Err(WebauthnError::VerificationFailed(format!(
275                "rpId mismatch: client sent '{rp}', expected '{rp_id}'"
276            )));
277        }
278    }
279
280    Ok(client_data_bytes)
281}
282
283/// Verify a registration response with full CTAP2/COSE verification and
284/// attestation statement verification.
285///
286/// # Security notes / threat assumptions
287///
288/// - `challenge_bytes` must be the server-generated, single-use challenge for
289///   this ceremony (obtain via [`crate::ChallengeStore::consume_registration_challenge`]
290///   immediately before calling).
291/// - `rp_origins` is an exact-match allow-list; `rp_id` is SHA-256-hashed and
292///   compared against the authenticator's `rpIdHash`, which binds the
293///   credential to this RP.
294/// - The UP and AT flags are mandatory. UV is enforced per
295///   `credential_policy.user_verification` ([`UserVerificationPolicy::Required`]
296///   rejects UV-clear registrations); the backup-eligibility flag is enforced
297///   per `credential_policy.backup`.
298/// - Attestation statements are verified for the `none`, `packed`, and
299///   `fido-u2f` formats; unknown formats are rejected unless
300///   [`AttestationPolicy::allow_unknown_formats`] is set (trust-weakening).
301///   The provenance conveyed by a verified attestation is bounded by
302///   `attestation.trust_level` in the result — see
303///   [`crate::attestation`]: only [`crate::attestation::TrustLevel::AttCa`]
304///   (chain terminating at a configured trust anchor) attests device
305///   provenance.
306/// - Re-registration of an existing credential is rejected when
307///   `existing_credential_id` matches the presented credential ID.
308///
309/// # Requirements
310/// REQ-WA-001, REQ-WA-101, REQ-WA-102, REQ-WA-103, REQ-WA-104, REQ-WA-105,
311/// REQ-WA-113, REQ-WA-117, REQ-WA-119, REQ-WA-120, REQ-WA-121, REQ-WA-123,
312/// REQ-WA-124, REQ-WA-126, REQ-WA-142, REQ-WA-143, REQ-WA-144
313///
314/// Returns the verified registration data to persist.
315#[allow(clippy::too_many_arguments)]
316pub fn verify_registration(
317    challenge_bytes: &[u8],
318    client_data_json_b64: &str,
319    attestation_object_b64: &str,
320    existing_credential_id: &str,
321    rp_id: &str,
322    rp_origins: &[String],
323    attestation_policy: &AttestationPolicy,
324    credential_policy: &CredentialPolicy,
325) -> Result<RegistrationResult, WebauthnError> {
326    let client_data_bytes = validate_client_data(
327        client_data_json_b64,
328        challenge_bytes,
329        "webauthn.create",
330        rp_id,
331        rp_origins,
332    )?;
333
334    let attestation_bytes = base64_decode_urlsafe(attestation_object_b64)?;
335    let attestation_val: ciborium::Value = ciborium::de::from_reader(&attestation_bytes[..])
336        .map_err(|e| {
337            WebauthnError::AttestationError(format!("attestation object CBOR parse error: {e}"))
338        })?;
339
340    let attestation_entries = cbor_map_entries(&attestation_val).ok_or_else(|| {
341        WebauthnError::AttestationError("attestation object is not a CBOR map".to_string())
342    })?;
343
344    let mut fmt: Option<String> = None;
345    let mut auth_data_bytes: Option<Vec<u8>> = None;
346    let mut att_stmt: Option<ciborium::Value> = None;
347
348    for (key, val) in &attestation_entries {
349        match *key {
350            1 => {
351                if let ciborium::Value::Text(s) = val {
352                    fmt = Some(s.clone());
353                }
354            }
355            2 => {
356                if let Some(b) = cbor_bytes(val) {
357                    auth_data_bytes = Some(b);
358                }
359            }
360            3 => {
361                att_stmt = Some(val.clone());
362            }
363            _ => {}
364        }
365    }
366
367    let fmt = fmt.unwrap_or_else(|| "none".to_string());
368    let auth_data_bytes = auth_data_bytes.ok_or_else(|| {
369        WebauthnError::AttestationError("missing authData in attestation object".to_string())
370    })?;
371
372    let auth_data = parse_authenticator_data(&auth_data_bytes)?;
373
374    use sha2::Digest;
375    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
376    if auth_data.rp_id_hash != computed_rp_id_hash {
377        return Err(WebauthnError::VerificationFailed(format!(
378            "rpId hash mismatch: computed {:x?}, got {:x?}",
379            computed_rp_id_hash, auth_data.rp_id_hash
380        )));
381    }
382
383    if auth_data.flags & FLAG_UP == 0 {
384        return Err(WebauthnError::VerificationFailed(
385            "User Present flag not set".to_string(),
386        ));
387    }
388
389    if auth_data.flags & FLAG_AT == 0 {
390        return Err(WebauthnError::VerificationFailed(
391            "Attested Credential Data flag not set during registration".to_string(),
392        ));
393    }
394
395    // Credential policy: UV requirement and backup eligibility (REQ-WA-142/144).
396    enforce_flag_policy(auth_data.flags, credential_policy)?;
397
398    let credential_id = auth_data.credential_id.ok_or_else(|| {
399        WebauthnError::VerificationFailed("no credential ID in attested data".to_string())
400    })?;
401    let credential_id_b64 = base64_encode_urlsafe(&credential_id);
402
403    if existing_credential_id == credential_id_b64 {
404        return Err(WebauthnError::DuplicateCredential(credential_id_b64));
405    }
406
407    let public_key_cose = auth_data.credential_public_key_cose.ok_or_else(|| {
408        WebauthnError::VerificationFailed("no public key in attested data".to_string())
409    })?;
410
411    let (alg, cose_key) = parse_cose_key(&public_key_cose)?;
412
413    // Server-side algorithm allowlist (REQ-WA-143).
414    enforce_algorithm_allowlist(alg, credential_policy)?;
415
416    // Attestation verification (REQ-WA-119..): dispatch on `fmt` and verify
417    // the statement per the caller's policy. Fails closed on tampered
418    // signatures, unknown formats (unless opted out), and policy violations.
419    let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
420    let aaguid = auth_data.aaguid.unwrap_or([0u8; 16]);
421    let attestation = verify_attestation(
422        &fmt,
423        att_stmt.as_ref(),
424        &auth_data_bytes,
425        &client_data_hash,
426        &credential_id,
427        alg,
428        &cose_key,
429        aaguid,
430        attestation_policy,
431    )?;
432
433    let user_verified = auth_data.flags & FLAG_UV != 0;
434
435    Ok(RegistrationResult {
436        credential_id: credential_id_b64,
437        device_name: format!("WebAuthn ({})", alg_to_name(alg)),
438        attestation_format: fmt,
439        attestation,
440        user_verified,
441        backup_eligible: auth_data.backup_eligible,
442        backup_state: auth_data.backup_state,
443    })
444}
445
446/// Parameters for verifying an authentication response.
447///
448/// All base64 fields are client-supplied and hostile; `public_key_cose` and
449/// `current_sign_count` are the server-side stored credential state.
450#[derive(Debug, Clone)]
451pub struct AuthenticationParams {
452    /// The raw challenge bytes that were stored server-side.
453    pub challenge_bytes: Vec<u8>,
454    /// Base64url-encoded client data JSON.
455    pub client_data_json_b64: String,
456    /// Base64url-encoded authenticator data.
457    pub authenticator_data_b64: String,
458    /// Base64url-encoded signature.
459    pub signature_b64: String,
460    /// Base64url-encoded credential ID presented by the authenticator.
461    pub credential_id_b64: String,
462    /// COSE-encoded public key for this credential.
463    pub public_key_cose: Vec<u8>,
464    /// Current sign count stored server-side for this credential.
465    pub current_sign_count: u32,
466    /// Allowed credential IDs for this authentication session.
467    pub allowed_credential_ids: Vec<String>,
468    /// Expected relying party ID.
469    pub rp_id: String,
470    /// Allowed origins.
471    pub rp_origins: Vec<String>,
472    /// Credential policy enforced on this assertion
473    /// (default: [`CredentialPolicy::default`] — UV reported, not required).
474    ///
475    /// # Security note
476    ///
477    /// With [`UserVerificationPolicy::Required`], an assertion whose UV flag
478    /// is clear is rejected — this is the enforcement point for step-up or
479    /// high-assurance logins.
480    pub policy: CredentialPolicy,
481}
482
483/// Verify an authentication response with full CTAP2/COSE signature
484/// verification.
485///
486/// Verification steps, in order:
487///
488/// 1. Credential ID membership in `allowed_credential_ids`.
489/// 2. `clientDataJSON` validation (challenge echo, `type == "webauthn.get"`,
490///    origin allow-list, optional `rpId` cross-check).
491/// 3. Authenticator data parsing and `rpIdHash` comparison.
492/// 4. User Presence flag + credential-policy flag enforcement (UV, backup).
493/// 5. Sign-count freshness ([`check_sign_count`]).
494/// 6. `ring` signature verification over `authenticatorData || SHA-256(clientDataJSON)`.
495///
496/// # Security notes / threat assumptions
497///
498/// - `challenge_bytes` must be consumed from single-use storage before this
499///   call; this module does not (and cannot) detect a challenge used twice.
500/// - On success the caller MUST persist [`AuthenticationResult::new_sign_count`]
501///   (ideally compare-and-swap) before considering the user logged in.
502/// - UV is enforced per `params.policy.user_verification`
503///   ([`UserVerificationPolicy::Required`] rejects UV-clear assertions);
504///   backup eligibility per `params.policy.backup`. The BE/BS flags are
505///   always reported on the result.
506///
507/// # Requirements
508/// REQ-WA-002, REQ-WA-101, REQ-WA-102, REQ-WA-103, REQ-WA-104, REQ-WA-105,
509/// REQ-WA-106, REQ-WA-111, REQ-WA-114, REQ-WA-116, REQ-WA-142, REQ-WA-144
510///
511/// Returns the verified authentication data.
512pub fn verify_authentication(
513    params: &AuthenticationParams,
514) -> Result<AuthenticationResult, WebauthnError> {
515    let AuthenticationParams {
516        challenge_bytes,
517        client_data_json_b64,
518        authenticator_data_b64,
519        signature_b64,
520        credential_id_b64,
521        public_key_cose,
522        current_sign_count,
523        allowed_credential_ids,
524        rp_id,
525        rp_origins,
526        policy,
527    } = params;
528
529    if !allowed_credential_ids.contains(credential_id_b64) {
530        return Err(WebauthnError::VerificationFailed(
531            "credential ID not in allowed list".to_string(),
532        ));
533    }
534
535    let client_data_bytes = validate_client_data(
536        client_data_json_b64,
537        challenge_bytes,
538        "webauthn.get",
539        rp_id,
540        rp_origins,
541    )?;
542
543    let authenticator_data = base64_decode_urlsafe(authenticator_data_b64)?;
544    let auth_data = parse_authenticator_data(&authenticator_data)?;
545
546    use sha2::Digest;
547    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
548    if auth_data.rp_id_hash != computed_rp_id_hash {
549        return Err(WebauthnError::VerificationFailed(format!(
550            "rpId hash mismatch: computed {:x?}, got {:x?}",
551            computed_rp_id_hash, auth_data.rp_id_hash
552        )));
553    }
554
555    if auth_data.flags & FLAG_UP == 0 {
556        return Err(WebauthnError::VerificationFailed(
557            "User Present flag not set".to_string(),
558        ));
559    }
560
561    // Credential policy: UV requirement and backup eligibility (REQ-WA-142/144).
562    enforce_flag_policy(auth_data.flags, policy)?;
563
564    check_sign_count(*current_sign_count, auth_data.sign_count)?;
565
566    let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
567
568    let mut signed_data = Vec::with_capacity(authenticator_data.len() + 32);
569    signed_data.extend_from_slice(&authenticator_data);
570    signed_data.extend_from_slice(&client_data_hash);
571
572    let signature = base64_decode_urlsafe(signature_b64)?;
573
574    let (alg, cose_key) = parse_cose_key(public_key_cose)?;
575    verify_cose_signature(alg, &cose_key, &signed_data, &signature)?;
576
577    let user_verified = auth_data.flags & FLAG_UV != 0;
578    let new_sign_count = auth_data.sign_count;
579
580    Ok(AuthenticationResult {
581        credential_id: credential_id_b64.clone(),
582        new_sign_count,
583        user_verified,
584        backup_state: auth_data.backup_state,
585        backup_eligible: auth_data.backup_eligible,
586    })
587}
588
589// Tests exercise failure paths and invariants directly; unwrap/expect,
590// slicing, and panicking asserts are acceptable here — violations
591// surface as test failures, not production panics.
592#[allow(
593    clippy::unwrap_used,
594    clippy::expect_used,
595    clippy::indexing_slicing,
596    clippy::panic
597)]
598#[cfg(test)]
599mod tests {
600    use super::*;
601    use crate::crypto::tests::{build_cose_ec2_key, build_cose_rsa_key};
602    use crate::crypto::{base64_encode_urlsafe, generate_challenge_bytes, COSE_ALG_RS256};
603    use ring::signature::KeyPair as _;
604
605    fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
606        use ciborium::Value;
607        let map = vec![
608            (Value::Integer(1.into()), Value::Text("none".to_string())),
609            (Value::Integer(2.into()), Value::Bytes(auth_data.to_vec())),
610            (Value::Integer(3.into()), Value::Map(vec![])),
611        ];
612        let mut buf = Vec::new();
613        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
614        buf
615    }
616
617    fn build_auth_data_with_credential(
618        rp_id: &str,
619        flags: u8,
620        sign_count: u32,
621        credential_id: &[u8],
622        cose_key: &[u8],
623    ) -> Vec<u8> {
624        use sha2::Digest;
625        let rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
626        let mut auth_data = Vec::new();
627        auth_data.extend_from_slice(&rp_id_hash);
628        auth_data.push(flags);
629        auth_data.extend_from_slice(&sign_count.to_be_bytes());
630
631        if flags & FLAG_AT != 0 {
632            auth_data.extend_from_slice(&[0u8; 16]);
633            auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
634            auth_data.extend_from_slice(credential_id);
635            auth_data.extend_from_slice(cose_key);
636        }
637
638        auth_data
639    }
640
641    #[test]
642    fn test_parse_authenticator_data_minimal() {
643        let mut auth_data = vec![0u8; 37];
644        auth_data[..32].copy_from_slice(&[0xAA; 32]);
645        auth_data[32] = FLAG_UP;
646        auth_data[33..37].copy_from_slice(&1u32.to_be_bytes());
647
648        let parsed = parse_authenticator_data(&auth_data).unwrap();
649        assert_eq!(parsed.rp_id_hash, vec![0xAA; 32]);
650        assert_eq!(parsed.flags, FLAG_UP);
651        assert_eq!(parsed.sign_count, 1);
652        assert!(parsed.credential_public_key_cose.is_none());
653        assert!(parsed.credential_id.is_none());
654    }
655
656    #[test]
657    fn test_parse_authenticator_data_with_attested_credential() {
658        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
659        let public_key_cose = vec![0x10, 0x20, 0x30];
660
661        let total_len = 37 + 16 + 2 + credential_id.len() + public_key_cose.len();
662        let mut auth_data = vec![0u8; total_len];
663        auth_data[32] = FLAG_UP | FLAG_AT;
664        auth_data[33..37].copy_from_slice(&5u32.to_be_bytes());
665
666        let offset = 37 + 16;
667        auth_data[offset..offset + 2].copy_from_slice(&(credential_id.len() as u16).to_be_bytes());
668        auth_data[offset + 2..offset + 2 + credential_id.len()].copy_from_slice(&credential_id);
669        let pk_offset = offset + 2 + credential_id.len();
670        auth_data[pk_offset..].copy_from_slice(&public_key_cose);
671
672        let parsed = parse_authenticator_data(&auth_data).unwrap();
673        assert_eq!(parsed.flags, FLAG_UP | FLAG_AT);
674        assert_eq!(parsed.sign_count, 5);
675        assert_eq!(parsed.credential_id, Some(credential_id));
676        assert_eq!(parsed.credential_public_key_cose, Some(public_key_cose));
677    }
678
679    #[test]
680    fn test_parse_authenticator_data_too_short() {
681        let auth_data = vec![0u8; 10];
682        let result = parse_authenticator_data(&auth_data);
683        assert!(result.is_err());
684    }
685
686    #[test]
687    fn test_verify_registration_valid_es256() {
688        use ring::signature::EcdsaKeyPair;
689
690        let rng = ring::rand::SystemRandom::new();
691        let pkcs8 =
692            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
693                .unwrap();
694        let key_pair = EcdsaKeyPair::from_pkcs8(
695            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
696            pkcs8.as_ref(),
697            &rng,
698        )
699        .unwrap();
700
701        let pub_bytes = key_pair.public_key().as_ref().to_vec();
702        let x = pub_bytes[1..33].to_vec();
703        let y = pub_bytes[33..65].to_vec();
704        let cose_key = build_cose_ec2_key(&x, &y);
705
706        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
707        let auth_data = build_auth_data_with_credential(
708            "localhost",
709            FLAG_UP | FLAG_AT,
710            0,
711            &credential_id,
712            &cose_key,
713        );
714        let att_obj = build_attestation_object(&auth_data);
715
716        let challenge = generate_challenge_bytes();
717        let challenge_b64 = base64_encode_urlsafe(&challenge);
718
719        let client_data = serde_json::json!({
720            "type": "webauthn.create",
721            "challenge": challenge_b64,
722            "origin": "http://localhost:8080",
723        });
724        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
725        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
726
727        let result = verify_registration(
728            &challenge,
729            &client_data_b64,
730            &att_obj_b64,
731            "different-id",
732            "localhost",
733            &["http://localhost:8080".to_string()],
734            &AttestationPolicy::default(),
735            &CredentialPolicy::default(),
736        )
737        .unwrap();
738
739        assert_eq!(result.credential_id, base64_encode_urlsafe(&credential_id));
740        assert!(result.attestation_format == "none");
741    }
742
743    #[test]
744    fn test_verify_registration_challenge_mismatch() {
745        let auth_data = build_auth_data_with_credential(
746            "localhost",
747            FLAG_UP | FLAG_AT,
748            0,
749            &[0x01],
750            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
751        );
752        let att_obj = build_attestation_object(&auth_data);
753
754        let challenge = generate_challenge_bytes();
755        let _challenge_b64 = base64_encode_urlsafe(&challenge);
756
757        let client_data = serde_json::json!({
758            "type": "webauthn.create",
759            "challenge": base64_encode_urlsafe(&[1u8; 32]),
760            "origin": "http://localhost:8080",
761        });
762        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
763        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
764
765        let result = verify_registration(
766            &challenge,
767            &client_data_b64,
768            &att_obj_b64,
769            "different",
770            "localhost",
771            &["http://localhost:8080".to_string()],
772            &AttestationPolicy::default(),
773            &CredentialPolicy::default(),
774        );
775        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
776    }
777
778    #[test]
779    fn test_verify_registration_wrong_type() {
780        let auth_data = build_auth_data_with_credential(
781            "localhost",
782            FLAG_UP | FLAG_AT,
783            0,
784            &[0x01],
785            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
786        );
787        let att_obj = build_attestation_object(&auth_data);
788
789        let challenge = generate_challenge_bytes();
790        let challenge_b64 = base64_encode_urlsafe(&challenge);
791
792        let client_data = serde_json::json!({
793            "type": "webauthn.get",
794            "challenge": challenge_b64,
795            "origin": "http://localhost:8080",
796        });
797        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
798        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
799
800        let result = verify_registration(
801            &challenge,
802            &client_data_b64,
803            &att_obj_b64,
804            "different",
805            "localhost",
806            &["http://localhost:8080".to_string()],
807            &AttestationPolicy::default(),
808            &CredentialPolicy::default(),
809        );
810        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
811    }
812
813    #[test]
814    fn test_verify_registration_wrong_origin() {
815        let auth_data = build_auth_data_with_credential(
816            "localhost",
817            FLAG_UP | FLAG_AT,
818            0,
819            &[0x01],
820            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
821        );
822        let att_obj = build_attestation_object(&auth_data);
823
824        let challenge = generate_challenge_bytes();
825        let challenge_b64 = base64_encode_urlsafe(&challenge);
826
827        let client_data = serde_json::json!({
828            "type": "webauthn.create",
829            "challenge": challenge_b64,
830            "origin": "http://evil.com",
831        });
832        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
833        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
834
835        let result = verify_registration(
836            &challenge,
837            &client_data_b64,
838            &att_obj_b64,
839            "different",
840            "localhost",
841            &["http://localhost:8080".to_string()],
842            &AttestationPolicy::default(),
843            &CredentialPolicy::default(),
844        );
845        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
846    }
847
848    #[test]
849    fn test_verify_registration_duplicate() {
850        let auth_data = build_auth_data_with_credential(
851            "localhost",
852            FLAG_UP | FLAG_AT,
853            0,
854            &[0x01, 0x02, 0x03],
855            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
856        );
857        let att_obj = build_attestation_object(&auth_data);
858
859        let challenge = generate_challenge_bytes();
860        let challenge_b64 = base64_encode_urlsafe(&challenge);
861
862        let client_data = serde_json::json!({
863            "type": "webauthn.create",
864            "challenge": challenge_b64,
865            "origin": "http://localhost:8080",
866        });
867        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
868        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
869        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02, 0x03]);
870
871        let result = verify_registration(
872            &challenge,
873            &client_data_b64,
874            &att_obj_b64,
875            &cred_id_b64,
876            "localhost",
877            &["http://localhost:8080".to_string()],
878            &AttestationPolicy::default(),
879            &CredentialPolicy::default(),
880        );
881        assert!(matches!(result, Err(WebauthnError::DuplicateCredential(_))));
882    }
883
884    #[test]
885    fn test_verify_registration_wrong_rp_id_hash() {
886        let credential_id = vec![0x01];
887        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
888        let auth_data = build_auth_data_with_credential(
889            "evil.com",
890            FLAG_UP | FLAG_AT,
891            0,
892            &credential_id,
893            &cose_key,
894        );
895        let att_obj = build_attestation_object(&auth_data);
896
897        let challenge = generate_challenge_bytes();
898        let challenge_b64 = base64_encode_urlsafe(&challenge);
899
900        let client_data = serde_json::json!({
901            "type": "webauthn.create",
902            "challenge": challenge_b64,
903            "origin": "http://localhost:8080",
904        });
905        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
906        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
907
908        let result = verify_registration(
909            &challenge,
910            &client_data_b64,
911            &att_obj_b64,
912            "different",
913            "localhost",
914            &["http://localhost:8080".to_string()],
915            &AttestationPolicy::default(),
916            &CredentialPolicy::default(),
917        );
918        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
919    }
920
921    #[test]
922    fn test_verify_registration_missing_up_flag() {
923        let credential_id = vec![0x01];
924        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
925        let auth_data =
926            build_auth_data_with_credential("localhost", FLAG_AT, 0, &credential_id, &cose_key);
927        let att_obj = build_attestation_object(&auth_data);
928
929        let challenge = generate_challenge_bytes();
930        let challenge_b64 = base64_encode_urlsafe(&challenge);
931
932        let client_data = serde_json::json!({
933            "type": "webauthn.create",
934            "challenge": challenge_b64,
935            "origin": "http://localhost:8080",
936        });
937        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
938        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
939
940        let result = verify_registration(
941            &challenge,
942            &client_data_b64,
943            &att_obj_b64,
944            "different",
945            "localhost",
946            &["http://localhost:8080".to_string()],
947            &AttestationPolicy::default(),
948            &CredentialPolicy::default(),
949        );
950        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
951    }
952
953    /// REQ-WA-117: registration with UP set but the AT flag clear (no
954    /// attested credential data) must be rejected — a credential cannot be
955    /// registered without attested key material.
956    #[test]
957    fn registration_rejects_missing_at_flag() {
958        let auth_data = build_auth_data_with_credential(
959            "localhost",
960            FLAG_UP, // UP set, AT clear
961            0,
962            &[0x01],
963            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
964        );
965        let att_obj = build_attestation_object(&auth_data);
966
967        let challenge = generate_challenge_bytes();
968        let challenge_b64 = base64_encode_urlsafe(&challenge);
969
970        let client_data = serde_json::json!({
971            "type": "webauthn.create",
972            "challenge": challenge_b64,
973            "origin": "http://localhost:8080",
974        });
975        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
976        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
977
978        let result = verify_registration(
979            &challenge,
980            &client_data_b64,
981            &att_obj_b64,
982            "different",
983            "localhost",
984            &["http://localhost:8080".to_string()],
985            &AttestationPolicy::default(),
986            &CredentialPolicy::default(),
987        );
988        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
989    }
990
991    #[test]
992    fn test_verify_authentication_valid_es256() {
993        use ring::signature::EcdsaKeyPair;
994
995        let rng = ring::rand::SystemRandom::new();
996        let pkcs8 =
997            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
998                .unwrap();
999        let key_pair = EcdsaKeyPair::from_pkcs8(
1000            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1001            pkcs8.as_ref(),
1002            &rng,
1003        )
1004        .unwrap();
1005
1006        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1007        let x = pub_bytes[1..33].to_vec();
1008        let y = pub_bytes[33..65].to_vec();
1009        let cose_key = build_cose_ec2_key(&x, &y);
1010
1011        let challenge = generate_challenge_bytes();
1012        let challenge_b64 = base64_encode_urlsafe(&challenge);
1013
1014        use sha2::Digest;
1015        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1016        let mut auth_data = Vec::new();
1017        auth_data.extend_from_slice(&rp_id_hash);
1018        auth_data.push(FLAG_UP);
1019        auth_data.extend_from_slice(&1u32.to_be_bytes());
1020
1021        let client_data = serde_json::json!({
1022            "type": "webauthn.get",
1023            "challenge": challenge_b64,
1024            "origin": "http://localhost:8080",
1025        });
1026        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1027        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1028        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1029
1030        let mut signed_data = Vec::new();
1031        signed_data.extend_from_slice(&auth_data);
1032        signed_data.extend_from_slice(&client_data_hash);
1033
1034        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1035
1036        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1037        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1038        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02]);
1039
1040        let result = verify_authentication(&AuthenticationParams {
1041            challenge_bytes: challenge.clone(),
1042            client_data_json_b64: client_data_b64,
1043            authenticator_data_b64: auth_data_b64,
1044            signature_b64: sig_b64,
1045            credential_id_b64: cred_id_b64.clone(),
1046            public_key_cose: cose_key,
1047            current_sign_count: 0,
1048            allowed_credential_ids: vec![cred_id_b64.clone()],
1049            rp_id: "localhost".to_string(),
1050            rp_origins: vec!["http://localhost:8080".to_string()],
1051            policy: CredentialPolicy::default(),
1052        })
1053        .unwrap();
1054
1055        assert_eq!(result.credential_id, cred_id_b64);
1056        assert_eq!(result.new_sign_count, 1);
1057    }
1058
1059    #[test]
1060    fn test_verify_authentication_wrong_signature() {
1061        use ring::signature::EcdsaKeyPair;
1062
1063        let rng = ring::rand::SystemRandom::new();
1064        let pkcs8 =
1065            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1066                .unwrap();
1067        let key_pair = EcdsaKeyPair::from_pkcs8(
1068            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1069            pkcs8.as_ref(),
1070            &rng,
1071        )
1072        .unwrap();
1073
1074        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1075        let x = pub_bytes[1..33].to_vec();
1076        let y = pub_bytes[33..65].to_vec();
1077        let cose_key = build_cose_ec2_key(&x, &y);
1078
1079        let challenge = generate_challenge_bytes();
1080        let challenge_b64 = base64_encode_urlsafe(&challenge);
1081
1082        use sha2::Digest;
1083        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1084        let mut auth_data = Vec::new();
1085        auth_data.extend_from_slice(&rp_id_hash);
1086        auth_data.push(FLAG_UP);
1087        auth_data.extend_from_slice(&1u32.to_be_bytes());
1088
1089        let client_data = serde_json::json!({
1090            "type": "webauthn.get",
1091            "challenge": challenge_b64,
1092            "origin": "http://localhost:8080",
1093        });
1094        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1095        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1096        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1097
1098        let mut signed_data = Vec::new();
1099        signed_data.extend_from_slice(&auth_data);
1100        signed_data.extend_from_slice(&client_data_hash);
1101
1102        let mut wrong_sig = vec![0u8; 64];
1103        wrong_sig[0] = 0xFF;
1104
1105        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1106        let sig_b64 = base64_encode_urlsafe(&wrong_sig);
1107        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1108
1109        let result = verify_authentication(&AuthenticationParams {
1110            challenge_bytes: challenge.clone(),
1111            client_data_json_b64: client_data_b64,
1112            authenticator_data_b64: auth_data_b64,
1113            signature_b64: sig_b64,
1114            credential_id_b64: cred_id_b64.clone(),
1115            public_key_cose: cose_key,
1116            current_sign_count: 0,
1117            allowed_credential_ids: vec![cred_id_b64],
1118            rp_id: "localhost".to_string(),
1119            rp_origins: vec!["http://localhost:8080".to_string()],
1120            policy: CredentialPolicy::default(),
1121        });
1122        assert!(matches!(
1123            result,
1124            Err(WebauthnError::SignatureVerificationFailed)
1125        ));
1126    }
1127
1128    #[test]
1129    fn test_verify_authentication_credential_not_allowed() {
1130        let challenge = generate_challenge_bytes();
1131        let challenge_b64 = base64_encode_urlsafe(&challenge);
1132
1133        let client_data = serde_json::json!({
1134            "type": "webauthn.get",
1135            "challenge": challenge_b64,
1136            "origin": "http://localhost:8080",
1137        });
1138        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1139
1140        use sha2::Digest;
1141        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1142        let mut auth_data = Vec::new();
1143        auth_data.extend_from_slice(&rp_id_hash);
1144        auth_data.push(FLAG_UP);
1145        auth_data.extend_from_slice(&0u32.to_be_bytes());
1146        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1147
1148        let result = verify_authentication(&AuthenticationParams {
1149            challenge_bytes: challenge.clone(),
1150            client_data_json_b64: client_data_b64,
1151            authenticator_data_b64: auth_data_b64,
1152            signature_b64: "sig".to_string(),
1153            credential_id_b64: "unauthorized-cred".to_string(),
1154            public_key_cose: vec![0x10, 0x20],
1155            current_sign_count: 0,
1156            allowed_credential_ids: vec!["allowed-cred".to_string()],
1157            rp_id: "localhost".to_string(),
1158            rp_origins: vec!["http://localhost:8080".to_string()],
1159            policy: CredentialPolicy::default(),
1160        });
1161        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1162    }
1163
1164    #[test]
1165    fn test_verify_authentication_wrong_origin() {
1166        let challenge = generate_challenge_bytes();
1167        let challenge_b64 = base64_encode_urlsafe(&challenge);
1168
1169        let client_data = serde_json::json!({
1170            "type": "webauthn.get",
1171            "challenge": challenge_b64,
1172            "origin": "http://evil.com",
1173        });
1174        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1175
1176        use sha2::Digest;
1177        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1178        let mut auth_data = Vec::new();
1179        auth_data.extend_from_slice(&rp_id_hash);
1180        auth_data.push(FLAG_UP);
1181        auth_data.extend_from_slice(&0u32.to_be_bytes());
1182        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1183
1184        let result = verify_authentication(&AuthenticationParams {
1185            challenge_bytes: challenge.clone(),
1186            client_data_json_b64: client_data_b64,
1187            authenticator_data_b64: auth_data_b64,
1188            signature_b64: "sig".to_string(),
1189            credential_id_b64: "cred".to_string(),
1190            public_key_cose: vec![0x10],
1191            current_sign_count: 0,
1192            allowed_credential_ids: vec!["cred".to_string()],
1193            rp_id: "localhost".to_string(),
1194            rp_origins: vec!["http://localhost:8080".to_string()],
1195            policy: CredentialPolicy::default(),
1196        });
1197        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1198    }
1199
1200    #[test]
1201    fn test_verify_authentication_wrong_type() {
1202        let challenge = generate_challenge_bytes();
1203        let challenge_b64 = base64_encode_urlsafe(&challenge);
1204
1205        let client_data = serde_json::json!({
1206            "type": "webauthn.create",
1207            "challenge": challenge_b64,
1208            "origin": "http://localhost:8080",
1209        });
1210        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1211
1212        use sha2::Digest;
1213        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1214        let mut auth_data = Vec::new();
1215        auth_data.extend_from_slice(&rp_id_hash);
1216        auth_data.push(FLAG_UP);
1217        auth_data.extend_from_slice(&0u32.to_be_bytes());
1218        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1219
1220        let result = verify_authentication(&AuthenticationParams {
1221            challenge_bytes: challenge.clone(),
1222            client_data_json_b64: client_data_b64,
1223            authenticator_data_b64: auth_data_b64,
1224            signature_b64: "sig".to_string(),
1225            credential_id_b64: "cred".to_string(),
1226            public_key_cose: vec![0x10],
1227            current_sign_count: 0,
1228            allowed_credential_ids: vec!["cred".to_string()],
1229            rp_id: "localhost".to_string(),
1230            rp_origins: vec!["http://localhost:8080".to_string()],
1231            policy: CredentialPolicy::default(),
1232        });
1233        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1234    }
1235
1236    #[test]
1237    fn test_verify_authentication_sign_count_decrease() {
1238        use ring::signature::EcdsaKeyPair;
1239
1240        let rng = ring::rand::SystemRandom::new();
1241        let pkcs8 =
1242            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1243                .unwrap();
1244        let key_pair = EcdsaKeyPair::from_pkcs8(
1245            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1246            pkcs8.as_ref(),
1247            &rng,
1248        )
1249        .unwrap();
1250
1251        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1252        let x = pub_bytes[1..33].to_vec();
1253        let y = pub_bytes[33..65].to_vec();
1254        let cose_key = build_cose_ec2_key(&x, &y);
1255
1256        let challenge = generate_challenge_bytes();
1257        let challenge_b64 = base64_encode_urlsafe(&challenge);
1258
1259        use sha2::Digest;
1260        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1261        let mut auth_data = Vec::new();
1262        auth_data.extend_from_slice(&rp_id_hash);
1263        auth_data.push(FLAG_UP);
1264        auth_data.extend_from_slice(&5u32.to_be_bytes());
1265
1266        let client_data = serde_json::json!({
1267            "type": "webauthn.get",
1268            "challenge": challenge_b64,
1269            "origin": "http://localhost:8080",
1270        });
1271        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1272        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1273        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1274
1275        let mut signed_data = Vec::new();
1276        signed_data.extend_from_slice(&auth_data);
1277        signed_data.extend_from_slice(&client_data_hash);
1278
1279        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1280
1281        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1282        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1283        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1284
1285        let result = verify_authentication(&AuthenticationParams {
1286            challenge_bytes: challenge.clone(),
1287            client_data_json_b64: client_data_b64,
1288            authenticator_data_b64: auth_data_b64,
1289            signature_b64: sig_b64,
1290            credential_id_b64: cred_id_b64.clone(),
1291            public_key_cose: cose_key,
1292            current_sign_count: 10,
1293            allowed_credential_ids: vec![cred_id_b64],
1294            rp_id: "localhost".to_string(),
1295            rp_origins: vec!["http://localhost:8080".to_string()],
1296            policy: CredentialPolicy::default(),
1297        });
1298        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1299    }
1300
1301    #[test]
1302    fn test_verify_authentication_wrong_rp_id_hash() {
1303        let challenge = generate_challenge_bytes();
1304        let challenge_b64 = base64_encode_urlsafe(&challenge);
1305
1306        let client_data = serde_json::json!({
1307            "type": "webauthn.get",
1308            "challenge": challenge_b64,
1309            "origin": "http://localhost:8080",
1310        });
1311        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1312
1313        use sha2::Digest;
1314        let rp_id_hash = sha2::Sha256::digest(b"evil.com").to_vec();
1315        let mut auth_data = Vec::new();
1316        auth_data.extend_from_slice(&rp_id_hash);
1317        auth_data.push(FLAG_UP);
1318        auth_data.extend_from_slice(&0u32.to_be_bytes());
1319        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1320
1321        let result = verify_authentication(&AuthenticationParams {
1322            challenge_bytes: challenge.clone(),
1323            client_data_json_b64: client_data_b64,
1324            authenticator_data_b64: auth_data_b64,
1325            signature_b64: "sig".to_string(),
1326            credential_id_b64: "cred".to_string(),
1327            public_key_cose: vec![0x10],
1328            current_sign_count: 0,
1329            allowed_credential_ids: vec!["cred".to_string()],
1330            rp_id: "localhost".to_string(),
1331            rp_origins: vec!["http://localhost:8080".to_string()],
1332            policy: CredentialPolicy::default(),
1333        });
1334        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1335    }
1336
1337    #[test]
1338    fn test_verify_authentication_missing_up_flag() {
1339        use ring::signature::EcdsaKeyPair;
1340
1341        let rng = ring::rand::SystemRandom::new();
1342        let pkcs8 =
1343            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1344                .unwrap();
1345        let key_pair = EcdsaKeyPair::from_pkcs8(
1346            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1347            pkcs8.as_ref(),
1348            &rng,
1349        )
1350        .unwrap();
1351
1352        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1353        let x = pub_bytes[1..33].to_vec();
1354        let y = pub_bytes[33..65].to_vec();
1355        let cose_key = build_cose_ec2_key(&x, &y);
1356
1357        let challenge = generate_challenge_bytes();
1358        let challenge_b64 = base64_encode_urlsafe(&challenge);
1359
1360        use sha2::Digest;
1361        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1362        let mut auth_data = Vec::new();
1363        auth_data.extend_from_slice(&rp_id_hash);
1364        auth_data.push(0);
1365        auth_data.extend_from_slice(&1u32.to_be_bytes());
1366
1367        let client_data = serde_json::json!({
1368            "type": "webauthn.get",
1369            "challenge": challenge_b64,
1370            "origin": "http://localhost:8080",
1371        });
1372        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1373        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1374        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1375
1376        let mut signed_data = Vec::new();
1377        signed_data.extend_from_slice(&auth_data);
1378        signed_data.extend_from_slice(&client_data_hash);
1379
1380        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1381
1382        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1383        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1384        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1385
1386        let result = verify_authentication(&AuthenticationParams {
1387            challenge_bytes: challenge.clone(),
1388            client_data_json_b64: client_data_b64,
1389            authenticator_data_b64: auth_data_b64,
1390            signature_b64: sig_b64,
1391            credential_id_b64: cred_id_b64.clone(),
1392            public_key_cose: cose_key,
1393            current_sign_count: 0,
1394            allowed_credential_ids: vec![cred_id_b64],
1395            rp_id: "localhost".to_string(),
1396            rp_origins: vec!["http://localhost:8080".to_string()],
1397            policy: CredentialPolicy::default(),
1398        });
1399        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1400    }
1401
1402    #[test]
1403    fn test_verify_authentication_wrong_rp_id_in_client_data() {
1404        use ring::signature::EcdsaKeyPair;
1405
1406        let rng = ring::rand::SystemRandom::new();
1407        let pkcs8 =
1408            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1409                .unwrap();
1410        let key_pair = EcdsaKeyPair::from_pkcs8(
1411            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1412            pkcs8.as_ref(),
1413            &rng,
1414        )
1415        .unwrap();
1416
1417        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1418        let x = pub_bytes[1..33].to_vec();
1419        let y = pub_bytes[33..65].to_vec();
1420        let cose_key = build_cose_ec2_key(&x, &y);
1421
1422        let challenge = generate_challenge_bytes();
1423        let challenge_b64 = base64_encode_urlsafe(&challenge);
1424
1425        let client_data = serde_json::json!({
1426            "type": "webauthn.get",
1427            "challenge": challenge_b64,
1428            "origin": "http://localhost:8080",
1429            "rpId": "evil.com",
1430        });
1431        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1432        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1433
1434        use sha2::Digest;
1435        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1436        let mut auth_data = Vec::new();
1437        auth_data.extend_from_slice(&rp_id_hash);
1438        auth_data.push(FLAG_UP);
1439        auth_data.extend_from_slice(&1u32.to_be_bytes());
1440
1441        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1442        let mut signed_data = Vec::new();
1443        signed_data.extend_from_slice(&auth_data);
1444        signed_data.extend_from_slice(&client_data_hash);
1445
1446        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1447
1448        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1449        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1450        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1451
1452        let result = verify_authentication(&AuthenticationParams {
1453            challenge_bytes: challenge.clone(),
1454            client_data_json_b64: client_data_b64,
1455            authenticator_data_b64: auth_data_b64,
1456            signature_b64: sig_b64,
1457            credential_id_b64: cred_id_b64.clone(),
1458            public_key_cose: cose_key,
1459            current_sign_count: 0,
1460            allowed_credential_ids: vec![cred_id_b64],
1461            rp_id: "localhost".to_string(),
1462            rp_origins: vec!["http://localhost:8080".to_string()],
1463            policy: CredentialPolicy::default(),
1464        });
1465        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1466    }
1467
1468    #[test]
1469    fn test_verify_authentication_challenge_mismatch() {
1470        let challenge = generate_challenge_bytes();
1471        let _challenge_b64 = base64_encode_urlsafe(&challenge);
1472
1473        let client_data = serde_json::json!({
1474            "type": "webauthn.get",
1475            "challenge": base64_encode_urlsafe(&[99u8; 32]),
1476            "origin": "http://localhost:8080",
1477        });
1478        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1479
1480        use sha2::Digest;
1481        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1482        let mut auth_data = Vec::new();
1483        auth_data.extend_from_slice(&rp_id_hash);
1484        auth_data.push(FLAG_UP);
1485        auth_data.extend_from_slice(&0u32.to_be_bytes());
1486        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1487
1488        let result = verify_authentication(&AuthenticationParams {
1489            challenge_bytes: challenge.clone(),
1490            client_data_json_b64: client_data_b64,
1491            authenticator_data_b64: auth_data_b64,
1492            signature_b64: "sig".to_string(),
1493            credential_id_b64: "cred".to_string(),
1494            public_key_cose: vec![0x10],
1495            current_sign_count: 0,
1496            allowed_credential_ids: vec!["cred".to_string()],
1497            rp_id: "localhost".to_string(),
1498            rp_origins: vec!["http://localhost:8080".to_string()],
1499            policy: CredentialPolicy::default(),
1500        });
1501        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1502    }
1503
1504    #[test]
1505    fn test_verify_authentication_rs256_roundtrip() {
1506        // Synthetic fixed-size RSA test key is impractical here; verify the
1507        // RS256 *dispatch* path instead: an ES256 key claiming RS256 is
1508        // rejected (key type/algorithm cross-check), proving the RS256 arm
1509        // requires RSA material.
1510        let cose_key = build_cose_rsa_key(&[0xAA; 256], &[0x01, 0x00, 0x01]);
1511        let (alg, key) = crate::crypto::parse_cose_key(&cose_key).unwrap();
1512        assert_eq!(alg, COSE_ALG_RS256);
1513        let result = verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]);
1514        assert!(matches!(
1515            result,
1516            Err(WebauthnError::SignatureVerificationFailed)
1517        ));
1518    }
1519
1520    /// Build a fully valid ES256 registration response. `rp_id_field`
1521    /// injects a matching `rpId` into clientDataJSON; `extra_att_key`
1522    /// appends an unknown key to the attestation object (must be ignored).
1523    fn valid_registration(rp_id_field: bool, extra_att_key: bool) -> (Vec<u8>, String, String) {
1524        use ring::signature::EcdsaKeyPair;
1525
1526        let rng = ring::rand::SystemRandom::new();
1527        let pkcs8 =
1528            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1529                .unwrap();
1530        let key_pair = EcdsaKeyPair::from_pkcs8(
1531            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1532            pkcs8.as_ref(),
1533            &rng,
1534        )
1535        .unwrap();
1536        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1537        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
1538
1539        let credential_id = vec![0x42u8; 8];
1540        let auth_data = build_auth_data_with_credential(
1541            "localhost",
1542            FLAG_UP | FLAG_AT,
1543            0,
1544            &credential_id,
1545            &cose_key,
1546        );
1547
1548        let challenge = generate_challenge_bytes();
1549        let challenge_b64 = base64_encode_urlsafe(&challenge);
1550
1551        let mut client_data = serde_json::json!({
1552            "type": "webauthn.create",
1553            "challenge": challenge_b64,
1554            "origin": "http://localhost:8080",
1555        });
1556        if rp_id_field {
1557            client_data["rpId"] = serde_json::json!("localhost");
1558        }
1559        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1560
1561        let mut entries = vec![
1562            (
1563                ciborium::Value::Integer(1.into()),
1564                ciborium::Value::Text("none".to_string()),
1565            ),
1566            (
1567                ciborium::Value::Integer(2.into()),
1568                ciborium::Value::Bytes(auth_data),
1569            ),
1570        ];
1571        if extra_att_key {
1572            entries.push((
1573                ciborium::Value::Integer(9.into()),
1574                ciborium::Value::Bool(true),
1575            ));
1576        }
1577        entries.push((
1578            ciborium::Value::Integer(3.into()),
1579            ciborium::Value::Map(vec![]),
1580        ));
1581        let mut buf = Vec::new();
1582        ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
1583
1584        (challenge, client_data_b64, base64_encode_urlsafe(&buf))
1585    }
1586
1587    /// An explicit `rpId` field matching the RP must be accepted, and
1588    /// unknown attestation-object keys must be ignored.
1589    #[test]
1590    fn registration_accepts_rpid_field_and_unknown_keys() {
1591        let (challenge, client_data_b64, att_obj_b64) = valid_registration(true, true);
1592        let result = verify_registration(
1593            &challenge,
1594            &client_data_b64,
1595            &att_obj_b64,
1596            "",
1597            "localhost",
1598            &["http://localhost:8080".to_string()],
1599            &AttestationPolicy::default(),
1600            &CredentialPolicy::default(),
1601        )
1602        .unwrap();
1603        assert_eq!(result.attestation_format, "none");
1604        assert_eq!(result.credential_id, base64_encode_urlsafe(&[0x42u8; 8]));
1605    }
1606
1607    #[test]
1608    fn registration_rejects_missing_type_in_client_data() {
1609        let challenge = generate_challenge_bytes();
1610        let client_data = serde_json::json!({
1611            "challenge": base64_encode_urlsafe(&challenge),
1612            "origin": "http://localhost:8080",
1613        });
1614        let result = verify_registration(
1615            &challenge,
1616            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1617            &base64_encode_urlsafe(&[0xFF; 8]),
1618            "",
1619            "localhost",
1620            &["http://localhost:8080".to_string()],
1621            &AttestationPolicy::default(),
1622            &CredentialPolicy::default(),
1623        );
1624        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1625    }
1626
1627    #[test]
1628    fn registration_rejects_missing_origin_in_client_data() {
1629        let challenge = generate_challenge_bytes();
1630        let client_data = serde_json::json!({
1631            "type": "webauthn.create",
1632            "challenge": base64_encode_urlsafe(&challenge),
1633        });
1634        let result = verify_registration(
1635            &challenge,
1636            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1637            &base64_encode_urlsafe(&[0xFF; 8]),
1638            "",
1639            "localhost",
1640            &["http://localhost:8080".to_string()],
1641            &AttestationPolicy::default(),
1642            &CredentialPolicy::default(),
1643        );
1644        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1645    }
1646
1647    /// Attested credential data that ends right after the credential ID
1648    /// (no COSE key bytes) must be rejected as truncated, never sliced
1649    /// out of bounds.
1650    #[test]
1651    fn parse_auth_data_rejects_truncated_public_key() {
1652        let mut auth_data = vec![0u8; 37 + 16 + 2 + 2];
1653        auth_data[32] = FLAG_UP | FLAG_AT;
1654        auth_data[53..55].copy_from_slice(&2u16.to_be_bytes());
1655        let result = parse_authenticator_data(&auth_data);
1656        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1657    }
1658
1659    // ---- ES384 / EdDSA ceremonies (REQ-WA-140, REQ-WA-141) ----
1660
1661    use crate::crypto::tests::{build_cose_ec2_key_alg, build_cose_okp_key};
1662    use crate::crypto::{COSE_ALG_ES256, COSE_ALG_ES384};
1663
1664    /// Full ES384 registration roundtrip with a synthetic P-384 keypair.
1665    #[test]
1666    fn registration_roundtrip_es384() {
1667        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1668
1669        let rng = ring::rand::SystemRandom::new();
1670        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1671        let key_pair =
1672            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1673                .unwrap();
1674        let pub_bytes = key_pair.public_key().as_ref();
1675        let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1676
1677        let credential_id = vec![0xE3u8; 8];
1678        let auth_data = build_auth_data_with_credential(
1679            "localhost",
1680            FLAG_UP | FLAG_AT | FLAG_UV | FLAG_BE | FLAG_BS,
1681            0,
1682            &credential_id,
1683            &cose_key,
1684        );
1685        let att_obj = build_attestation_object(&auth_data);
1686
1687        let challenge = generate_challenge_bytes();
1688        let client_data = serde_json::json!({
1689            "type": "webauthn.create",
1690            "challenge": base64_encode_urlsafe(&challenge),
1691            "origin": "http://localhost:8080",
1692        });
1693        let result = verify_registration(
1694            &challenge,
1695            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1696            &base64_encode_urlsafe(&att_obj),
1697            "",
1698            "localhost",
1699            &["http://localhost:8080".to_string()],
1700            &AttestationPolicy::default(),
1701            &CredentialPolicy::default(),
1702        )
1703        .unwrap();
1704
1705        assert!(result.device_name.contains("ES384"));
1706        assert!(result.user_verified);
1707        assert!(result.backup_eligible);
1708        assert!(result.backup_state);
1709    }
1710
1711    /// Full EdDSA registration → authentication roundtrip.
1712    #[test]
1713    fn registration_authentication_roundtrip_eddsa() {
1714        use ring::signature::{Ed25519KeyPair, KeyPair as _};
1715
1716        let rng = ring::rand::SystemRandom::new();
1717        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
1718        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1719        let cose_key = build_cose_okp_key(key_pair.public_key().as_ref());
1720
1721        // -- registration --
1722        let credential_id = vec![0xEDu8; 8];
1723        let auth_data = build_auth_data_with_credential(
1724            "localhost",
1725            FLAG_UP | FLAG_AT,
1726            0,
1727            &credential_id,
1728            &cose_key,
1729        );
1730        let att_obj = build_attestation_object(&auth_data);
1731        let challenge = generate_challenge_bytes();
1732        let client_data = serde_json::json!({
1733            "type": "webauthn.create",
1734            "challenge": base64_encode_urlsafe(&challenge),
1735            "origin": "http://localhost:8080",
1736        });
1737        let registration = verify_registration(
1738            &challenge,
1739            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1740            &base64_encode_urlsafe(&att_obj),
1741            "",
1742            "localhost",
1743            &["http://localhost:8080".to_string()],
1744            &AttestationPolicy::default(),
1745            &CredentialPolicy::default(),
1746        )
1747        .unwrap();
1748        assert!(registration.device_name.contains("EdDSA"));
1749        assert!(!registration.backup_eligible);
1750
1751        // -- authentication --
1752        let auth_challenge = generate_challenge_bytes();
1753        let assertion_client_data = serde_json::json!({
1754            "type": "webauthn.get",
1755            "challenge": base64_encode_urlsafe(&auth_challenge),
1756            "origin": "http://localhost:8080",
1757        });
1758        let assertion_client_data_bytes = serde_json::to_vec(&assertion_client_data).unwrap();
1759        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1760        let mut assertion_auth_data = rp_id_hash;
1761        assertion_auth_data.push(FLAG_UP | FLAG_BS);
1762        assertion_auth_data.extend_from_slice(&1u32.to_be_bytes());
1763
1764        use sha2::Digest as _;
1765        let mut signed_data = assertion_auth_data.clone();
1766        signed_data.extend_from_slice(&sha2::Sha256::digest(&assertion_client_data_bytes));
1767        let signature = key_pair.sign(&signed_data);
1768
1769        let cred_b64 = base64_encode_urlsafe(&credential_id);
1770        let result = verify_authentication(&AuthenticationParams {
1771            challenge_bytes: auth_challenge,
1772            client_data_json_b64: base64_encode_urlsafe(&assertion_client_data_bytes),
1773            authenticator_data_b64: base64_encode_urlsafe(&assertion_auth_data),
1774            signature_b64: base64_encode_urlsafe(signature.as_ref()),
1775            credential_id_b64: cred_b64.clone(),
1776            public_key_cose: cose_key,
1777            current_sign_count: 0,
1778            allowed_credential_ids: vec![cred_b64],
1779            rp_id: "localhost".to_string(),
1780            rp_origins: vec!["http://localhost:8080".to_string()],
1781            policy: CredentialPolicy::default(),
1782        })
1783        .unwrap();
1784        assert_eq!(result.new_sign_count, 1);
1785        assert!(result.backup_state);
1786        assert!(!result.backup_eligible);
1787    }
1788
1789    /// Algorithm-confusion via the ceremony entry point: attested credential
1790    /// data carrying a P-256 key that claims `alg = -35` (ES384) must be
1791    /// rejected (the RS256 bug class, replayed against ES384).
1792    #[test]
1793    fn registration_rejects_p256_key_claiming_es384() {
1794        let cose_key = build_cose_ec2_key_alg(1, -35, &[0xAA; 32], &[0xBB; 32]); // crv 1 = P-256
1795        let auth_data =
1796            build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1797        let att_obj = build_attestation_object(&auth_data);
1798        let challenge = generate_challenge_bytes();
1799        let client_data = serde_json::json!({
1800            "type": "webauthn.create",
1801            "challenge": base64_encode_urlsafe(&challenge),
1802            "origin": "http://localhost:8080",
1803        });
1804        let result = verify_registration(
1805            &challenge,
1806            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1807            &base64_encode_urlsafe(&att_obj),
1808            "",
1809            "localhost",
1810            &["http://localhost:8080".to_string()],
1811            &AttestationPolicy::default(),
1812            &CredentialPolicy::default(),
1813        );
1814        assert!(matches!(
1815            result,
1816            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1817        ));
1818    }
1819
1820    // ---- UV enforcement matrix (REQ-WA-142) ----
1821
1822    /// Build a minimal valid registration for the given flags; returns the
1823    /// ceremony inputs.
1824    fn registration_inputs(flags: u8) -> (Vec<u8>, String, String) {
1825        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
1826        let auth_data =
1827            build_auth_data_with_credential("localhost", flags, 0, &[0x01, 0x02], &cose_key);
1828        let challenge = generate_challenge_bytes();
1829        let client_data = serde_json::json!({
1830            "type": "webauthn.create",
1831            "challenge": base64_encode_urlsafe(&challenge),
1832            "origin": "http://localhost:8080",
1833        });
1834        (
1835            challenge,
1836            base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1837            base64_encode_urlsafe(&build_attestation_object(&auth_data)),
1838        )
1839    }
1840
1841    #[test]
1842    fn uv_required_rejects_clear_flag_and_accepts_set() {
1843        use crate::policy::UserVerificationPolicy;
1844        let uv_required = CredentialPolicy {
1845            user_verification: UserVerificationPolicy::Required,
1846            ..CredentialPolicy::default()
1847        };
1848
1849        // Required + UV clear → rejected.
1850        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1851        let result = verify_registration(
1852            &challenge,
1853            &cd,
1854            &ao,
1855            "",
1856            "localhost",
1857            &["http://localhost:8080".to_string()],
1858            &AttestationPolicy::default(),
1859            &uv_required,
1860        );
1861        assert!(matches!(
1862            result,
1863            Err(WebauthnError::UserVerificationRequired)
1864        ));
1865
1866        // Required + UV set → accepted.
1867        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_UV);
1868        let result = verify_registration(
1869            &challenge,
1870            &cd,
1871            &ao,
1872            "",
1873            "localhost",
1874            &["http://localhost:8080".to_string()],
1875            &AttestationPolicy::default(),
1876            &uv_required,
1877        );
1878        assert!(result.is_ok());
1879    }
1880
1881    /// Preferred/Discouraged never reject on UV; the flag is only reported.
1882    #[test]
1883    fn uv_preferred_and_discouraged_report_only() {
1884        use crate::policy::UserVerificationPolicy;
1885        // UP set, UV clear.
1886        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1887
1888        for uv in [
1889            UserVerificationPolicy::Preferred,
1890            UserVerificationPolicy::Discouraged,
1891        ] {
1892            let policy = CredentialPolicy {
1893                user_verification: uv,
1894                ..CredentialPolicy::default()
1895            };
1896            let result = verify_registration(
1897                &challenge,
1898                &cd,
1899                &ao,
1900                "",
1901                "localhost",
1902                &["http://localhost:8080".to_string()],
1903                &AttestationPolicy::default(),
1904                &policy,
1905            )
1906            .unwrap();
1907            assert!(!result.user_verified, "{uv:?} must report, not reject");
1908        }
1909    }
1910
1911    // ---- algorithm allowlist (REQ-WA-143) ----
1912
1913    /// A credential whose algorithm is outside the configured allowlist is
1914    /// rejected at registration, even though the kit could verify it.
1915    #[test]
1916    fn registration_rejects_algorithm_outside_allowlist() {
1917        // P-384/ES384 credential with an ES256-only allowlist.
1918        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1919        let rng = ring::rand::SystemRandom::new();
1920        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1921        let key_pair =
1922            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1923                .unwrap();
1924        let pub_bytes = key_pair.public_key().as_ref();
1925        let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1926
1927        let auth_data =
1928            build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1929        let att_obj = build_attestation_object(&auth_data);
1930        let challenge = generate_challenge_bytes();
1931        let client_data = serde_json::json!({
1932            "type": "webauthn.create",
1933            "challenge": base64_encode_urlsafe(&challenge),
1934            "origin": "http://localhost:8080",
1935        });
1936        let common = (
1937            challenge,
1938            base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1939            base64_encode_urlsafe(&att_obj),
1940        );
1941
1942        // Disallowed: ES384 not on the ES256-only list.
1943        let es256_only = CredentialPolicy {
1944            allowed_algorithms: vec![COSE_ALG_ES256],
1945            ..CredentialPolicy::default()
1946        };
1947        let result = verify_registration(
1948            &common.0,
1949            &common.1,
1950            &common.2,
1951            "",
1952            "localhost",
1953            &["http://localhost:8080".to_string()],
1954            &AttestationPolicy::default(),
1955            &es256_only,
1956        );
1957        assert!(matches!(
1958            result,
1959            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1960        ));
1961
1962        // Allowed once ES384 is listed.
1963        let with_es384 = CredentialPolicy {
1964            allowed_algorithms: vec![COSE_ALG_ES256, COSE_ALG_ES384],
1965            ..CredentialPolicy::default()
1966        };
1967        let result = verify_registration(
1968            &common.0,
1969            &common.1,
1970            &common.2,
1971            "",
1972            "localhost",
1973            &["http://localhost:8080".to_string()],
1974            &AttestationPolicy::default(),
1975            &with_es384,
1976        );
1977        assert!(result.is_ok());
1978
1979        // Empty allowlist = accept everything supported.
1980        let result = verify_registration(
1981            &common.0,
1982            &common.1,
1983            &common.2,
1984            "",
1985            "localhost",
1986            &["http://localhost:8080".to_string()],
1987            &AttestationPolicy::default(),
1988            &CredentialPolicy::default(),
1989        );
1990        assert!(result.is_ok());
1991    }
1992
1993    // ---- backup eligibility / backup state policy (REQ-WA-144) ----
1994
1995    /// `RequireDeviceBound` rejects synced (BE=1) credentials at
1996    /// registration; device-bound (BE=0) registrations pass.
1997    #[test]
1998    fn backup_policy_device_bound_enforced_at_registration() {
1999        use crate::policy::BackupPolicy;
2000        let device_bound = CredentialPolicy {
2001            backup: BackupPolicy::RequireDeviceBound,
2002            ..CredentialPolicy::default()
2003        };
2004
2005        // BE=1 (synced) → rejected.
2006        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE);
2007        let result = verify_registration(
2008            &challenge,
2009            &cd,
2010            &ao,
2011            "",
2012            "localhost",
2013            &["http://localhost:8080".to_string()],
2014            &AttestationPolicy::default(),
2015            &device_bound,
2016        );
2017        assert!(matches!(result, Err(WebauthnError::PolicyViolation(_))));
2018
2019        // BE=0 (device-bound) → accepted; BS reported as-is.
2020        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
2021        let result = verify_registration(
2022            &challenge,
2023            &cd,
2024            &ao,
2025            "",
2026            "localhost",
2027            &["http://localhost:8080".to_string()],
2028            &AttestationPolicy::default(),
2029            &device_bound,
2030        );
2031        let registration = result.unwrap();
2032        assert!(!registration.backup_eligible);
2033        assert!(!registration.backup_state);
2034    }
2035
2036    /// `Allow` (default) accepts synced credentials and reports both flags.
2037    #[test]
2038    fn backup_policy_allow_reports_flags() {
2039        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE | FLAG_BS);
2040        let registration = verify_registration(
2041            &challenge,
2042            &cd,
2043            &ao,
2044            "",
2045            "localhost",
2046            &["http://localhost:8080".to_string()],
2047            &AttestationPolicy::default(),
2048            &CredentialPolicy::default(),
2049        )
2050        .unwrap();
2051        assert!(registration.backup_eligible);
2052        assert!(registration.backup_state);
2053    }
2054
2055    /// `RequireDeviceBound` also rejects BE=1 assertions at authentication.
2056    #[test]
2057    fn backup_policy_device_bound_enforced_at_authentication() {
2058        use crate::policy::BackupPolicy;
2059        use ring::signature::{EcdsaKeyPair, KeyPair as _};
2060
2061        let rng = ring::rand::SystemRandom::new();
2062        let pkcs8 =
2063            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2064                .unwrap();
2065        let key_pair = EcdsaKeyPair::from_pkcs8(
2066            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2067            pkcs8.as_ref(),
2068            &rng,
2069        )
2070        .unwrap();
2071        let pub_bytes = key_pair.public_key().as_ref();
2072        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2073
2074        let challenge = generate_challenge_bytes();
2075        let client_data = serde_json::json!({
2076            "type": "webauthn.get",
2077            "challenge": base64_encode_urlsafe(&challenge),
2078            "origin": "http://localhost:8080",
2079        });
2080        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2081        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2082        let mut auth_data = rp_id_hash;
2083        auth_data.push(FLAG_UP | FLAG_BE); // synced credential assertion
2084        auth_data.extend_from_slice(&1u32.to_be_bytes());
2085
2086        use sha2::Digest as _;
2087        let mut signed_data = auth_data.clone();
2088        signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2089        let signature = key_pair.sign(&rng, &signed_data).unwrap();
2090
2091        let cred_b64 = base64_encode_urlsafe(&[0x01]);
2092        let params = AuthenticationParams {
2093            challenge_bytes: challenge,
2094            client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2095            authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2096            signature_b64: base64_encode_urlsafe(signature.as_ref()),
2097            credential_id_b64: cred_b64.clone(),
2098            public_key_cose: cose_key,
2099            current_sign_count: 0,
2100            allowed_credential_ids: vec![cred_b64],
2101            rp_id: "localhost".to_string(),
2102            rp_origins: vec!["http://localhost:8080".to_string()],
2103            policy: CredentialPolicy {
2104                backup: BackupPolicy::RequireDeviceBound,
2105                ..CredentialPolicy::default()
2106            },
2107        };
2108        assert!(matches!(
2109            verify_authentication(&params),
2110            Err(WebauthnError::PolicyViolation(_))
2111        ));
2112    }
2113
2114    /// UV Required is enforced at authentication: a validly signed
2115    /// assertion with a clear UV flag fails with
2116    /// `UserVerificationRequired`.
2117    #[test]
2118    fn uv_required_enforced_at_authentication() {
2119        use crate::policy::UserVerificationPolicy;
2120        use ring::signature::{EcdsaKeyPair, KeyPair as _};
2121
2122        let rng = ring::rand::SystemRandom::new();
2123        let pkcs8 =
2124            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2125                .unwrap();
2126        let key_pair = EcdsaKeyPair::from_pkcs8(
2127            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2128            pkcs8.as_ref(),
2129            &rng,
2130        )
2131        .unwrap();
2132        let pub_bytes = key_pair.public_key().as_ref();
2133        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2134
2135        let challenge = generate_challenge_bytes();
2136        let client_data = serde_json::json!({
2137            "type": "webauthn.get",
2138            "challenge": base64_encode_urlsafe(&challenge),
2139            "origin": "http://localhost:8080",
2140        });
2141        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2142        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2143        let mut auth_data = rp_id_hash;
2144        auth_data.push(FLAG_UP); // UV clear
2145        auth_data.extend_from_slice(&1u32.to_be_bytes());
2146
2147        use sha2::Digest as _;
2148        let mut signed_data = auth_data.clone();
2149        signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2150        let signature = key_pair.sign(&rng, &signed_data).unwrap();
2151
2152        let cred_b64 = base64_encode_urlsafe(&[0x01]);
2153        let params = AuthenticationParams {
2154            challenge_bytes: challenge,
2155            client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2156            authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2157            signature_b64: base64_encode_urlsafe(signature.as_ref()),
2158            credential_id_b64: cred_b64.clone(),
2159            public_key_cose: cose_key,
2160            current_sign_count: 0,
2161            allowed_credential_ids: vec![cred_b64],
2162            rp_id: "localhost".to_string(),
2163            rp_origins: vec!["http://localhost:8080".to_string()],
2164            policy: CredentialPolicy {
2165                user_verification: UserVerificationPolicy::Required,
2166                ..CredentialPolicy::default()
2167            },
2168        };
2169        assert!(matches!(
2170            verify_authentication(&params),
2171            Err(WebauthnError::UserVerificationRequired)
2172        ));
2173
2174        // The identical assertion under the default policy is accepted.
2175        let mut default_params = params.clone();
2176        default_params.policy = CredentialPolicy::default();
2177        assert!(verify_authentication(&default_params).is_ok());
2178    }
2179
2180    /// A malformed attestation *statement* (packed without `alg`) surfaces
2181    /// as an `AttestationError` through the registration entry point.
2182    #[test]
2183    fn registration_propagates_attestation_statement_errors() {
2184        let auth_data = build_auth_data_with_credential(
2185            "localhost",
2186            FLAG_UP | FLAG_AT,
2187            0,
2188            &[0x01],
2189            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
2190        );
2191        let entries = vec![
2192            (
2193                ciborium::Value::Integer(1.into()),
2194                ciborium::Value::Text("packed".to_string()),
2195            ),
2196            (
2197                ciborium::Value::Integer(2.into()),
2198                ciborium::Value::Bytes(auth_data),
2199            ),
2200            (
2201                ciborium::Value::Integer(3.into()),
2202                ciborium::Value::Map(vec![]), // packed attStmt without alg/sig
2203            ),
2204        ];
2205        let mut buf = Vec::new();
2206        ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
2207
2208        let challenge = generate_challenge_bytes();
2209        let client_data = serde_json::json!({
2210            "type": "webauthn.create",
2211            "challenge": base64_encode_urlsafe(&challenge),
2212            "origin": "http://localhost:8080",
2213        });
2214        let result = verify_registration(
2215            &challenge,
2216            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
2217            &base64_encode_urlsafe(&buf),
2218            "",
2219            "localhost",
2220            &["http://localhost:8080".to_string()],
2221            &AttestationPolicy::default(),
2222            &CredentialPolicy::default(),
2223        );
2224        assert!(matches!(result, Err(WebauthnError::AttestationError(_))));
2225    }
2226}