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, parse_cose_key,
33    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    // RFC 8949 + CTAP2: the attestation object is a map with TEXT keys
341    // ("fmt", "attStmt", "authData") — not the integer-keyed COSE map.
342    let attestation_entries = match &attestation_val {
343        ciborium::Value::Map(entries) => entries.clone(),
344        other => {
345            let hex = |b: &[u8]| {
346                let mut out = String::new();
347                for x in b.iter().take(8) {
348                    use std::fmt::Write as _;
349                    let _ = write!(out, "{x:02x}");
350                }
351                out
352            };
353            return Err(WebauthnError::AttestationError(format!(
354                "attestation object is not a CBOR map (decoded {} bytes, first: {}, value kind: {:?})",
355                attestation_bytes.len(),
356                hex(attestation_bytes.get(..8).unwrap_or(&attestation_bytes)),
357                match other {
358                    ciborium::Value::Bytes(_) => "bytes",
359                    ciborium::Value::Text(_) => "text",
360                    ciborium::Value::Array(_) => "array",
361                    ciborium::Value::Integer(_) => "integer",
362                    ciborium::Value::Bool(_) => "bool",
363                    _ => "other",
364                },
365            )));
366        }
367    };
368
369    let mut fmt: Option<String> = None;
370    let mut auth_data_bytes: Option<Vec<u8>> = None;
371    let mut att_stmt: Option<ciborium::Value> = None;
372
373    for (key, val) in &attestation_entries {
374        if let ciborium::Value::Text(k) = key {
375            match k.as_str() {
376                "fmt" => {
377                    if let ciborium::Value::Text(s) = val {
378                        fmt = Some(s.clone());
379                    }
380                }
381                "authData" => {
382                    if let Some(b) = cbor_bytes(val) {
383                        auth_data_bytes = Some(b);
384                    }
385                }
386                "attStmt" => {
387                    att_stmt = Some(val.clone());
388                }
389                _ => {}
390            }
391        }
392    }
393
394    let fmt = fmt.unwrap_or_else(|| "none".to_string());
395    let auth_data_bytes = auth_data_bytes.ok_or_else(|| {
396        WebauthnError::AttestationError("missing authData in attestation object".to_string())
397    })?;
398
399    let auth_data = parse_authenticator_data(&auth_data_bytes)?;
400
401    use sha2::Digest;
402    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
403    if auth_data.rp_id_hash != computed_rp_id_hash {
404        return Err(WebauthnError::VerificationFailed(format!(
405            "rpId hash mismatch: computed {:x?}, got {:x?}",
406            computed_rp_id_hash, auth_data.rp_id_hash
407        )));
408    }
409
410    if auth_data.flags & FLAG_UP == 0 {
411        return Err(WebauthnError::VerificationFailed(
412            "User Present flag not set".to_string(),
413        ));
414    }
415
416    if auth_data.flags & FLAG_AT == 0 {
417        return Err(WebauthnError::VerificationFailed(
418            "Attested Credential Data flag not set during registration".to_string(),
419        ));
420    }
421
422    // Credential policy: UV requirement and backup eligibility (REQ-WA-142/144).
423    enforce_flag_policy(auth_data.flags, credential_policy)?;
424
425    let credential_id = auth_data.credential_id.ok_or_else(|| {
426        WebauthnError::VerificationFailed("no credential ID in attested data".to_string())
427    })?;
428    let credential_id_b64 = base64_encode_urlsafe(&credential_id);
429
430    if existing_credential_id == credential_id_b64 {
431        return Err(WebauthnError::DuplicateCredential(credential_id_b64));
432    }
433
434    let public_key_cose = auth_data.credential_public_key_cose.ok_or_else(|| {
435        WebauthnError::VerificationFailed("no public key in attested data".to_string())
436    })?;
437
438    let (alg, cose_key) = parse_cose_key(&public_key_cose)?;
439
440    // Server-side algorithm allowlist (REQ-WA-143).
441    enforce_algorithm_allowlist(alg, credential_policy)?;
442
443    // Attestation verification (REQ-WA-119..): dispatch on `fmt` and verify
444    // the statement per the caller's policy. Fails closed on tampered
445    // signatures, unknown formats (unless opted out), and policy violations.
446    let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
447    let aaguid = auth_data.aaguid.unwrap_or([0u8; 16]);
448    let attestation = verify_attestation(
449        &fmt,
450        att_stmt.as_ref(),
451        &auth_data_bytes,
452        &client_data_hash,
453        &credential_id,
454        alg,
455        &cose_key,
456        aaguid,
457        attestation_policy,
458    )?;
459
460    let user_verified = auth_data.flags & FLAG_UV != 0;
461
462    Ok(RegistrationResult {
463        credential_id: credential_id_b64,
464        public_key_cose,
465        device_name: format!("WebAuthn ({})", alg_to_name(alg)),
466        attestation_format: fmt,
467        attestation,
468        user_verified,
469        backup_eligible: auth_data.backup_eligible,
470        backup_state: auth_data.backup_state,
471    })
472}
473
474/// Parameters for verifying an authentication response.
475///
476/// All base64 fields are client-supplied and hostile; `public_key_cose` and
477/// `current_sign_count` are the server-side stored credential state.
478#[derive(Debug, Clone)]
479pub struct AuthenticationParams {
480    /// The raw challenge bytes that were stored server-side.
481    pub challenge_bytes: Vec<u8>,
482    /// Base64url-encoded client data JSON.
483    pub client_data_json_b64: String,
484    /// Base64url-encoded authenticator data.
485    pub authenticator_data_b64: String,
486    /// Base64url-encoded signature.
487    pub signature_b64: String,
488    /// Base64url-encoded credential ID presented by the authenticator.
489    pub credential_id_b64: String,
490    /// COSE-encoded public key for this credential.
491    pub public_key_cose: Vec<u8>,
492    /// Current sign count stored server-side for this credential.
493    pub current_sign_count: u32,
494    /// Allowed credential IDs for this authentication session.
495    pub allowed_credential_ids: Vec<String>,
496    /// Expected relying party ID.
497    pub rp_id: String,
498    /// Allowed origins.
499    pub rp_origins: Vec<String>,
500    /// Credential policy enforced on this assertion
501    /// (default: [`CredentialPolicy::default`] — UV reported, not required).
502    ///
503    /// # Security note
504    ///
505    /// With [`UserVerificationPolicy::Required`], an assertion whose UV flag
506    /// is clear is rejected — this is the enforcement point for step-up or
507    /// high-assurance logins.
508    pub policy: CredentialPolicy,
509}
510
511/// Verify an authentication response with full CTAP2/COSE signature
512/// verification.
513///
514/// Verification steps, in order:
515///
516/// 1. Credential ID membership in `allowed_credential_ids`.
517/// 2. `clientDataJSON` validation (challenge echo, `type == "webauthn.get"`,
518///    origin allow-list, optional `rpId` cross-check).
519/// 3. Authenticator data parsing and `rpIdHash` comparison.
520/// 4. User Presence flag + credential-policy flag enforcement (UV, backup).
521/// 5. Sign-count freshness ([`check_sign_count`]).
522/// 6. `ring` signature verification over `authenticatorData || SHA-256(clientDataJSON)`.
523///
524/// # Security notes / threat assumptions
525///
526/// - `challenge_bytes` must be consumed from single-use storage before this
527///   call; this module does not (and cannot) detect a challenge used twice.
528/// - On success the caller MUST persist [`AuthenticationResult::new_sign_count`]
529///   (ideally compare-and-swap) before considering the user logged in.
530/// - UV is enforced per `params.policy.user_verification`
531///   ([`UserVerificationPolicy::Required`] rejects UV-clear assertions);
532///   backup eligibility per `params.policy.backup`. The BE/BS flags are
533///   always reported on the result.
534///
535/// # Requirements
536/// REQ-WA-002, REQ-WA-101, REQ-WA-102, REQ-WA-103, REQ-WA-104, REQ-WA-105,
537/// REQ-WA-106, REQ-WA-111, REQ-WA-114, REQ-WA-116, REQ-WA-142, REQ-WA-144
538///
539/// Returns the verified authentication data.
540pub fn verify_authentication(
541    params: &AuthenticationParams,
542) -> Result<AuthenticationResult, WebauthnError> {
543    let AuthenticationParams {
544        challenge_bytes,
545        client_data_json_b64,
546        authenticator_data_b64,
547        signature_b64,
548        credential_id_b64,
549        public_key_cose,
550        current_sign_count,
551        allowed_credential_ids,
552        rp_id,
553        rp_origins,
554        policy,
555    } = params;
556
557    if !allowed_credential_ids.contains(credential_id_b64) {
558        return Err(WebauthnError::VerificationFailed(
559            "credential ID not in allowed list".to_string(),
560        ));
561    }
562
563    let client_data_bytes = validate_client_data(
564        client_data_json_b64,
565        challenge_bytes,
566        "webauthn.get",
567        rp_id,
568        rp_origins,
569    )?;
570
571    let authenticator_data = base64_decode_urlsafe(authenticator_data_b64)?;
572    let auth_data = parse_authenticator_data(&authenticator_data)?;
573
574    use sha2::Digest;
575    let computed_rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
576    if auth_data.rp_id_hash != computed_rp_id_hash {
577        return Err(WebauthnError::VerificationFailed(format!(
578            "rpId hash mismatch: computed {:x?}, got {:x?}",
579            computed_rp_id_hash, auth_data.rp_id_hash
580        )));
581    }
582
583    if auth_data.flags & FLAG_UP == 0 {
584        return Err(WebauthnError::VerificationFailed(
585            "User Present flag not set".to_string(),
586        ));
587    }
588
589    // Credential policy: UV requirement and backup eligibility (REQ-WA-142/144).
590    enforce_flag_policy(auth_data.flags, policy)?;
591
592    check_sign_count(*current_sign_count, auth_data.sign_count)?;
593
594    let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
595
596    let mut signed_data = Vec::with_capacity(authenticator_data.len() + 32);
597    signed_data.extend_from_slice(&authenticator_data);
598    signed_data.extend_from_slice(&client_data_hash);
599
600    let signature = base64_decode_urlsafe(signature_b64)?;
601
602    let (alg, cose_key) = parse_cose_key(public_key_cose)?;
603    verify_cose_signature(alg, &cose_key, &signed_data, &signature)?;
604
605    let user_verified = auth_data.flags & FLAG_UV != 0;
606    let new_sign_count = auth_data.sign_count;
607
608    Ok(AuthenticationResult {
609        credential_id: credential_id_b64.clone(),
610        new_sign_count,
611        user_verified,
612        backup_state: auth_data.backup_state,
613        backup_eligible: auth_data.backup_eligible,
614    })
615}
616
617// Tests exercise failure paths and invariants directly; unwrap/expect,
618// slicing, and panicking asserts are acceptable here — violations
619// surface as test failures, not production panics.
620#[allow(
621    clippy::unwrap_used,
622    clippy::expect_used,
623    clippy::indexing_slicing,
624    clippy::panic
625)]
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use crate::crypto::tests::{build_cose_ec2_key, build_cose_rsa_key};
630    use crate::crypto::{base64_encode_urlsafe, generate_challenge_bytes, COSE_ALG_RS256};
631    use ring::signature::KeyPair as _;
632
633    fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
634        use ciborium::Value;
635        // Real CTAP2 attestation objects use TEXT keys.
636        let map = vec![
637            (
638                Value::Text("fmt".to_string()),
639                Value::Text("none".to_string()),
640            ),
641            (Value::Text("attStmt".to_string()), Value::Map(vec![])),
642            (
643                Value::Text("authData".to_string()),
644                Value::Bytes(auth_data.to_vec()),
645            ),
646        ];
647        let mut buf = Vec::new();
648        ciborium::ser::into_writer(&Value::Map(map), &mut buf).unwrap();
649        buf
650    }
651
652    fn build_auth_data_with_credential(
653        rp_id: &str,
654        flags: u8,
655        sign_count: u32,
656        credential_id: &[u8],
657        cose_key: &[u8],
658    ) -> Vec<u8> {
659        use sha2::Digest;
660        let rp_id_hash = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
661        let mut auth_data = Vec::new();
662        auth_data.extend_from_slice(&rp_id_hash);
663        auth_data.push(flags);
664        auth_data.extend_from_slice(&sign_count.to_be_bytes());
665
666        if flags & FLAG_AT != 0 {
667            auth_data.extend_from_slice(&[0u8; 16]);
668            auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
669            auth_data.extend_from_slice(credential_id);
670            auth_data.extend_from_slice(cose_key);
671        }
672
673        auth_data
674    }
675
676    #[test]
677    fn test_parse_authenticator_data_minimal() {
678        let mut auth_data = vec![0u8; 37];
679        auth_data[..32].copy_from_slice(&[0xAA; 32]);
680        auth_data[32] = FLAG_UP;
681        auth_data[33..37].copy_from_slice(&1u32.to_be_bytes());
682
683        let parsed = parse_authenticator_data(&auth_data).unwrap();
684        assert_eq!(parsed.rp_id_hash, vec![0xAA; 32]);
685        assert_eq!(parsed.flags, FLAG_UP);
686        assert_eq!(parsed.sign_count, 1);
687        assert!(parsed.credential_public_key_cose.is_none());
688        assert!(parsed.credential_id.is_none());
689    }
690
691    #[test]
692    fn test_parse_authenticator_data_with_attested_credential() {
693        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
694        let public_key_cose = vec![0x10, 0x20, 0x30];
695
696        let total_len = 37 + 16 + 2 + credential_id.len() + public_key_cose.len();
697        let mut auth_data = vec![0u8; total_len];
698        auth_data[32] = FLAG_UP | FLAG_AT;
699        auth_data[33..37].copy_from_slice(&5u32.to_be_bytes());
700
701        let offset = 37 + 16;
702        auth_data[offset..offset + 2].copy_from_slice(&(credential_id.len() as u16).to_be_bytes());
703        auth_data[offset + 2..offset + 2 + credential_id.len()].copy_from_slice(&credential_id);
704        let pk_offset = offset + 2 + credential_id.len();
705        auth_data[pk_offset..].copy_from_slice(&public_key_cose);
706
707        let parsed = parse_authenticator_data(&auth_data).unwrap();
708        assert_eq!(parsed.flags, FLAG_UP | FLAG_AT);
709        assert_eq!(parsed.sign_count, 5);
710        assert_eq!(parsed.credential_id, Some(credential_id));
711        assert_eq!(parsed.credential_public_key_cose, Some(public_key_cose));
712    }
713
714    #[test]
715    fn test_parse_authenticator_data_too_short() {
716        let auth_data = vec![0u8; 10];
717        let result = parse_authenticator_data(&auth_data);
718        assert!(result.is_err());
719    }
720
721    #[test]
722    fn test_verify_registration_valid_es256() {
723        use ring::signature::EcdsaKeyPair;
724
725        let rng = ring::rand::SystemRandom::new();
726        let pkcs8 =
727            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
728                .unwrap();
729        let key_pair = EcdsaKeyPair::from_pkcs8(
730            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
731            pkcs8.as_ref(),
732            &rng,
733        )
734        .unwrap();
735
736        let pub_bytes = key_pair.public_key().as_ref().to_vec();
737        let x = pub_bytes[1..33].to_vec();
738        let y = pub_bytes[33..65].to_vec();
739        let cose_key = build_cose_ec2_key(&x, &y);
740
741        let credential_id = vec![0x01, 0x02, 0x03, 0x04];
742        let auth_data = build_auth_data_with_credential(
743            "localhost",
744            FLAG_UP | FLAG_AT,
745            0,
746            &credential_id,
747            &cose_key,
748        );
749        let att_obj = build_attestation_object(&auth_data);
750
751        let challenge = generate_challenge_bytes();
752        let challenge_b64 = base64_encode_urlsafe(&challenge);
753
754        let client_data = serde_json::json!({
755            "type": "webauthn.create",
756            "challenge": challenge_b64,
757            "origin": "http://localhost:8080",
758        });
759        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
760        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
761
762        let result = verify_registration(
763            &challenge,
764            &client_data_b64,
765            &att_obj_b64,
766            "different-id",
767            "localhost",
768            &["http://localhost:8080".to_string()],
769            &AttestationPolicy::default(),
770            &CredentialPolicy::default(),
771        )
772        .unwrap();
773
774        assert_eq!(result.credential_id, base64_encode_urlsafe(&credential_id));
775        assert!(result.attestation_format == "none");
776    }
777
778    #[test]
779    fn test_verify_registration_challenge_mismatch() {
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.create",
794            "challenge": base64_encode_urlsafe(&[1u8; 32]),
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_type() {
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.get",
829            "challenge": challenge_b64,
830            "origin": "http://localhost:8080",
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_wrong_origin() {
850        let auth_data = build_auth_data_with_credential(
851            "localhost",
852            FLAG_UP | FLAG_AT,
853            0,
854            &[0x01],
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://evil.com",
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
870        let result = verify_registration(
871            &challenge,
872            &client_data_b64,
873            &att_obj_b64,
874            "different",
875            "localhost",
876            &["http://localhost:8080".to_string()],
877            &AttestationPolicy::default(),
878            &CredentialPolicy::default(),
879        );
880        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
881    }
882
883    #[test]
884    fn test_verify_registration_duplicate() {
885        let auth_data = build_auth_data_with_credential(
886            "localhost",
887            FLAG_UP | FLAG_AT,
888            0,
889            &[0x01, 0x02, 0x03],
890            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
891        );
892        let att_obj = build_attestation_object(&auth_data);
893
894        let challenge = generate_challenge_bytes();
895        let challenge_b64 = base64_encode_urlsafe(&challenge);
896
897        let client_data = serde_json::json!({
898            "type": "webauthn.create",
899            "challenge": challenge_b64,
900            "origin": "http://localhost:8080",
901        });
902        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
903        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
904        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02, 0x03]);
905
906        let result = verify_registration(
907            &challenge,
908            &client_data_b64,
909            &att_obj_b64,
910            &cred_id_b64,
911            "localhost",
912            &["http://localhost:8080".to_string()],
913            &AttestationPolicy::default(),
914            &CredentialPolicy::default(),
915        );
916        assert!(matches!(result, Err(WebauthnError::DuplicateCredential(_))));
917    }
918
919    #[test]
920    fn test_verify_registration_wrong_rp_id_hash() {
921        let credential_id = vec![0x01];
922        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
923        let auth_data = build_auth_data_with_credential(
924            "evil.com",
925            FLAG_UP | FLAG_AT,
926            0,
927            &credential_id,
928            &cose_key,
929        );
930        let att_obj = build_attestation_object(&auth_data);
931
932        let challenge = generate_challenge_bytes();
933        let challenge_b64 = base64_encode_urlsafe(&challenge);
934
935        let client_data = serde_json::json!({
936            "type": "webauthn.create",
937            "challenge": challenge_b64,
938            "origin": "http://localhost:8080",
939        });
940        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
941        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
942
943        let result = verify_registration(
944            &challenge,
945            &client_data_b64,
946            &att_obj_b64,
947            "different",
948            "localhost",
949            &["http://localhost:8080".to_string()],
950            &AttestationPolicy::default(),
951            &CredentialPolicy::default(),
952        );
953        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
954    }
955
956    #[test]
957    fn test_verify_registration_missing_up_flag() {
958        let credential_id = vec![0x01];
959        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
960        let auth_data =
961            build_auth_data_with_credential("localhost", FLAG_AT, 0, &credential_id, &cose_key);
962        let att_obj = build_attestation_object(&auth_data);
963
964        let challenge = generate_challenge_bytes();
965        let challenge_b64 = base64_encode_urlsafe(&challenge);
966
967        let client_data = serde_json::json!({
968            "type": "webauthn.create",
969            "challenge": challenge_b64,
970            "origin": "http://localhost:8080",
971        });
972        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
973        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
974
975        let result = verify_registration(
976            &challenge,
977            &client_data_b64,
978            &att_obj_b64,
979            "different",
980            "localhost",
981            &["http://localhost:8080".to_string()],
982            &AttestationPolicy::default(),
983            &CredentialPolicy::default(),
984        );
985        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
986    }
987
988    /// REQ-WA-117: registration with UP set but the AT flag clear (no
989    /// attested credential data) must be rejected — a credential cannot be
990    /// registered without attested key material.
991    #[test]
992    fn registration_rejects_missing_at_flag() {
993        let auth_data = build_auth_data_with_credential(
994            "localhost",
995            FLAG_UP, // UP set, AT clear
996            0,
997            &[0x01],
998            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
999        );
1000        let att_obj = build_attestation_object(&auth_data);
1001
1002        let challenge = generate_challenge_bytes();
1003        let challenge_b64 = base64_encode_urlsafe(&challenge);
1004
1005        let client_data = serde_json::json!({
1006            "type": "webauthn.create",
1007            "challenge": challenge_b64,
1008            "origin": "http://localhost:8080",
1009        });
1010        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1011        let att_obj_b64 = base64_encode_urlsafe(&att_obj);
1012
1013        let result = verify_registration(
1014            &challenge,
1015            &client_data_b64,
1016            &att_obj_b64,
1017            "different",
1018            "localhost",
1019            &["http://localhost:8080".to_string()],
1020            &AttestationPolicy::default(),
1021            &CredentialPolicy::default(),
1022        );
1023        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1024    }
1025
1026    #[test]
1027    fn test_verify_authentication_valid_es256() {
1028        use ring::signature::EcdsaKeyPair;
1029
1030        let rng = ring::rand::SystemRandom::new();
1031        let pkcs8 =
1032            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1033                .unwrap();
1034        let key_pair = EcdsaKeyPair::from_pkcs8(
1035            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1036            pkcs8.as_ref(),
1037            &rng,
1038        )
1039        .unwrap();
1040
1041        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1042        let x = pub_bytes[1..33].to_vec();
1043        let y = pub_bytes[33..65].to_vec();
1044        let cose_key = build_cose_ec2_key(&x, &y);
1045
1046        let challenge = generate_challenge_bytes();
1047        let challenge_b64 = base64_encode_urlsafe(&challenge);
1048
1049        use sha2::Digest;
1050        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1051        let mut auth_data = Vec::new();
1052        auth_data.extend_from_slice(&rp_id_hash);
1053        auth_data.push(FLAG_UP);
1054        auth_data.extend_from_slice(&1u32.to_be_bytes());
1055
1056        let client_data = serde_json::json!({
1057            "type": "webauthn.get",
1058            "challenge": challenge_b64,
1059            "origin": "http://localhost:8080",
1060        });
1061        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1062        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1063        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1064
1065        let mut signed_data = Vec::new();
1066        signed_data.extend_from_slice(&auth_data);
1067        signed_data.extend_from_slice(&client_data_hash);
1068
1069        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1070
1071        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1072        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1073        let cred_id_b64 = base64_encode_urlsafe(&[0x01, 0x02]);
1074
1075        let result = verify_authentication(&AuthenticationParams {
1076            challenge_bytes: challenge.clone(),
1077            client_data_json_b64: client_data_b64,
1078            authenticator_data_b64: auth_data_b64,
1079            signature_b64: sig_b64,
1080            credential_id_b64: cred_id_b64.clone(),
1081            public_key_cose: cose_key,
1082            current_sign_count: 0,
1083            allowed_credential_ids: vec![cred_id_b64.clone()],
1084            rp_id: "localhost".to_string(),
1085            rp_origins: vec!["http://localhost:8080".to_string()],
1086            policy: CredentialPolicy::default(),
1087        })
1088        .unwrap();
1089
1090        assert_eq!(result.credential_id, cred_id_b64);
1091        assert_eq!(result.new_sign_count, 1);
1092    }
1093
1094    #[test]
1095    fn test_verify_authentication_wrong_signature() {
1096        use ring::signature::EcdsaKeyPair;
1097
1098        let rng = ring::rand::SystemRandom::new();
1099        let pkcs8 =
1100            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1101                .unwrap();
1102        let key_pair = EcdsaKeyPair::from_pkcs8(
1103            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1104            pkcs8.as_ref(),
1105            &rng,
1106        )
1107        .unwrap();
1108
1109        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1110        let x = pub_bytes[1..33].to_vec();
1111        let y = pub_bytes[33..65].to_vec();
1112        let cose_key = build_cose_ec2_key(&x, &y);
1113
1114        let challenge = generate_challenge_bytes();
1115        let challenge_b64 = base64_encode_urlsafe(&challenge);
1116
1117        use sha2::Digest;
1118        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1119        let mut auth_data = Vec::new();
1120        auth_data.extend_from_slice(&rp_id_hash);
1121        auth_data.push(FLAG_UP);
1122        auth_data.extend_from_slice(&1u32.to_be_bytes());
1123
1124        let client_data = serde_json::json!({
1125            "type": "webauthn.get",
1126            "challenge": challenge_b64,
1127            "origin": "http://localhost:8080",
1128        });
1129        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1130        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1131        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1132
1133        let mut signed_data = Vec::new();
1134        signed_data.extend_from_slice(&auth_data);
1135        signed_data.extend_from_slice(&client_data_hash);
1136
1137        let mut wrong_sig = vec![0u8; 64];
1138        wrong_sig[0] = 0xFF;
1139
1140        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1141        let sig_b64 = base64_encode_urlsafe(&wrong_sig);
1142        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1143
1144        let result = verify_authentication(&AuthenticationParams {
1145            challenge_bytes: challenge.clone(),
1146            client_data_json_b64: client_data_b64,
1147            authenticator_data_b64: auth_data_b64,
1148            signature_b64: sig_b64,
1149            credential_id_b64: cred_id_b64.clone(),
1150            public_key_cose: cose_key,
1151            current_sign_count: 0,
1152            allowed_credential_ids: vec![cred_id_b64],
1153            rp_id: "localhost".to_string(),
1154            rp_origins: vec!["http://localhost:8080".to_string()],
1155            policy: CredentialPolicy::default(),
1156        });
1157        assert!(matches!(
1158            result,
1159            Err(WebauthnError::SignatureVerificationFailed)
1160        ));
1161    }
1162
1163    #[test]
1164    fn test_verify_authentication_credential_not_allowed() {
1165        let challenge = generate_challenge_bytes();
1166        let challenge_b64 = base64_encode_urlsafe(&challenge);
1167
1168        let client_data = serde_json::json!({
1169            "type": "webauthn.get",
1170            "challenge": challenge_b64,
1171            "origin": "http://localhost:8080",
1172        });
1173        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1174
1175        use sha2::Digest;
1176        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1177        let mut auth_data = Vec::new();
1178        auth_data.extend_from_slice(&rp_id_hash);
1179        auth_data.push(FLAG_UP);
1180        auth_data.extend_from_slice(&0u32.to_be_bytes());
1181        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1182
1183        let result = verify_authentication(&AuthenticationParams {
1184            challenge_bytes: challenge.clone(),
1185            client_data_json_b64: client_data_b64,
1186            authenticator_data_b64: auth_data_b64,
1187            signature_b64: "sig".to_string(),
1188            credential_id_b64: "unauthorized-cred".to_string(),
1189            public_key_cose: vec![0x10, 0x20],
1190            current_sign_count: 0,
1191            allowed_credential_ids: vec!["allowed-cred".to_string()],
1192            rp_id: "localhost".to_string(),
1193            rp_origins: vec!["http://localhost:8080".to_string()],
1194            policy: CredentialPolicy::default(),
1195        });
1196        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1197    }
1198
1199    #[test]
1200    fn test_verify_authentication_wrong_origin() {
1201        let challenge = generate_challenge_bytes();
1202        let challenge_b64 = base64_encode_urlsafe(&challenge);
1203
1204        let client_data = serde_json::json!({
1205            "type": "webauthn.get",
1206            "challenge": challenge_b64,
1207            "origin": "http://evil.com",
1208        });
1209        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1210
1211        use sha2::Digest;
1212        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1213        let mut auth_data = Vec::new();
1214        auth_data.extend_from_slice(&rp_id_hash);
1215        auth_data.push(FLAG_UP);
1216        auth_data.extend_from_slice(&0u32.to_be_bytes());
1217        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1218
1219        let result = verify_authentication(&AuthenticationParams {
1220            challenge_bytes: challenge.clone(),
1221            client_data_json_b64: client_data_b64,
1222            authenticator_data_b64: auth_data_b64,
1223            signature_b64: "sig".to_string(),
1224            credential_id_b64: "cred".to_string(),
1225            public_key_cose: vec![0x10],
1226            current_sign_count: 0,
1227            allowed_credential_ids: vec!["cred".to_string()],
1228            rp_id: "localhost".to_string(),
1229            rp_origins: vec!["http://localhost:8080".to_string()],
1230            policy: CredentialPolicy::default(),
1231        });
1232        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1233    }
1234
1235    #[test]
1236    fn test_verify_authentication_wrong_type() {
1237        let challenge = generate_challenge_bytes();
1238        let challenge_b64 = base64_encode_urlsafe(&challenge);
1239
1240        let client_data = serde_json::json!({
1241            "type": "webauthn.create",
1242            "challenge": challenge_b64,
1243            "origin": "http://localhost:8080",
1244        });
1245        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1246
1247        use sha2::Digest;
1248        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1249        let mut auth_data = Vec::new();
1250        auth_data.extend_from_slice(&rp_id_hash);
1251        auth_data.push(FLAG_UP);
1252        auth_data.extend_from_slice(&0u32.to_be_bytes());
1253        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1254
1255        let result = verify_authentication(&AuthenticationParams {
1256            challenge_bytes: challenge.clone(),
1257            client_data_json_b64: client_data_b64,
1258            authenticator_data_b64: auth_data_b64,
1259            signature_b64: "sig".to_string(),
1260            credential_id_b64: "cred".to_string(),
1261            public_key_cose: vec![0x10],
1262            current_sign_count: 0,
1263            allowed_credential_ids: vec!["cred".to_string()],
1264            rp_id: "localhost".to_string(),
1265            rp_origins: vec!["http://localhost:8080".to_string()],
1266            policy: CredentialPolicy::default(),
1267        });
1268        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1269    }
1270
1271    #[test]
1272    fn test_verify_authentication_sign_count_decrease() {
1273        use ring::signature::EcdsaKeyPair;
1274
1275        let rng = ring::rand::SystemRandom::new();
1276        let pkcs8 =
1277            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1278                .unwrap();
1279        let key_pair = EcdsaKeyPair::from_pkcs8(
1280            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1281            pkcs8.as_ref(),
1282            &rng,
1283        )
1284        .unwrap();
1285
1286        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1287        let x = pub_bytes[1..33].to_vec();
1288        let y = pub_bytes[33..65].to_vec();
1289        let cose_key = build_cose_ec2_key(&x, &y);
1290
1291        let challenge = generate_challenge_bytes();
1292        let challenge_b64 = base64_encode_urlsafe(&challenge);
1293
1294        use sha2::Digest;
1295        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1296        let mut auth_data = Vec::new();
1297        auth_data.extend_from_slice(&rp_id_hash);
1298        auth_data.push(FLAG_UP);
1299        auth_data.extend_from_slice(&5u32.to_be_bytes());
1300
1301        let client_data = serde_json::json!({
1302            "type": "webauthn.get",
1303            "challenge": challenge_b64,
1304            "origin": "http://localhost:8080",
1305        });
1306        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1307        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1308        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1309
1310        let mut signed_data = Vec::new();
1311        signed_data.extend_from_slice(&auth_data);
1312        signed_data.extend_from_slice(&client_data_hash);
1313
1314        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1315
1316        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1317        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1318        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1319
1320        let result = verify_authentication(&AuthenticationParams {
1321            challenge_bytes: challenge.clone(),
1322            client_data_json_b64: client_data_b64,
1323            authenticator_data_b64: auth_data_b64,
1324            signature_b64: sig_b64,
1325            credential_id_b64: cred_id_b64.clone(),
1326            public_key_cose: cose_key,
1327            current_sign_count: 10,
1328            allowed_credential_ids: vec![cred_id_b64],
1329            rp_id: "localhost".to_string(),
1330            rp_origins: vec!["http://localhost:8080".to_string()],
1331            policy: CredentialPolicy::default(),
1332        });
1333        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1334    }
1335
1336    #[test]
1337    fn test_verify_authentication_wrong_rp_id_hash() {
1338        let challenge = generate_challenge_bytes();
1339        let challenge_b64 = base64_encode_urlsafe(&challenge);
1340
1341        let client_data = serde_json::json!({
1342            "type": "webauthn.get",
1343            "challenge": challenge_b64,
1344            "origin": "http://localhost:8080",
1345        });
1346        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1347
1348        use sha2::Digest;
1349        let rp_id_hash = sha2::Sha256::digest(b"evil.com").to_vec();
1350        let mut auth_data = Vec::new();
1351        auth_data.extend_from_slice(&rp_id_hash);
1352        auth_data.push(FLAG_UP);
1353        auth_data.extend_from_slice(&0u32.to_be_bytes());
1354        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1355
1356        let result = verify_authentication(&AuthenticationParams {
1357            challenge_bytes: challenge.clone(),
1358            client_data_json_b64: client_data_b64,
1359            authenticator_data_b64: auth_data_b64,
1360            signature_b64: "sig".to_string(),
1361            credential_id_b64: "cred".to_string(),
1362            public_key_cose: vec![0x10],
1363            current_sign_count: 0,
1364            allowed_credential_ids: vec!["cred".to_string()],
1365            rp_id: "localhost".to_string(),
1366            rp_origins: vec!["http://localhost:8080".to_string()],
1367            policy: CredentialPolicy::default(),
1368        });
1369        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1370    }
1371
1372    #[test]
1373    fn test_verify_authentication_missing_up_flag() {
1374        use ring::signature::EcdsaKeyPair;
1375
1376        let rng = ring::rand::SystemRandom::new();
1377        let pkcs8 =
1378            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1379                .unwrap();
1380        let key_pair = EcdsaKeyPair::from_pkcs8(
1381            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1382            pkcs8.as_ref(),
1383            &rng,
1384        )
1385        .unwrap();
1386
1387        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1388        let x = pub_bytes[1..33].to_vec();
1389        let y = pub_bytes[33..65].to_vec();
1390        let cose_key = build_cose_ec2_key(&x, &y);
1391
1392        let challenge = generate_challenge_bytes();
1393        let challenge_b64 = base64_encode_urlsafe(&challenge);
1394
1395        use sha2::Digest;
1396        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1397        let mut auth_data = Vec::new();
1398        auth_data.extend_from_slice(&rp_id_hash);
1399        auth_data.push(0);
1400        auth_data.extend_from_slice(&1u32.to_be_bytes());
1401
1402        let client_data = serde_json::json!({
1403            "type": "webauthn.get",
1404            "challenge": challenge_b64,
1405            "origin": "http://localhost:8080",
1406        });
1407        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1408        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1409        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1410
1411        let mut signed_data = Vec::new();
1412        signed_data.extend_from_slice(&auth_data);
1413        signed_data.extend_from_slice(&client_data_hash);
1414
1415        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1416
1417        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1418        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1419        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1420
1421        let result = verify_authentication(&AuthenticationParams {
1422            challenge_bytes: challenge.clone(),
1423            client_data_json_b64: client_data_b64,
1424            authenticator_data_b64: auth_data_b64,
1425            signature_b64: sig_b64,
1426            credential_id_b64: cred_id_b64.clone(),
1427            public_key_cose: cose_key,
1428            current_sign_count: 0,
1429            allowed_credential_ids: vec![cred_id_b64],
1430            rp_id: "localhost".to_string(),
1431            rp_origins: vec!["http://localhost:8080".to_string()],
1432            policy: CredentialPolicy::default(),
1433        });
1434        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1435    }
1436
1437    #[test]
1438    fn test_verify_authentication_wrong_rp_id_in_client_data() {
1439        use ring::signature::EcdsaKeyPair;
1440
1441        let rng = ring::rand::SystemRandom::new();
1442        let pkcs8 =
1443            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1444                .unwrap();
1445        let key_pair = EcdsaKeyPair::from_pkcs8(
1446            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1447            pkcs8.as_ref(),
1448            &rng,
1449        )
1450        .unwrap();
1451
1452        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1453        let x = pub_bytes[1..33].to_vec();
1454        let y = pub_bytes[33..65].to_vec();
1455        let cose_key = build_cose_ec2_key(&x, &y);
1456
1457        let challenge = generate_challenge_bytes();
1458        let challenge_b64 = base64_encode_urlsafe(&challenge);
1459
1460        let client_data = serde_json::json!({
1461            "type": "webauthn.get",
1462            "challenge": challenge_b64,
1463            "origin": "http://localhost:8080",
1464            "rpId": "evil.com",
1465        });
1466        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
1467        let client_data_b64 = base64_encode_urlsafe(&client_data_bytes);
1468
1469        use sha2::Digest;
1470        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1471        let mut auth_data = Vec::new();
1472        auth_data.extend_from_slice(&rp_id_hash);
1473        auth_data.push(FLAG_UP);
1474        auth_data.extend_from_slice(&1u32.to_be_bytes());
1475
1476        let client_data_hash = sha2::Sha256::digest(&client_data_bytes).to_vec();
1477        let mut signed_data = Vec::new();
1478        signed_data.extend_from_slice(&auth_data);
1479        signed_data.extend_from_slice(&client_data_hash);
1480
1481        let signature = key_pair.sign(&rng, &signed_data).unwrap();
1482
1483        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1484        let sig_b64 = base64_encode_urlsafe(signature.as_ref());
1485        let cred_id_b64 = base64_encode_urlsafe(&[0x01]);
1486
1487        let result = verify_authentication(&AuthenticationParams {
1488            challenge_bytes: challenge.clone(),
1489            client_data_json_b64: client_data_b64,
1490            authenticator_data_b64: auth_data_b64,
1491            signature_b64: sig_b64,
1492            credential_id_b64: cred_id_b64.clone(),
1493            public_key_cose: cose_key,
1494            current_sign_count: 0,
1495            allowed_credential_ids: vec![cred_id_b64],
1496            rp_id: "localhost".to_string(),
1497            rp_origins: vec!["http://localhost:8080".to_string()],
1498            policy: CredentialPolicy::default(),
1499        });
1500        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1501    }
1502
1503    #[test]
1504    fn test_verify_authentication_challenge_mismatch() {
1505        let challenge = generate_challenge_bytes();
1506        let _challenge_b64 = base64_encode_urlsafe(&challenge);
1507
1508        let client_data = serde_json::json!({
1509            "type": "webauthn.get",
1510            "challenge": base64_encode_urlsafe(&[99u8; 32]),
1511            "origin": "http://localhost:8080",
1512        });
1513        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1514
1515        use sha2::Digest;
1516        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1517        let mut auth_data = Vec::new();
1518        auth_data.extend_from_slice(&rp_id_hash);
1519        auth_data.push(FLAG_UP);
1520        auth_data.extend_from_slice(&0u32.to_be_bytes());
1521        let auth_data_b64 = base64_encode_urlsafe(&auth_data);
1522
1523        let result = verify_authentication(&AuthenticationParams {
1524            challenge_bytes: challenge.clone(),
1525            client_data_json_b64: client_data_b64,
1526            authenticator_data_b64: auth_data_b64,
1527            signature_b64: "sig".to_string(),
1528            credential_id_b64: "cred".to_string(),
1529            public_key_cose: vec![0x10],
1530            current_sign_count: 0,
1531            allowed_credential_ids: vec!["cred".to_string()],
1532            rp_id: "localhost".to_string(),
1533            rp_origins: vec!["http://localhost:8080".to_string()],
1534            policy: CredentialPolicy::default(),
1535        });
1536        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1537    }
1538
1539    #[test]
1540    fn test_verify_authentication_rs256_roundtrip() {
1541        // Synthetic fixed-size RSA test key is impractical here; verify the
1542        // RS256 *dispatch* path instead: an ES256 key claiming RS256 is
1543        // rejected (key type/algorithm cross-check), proving the RS256 arm
1544        // requires RSA material.
1545        let cose_key = build_cose_rsa_key(&[0xAA; 256], &[0x01, 0x00, 0x01]);
1546        let (alg, key) = crate::crypto::parse_cose_key(&cose_key).unwrap();
1547        assert_eq!(alg, COSE_ALG_RS256);
1548        let result = verify_cose_signature(COSE_ALG_RS256, &key, b"data", &[0u8; 256]);
1549        assert!(matches!(
1550            result,
1551            Err(WebauthnError::SignatureVerificationFailed)
1552        ));
1553    }
1554
1555    /// Build a fully valid ES256 registration response. `rp_id_field`
1556    /// injects a matching `rpId` into clientDataJSON; `extra_att_key`
1557    /// appends an unknown key to the attestation object (must be ignored).
1558    fn valid_registration(rp_id_field: bool, extra_att_key: bool) -> (Vec<u8>, String, String) {
1559        use ring::signature::EcdsaKeyPair;
1560
1561        let rng = ring::rand::SystemRandom::new();
1562        let pkcs8 =
1563            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
1564                .unwrap();
1565        let key_pair = EcdsaKeyPair::from_pkcs8(
1566            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
1567            pkcs8.as_ref(),
1568            &rng,
1569        )
1570        .unwrap();
1571        let pub_bytes = key_pair.public_key().as_ref().to_vec();
1572        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
1573
1574        let credential_id = vec![0x42u8; 8];
1575        let auth_data = build_auth_data_with_credential(
1576            "localhost",
1577            FLAG_UP | FLAG_AT,
1578            0,
1579            &credential_id,
1580            &cose_key,
1581        );
1582
1583        let challenge = generate_challenge_bytes();
1584        let challenge_b64 = base64_encode_urlsafe(&challenge);
1585
1586        let mut client_data = serde_json::json!({
1587            "type": "webauthn.create",
1588            "challenge": challenge_b64,
1589            "origin": "http://localhost:8080",
1590        });
1591        if rp_id_field {
1592            client_data["rpId"] = serde_json::json!("localhost");
1593        }
1594        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
1595
1596        let mut entries = vec![
1597            (
1598                ciborium::Value::Integer(1.into()),
1599                ciborium::Value::Text("none".to_string()),
1600            ),
1601            (
1602                ciborium::Value::Text("authData".to_string()),
1603                ciborium::Value::Bytes(auth_data),
1604            ),
1605        ];
1606        if extra_att_key {
1607            entries.push((
1608                ciborium::Value::Text("unknownKey".to_string()),
1609                ciborium::Value::Bool(true),
1610            ));
1611        }
1612        entries.push((
1613            ciborium::Value::Text("attStmt".to_string()),
1614            ciborium::Value::Map(vec![]),
1615        ));
1616        let mut buf = Vec::new();
1617        ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
1618
1619        (challenge, client_data_b64, base64_encode_urlsafe(&buf))
1620    }
1621
1622    /// An explicit `rpId` field matching the RP must be accepted, and
1623    /// unknown attestation-object keys must be ignored.
1624    #[test]
1625    fn registration_accepts_rpid_field_and_unknown_keys() {
1626        let (challenge, client_data_b64, att_obj_b64) = valid_registration(true, true);
1627        let result = verify_registration(
1628            &challenge,
1629            &client_data_b64,
1630            &att_obj_b64,
1631            "",
1632            "localhost",
1633            &["http://localhost:8080".to_string()],
1634            &AttestationPolicy::default(),
1635            &CredentialPolicy::default(),
1636        )
1637        .unwrap();
1638        assert_eq!(result.attestation_format, "none");
1639        assert_eq!(result.credential_id, base64_encode_urlsafe(&[0x42u8; 8]));
1640    }
1641
1642    #[test]
1643    fn registration_rejects_missing_type_in_client_data() {
1644        let challenge = generate_challenge_bytes();
1645        let client_data = serde_json::json!({
1646            "challenge": base64_encode_urlsafe(&challenge),
1647            "origin": "http://localhost:8080",
1648        });
1649        let result = verify_registration(
1650            &challenge,
1651            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1652            &base64_encode_urlsafe(&[0xFF; 8]),
1653            "",
1654            "localhost",
1655            &["http://localhost:8080".to_string()],
1656            &AttestationPolicy::default(),
1657            &CredentialPolicy::default(),
1658        );
1659        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1660    }
1661
1662    #[test]
1663    fn registration_rejects_missing_origin_in_client_data() {
1664        let challenge = generate_challenge_bytes();
1665        let client_data = serde_json::json!({
1666            "type": "webauthn.create",
1667            "challenge": base64_encode_urlsafe(&challenge),
1668        });
1669        let result = verify_registration(
1670            &challenge,
1671            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1672            &base64_encode_urlsafe(&[0xFF; 8]),
1673            "",
1674            "localhost",
1675            &["http://localhost:8080".to_string()],
1676            &AttestationPolicy::default(),
1677            &CredentialPolicy::default(),
1678        );
1679        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1680    }
1681
1682    /// Attested credential data that ends right after the credential ID
1683    /// (no COSE key bytes) must be rejected as truncated, never sliced
1684    /// out of bounds.
1685    #[test]
1686    fn parse_auth_data_rejects_truncated_public_key() {
1687        let mut auth_data = vec![0u8; 37 + 16 + 2 + 2];
1688        auth_data[32] = FLAG_UP | FLAG_AT;
1689        auth_data[53..55].copy_from_slice(&2u16.to_be_bytes());
1690        let result = parse_authenticator_data(&auth_data);
1691        assert!(matches!(result, Err(WebauthnError::VerificationFailed(_))));
1692    }
1693
1694    // ---- ES384 / EdDSA ceremonies (REQ-WA-140, REQ-WA-141) ----
1695
1696    use crate::crypto::tests::{build_cose_ec2_key_alg, build_cose_okp_key};
1697    use crate::crypto::{COSE_ALG_ES256, COSE_ALG_ES384};
1698
1699    /// Full ES384 registration roundtrip with a synthetic P-384 keypair.
1700    #[test]
1701    fn registration_roundtrip_es384() {
1702        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1703
1704        let rng = ring::rand::SystemRandom::new();
1705        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1706        let key_pair =
1707            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1708                .unwrap();
1709        let pub_bytes = key_pair.public_key().as_ref();
1710        let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1711
1712        let credential_id = vec![0xE3u8; 8];
1713        let auth_data = build_auth_data_with_credential(
1714            "localhost",
1715            FLAG_UP | FLAG_AT | FLAG_UV | FLAG_BE | FLAG_BS,
1716            0,
1717            &credential_id,
1718            &cose_key,
1719        );
1720        let att_obj = build_attestation_object(&auth_data);
1721
1722        let challenge = generate_challenge_bytes();
1723        let client_data = serde_json::json!({
1724            "type": "webauthn.create",
1725            "challenge": base64_encode_urlsafe(&challenge),
1726            "origin": "http://localhost:8080",
1727        });
1728        let result = verify_registration(
1729            &challenge,
1730            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1731            &base64_encode_urlsafe(&att_obj),
1732            "",
1733            "localhost",
1734            &["http://localhost:8080".to_string()],
1735            &AttestationPolicy::default(),
1736            &CredentialPolicy::default(),
1737        )
1738        .unwrap();
1739
1740        assert!(result.device_name.contains("ES384"));
1741        assert!(result.user_verified);
1742        assert!(result.backup_eligible);
1743        assert!(result.backup_state);
1744    }
1745
1746    /// Full EdDSA registration → authentication roundtrip.
1747    #[test]
1748    fn registration_authentication_roundtrip_eddsa() {
1749        use ring::signature::{Ed25519KeyPair, KeyPair as _};
1750
1751        let rng = ring::rand::SystemRandom::new();
1752        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
1753        let key_pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
1754        let cose_key = build_cose_okp_key(key_pair.public_key().as_ref());
1755
1756        // -- registration --
1757        let credential_id = vec![0xEDu8; 8];
1758        let auth_data = build_auth_data_with_credential(
1759            "localhost",
1760            FLAG_UP | FLAG_AT,
1761            0,
1762            &credential_id,
1763            &cose_key,
1764        );
1765        let att_obj = build_attestation_object(&auth_data);
1766        let challenge = generate_challenge_bytes();
1767        let client_data = serde_json::json!({
1768            "type": "webauthn.create",
1769            "challenge": base64_encode_urlsafe(&challenge),
1770            "origin": "http://localhost:8080",
1771        });
1772        let registration = verify_registration(
1773            &challenge,
1774            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1775            &base64_encode_urlsafe(&att_obj),
1776            "",
1777            "localhost",
1778            &["http://localhost:8080".to_string()],
1779            &AttestationPolicy::default(),
1780            &CredentialPolicy::default(),
1781        )
1782        .unwrap();
1783        assert!(registration.device_name.contains("EdDSA"));
1784        assert!(!registration.backup_eligible);
1785
1786        // -- authentication --
1787        let auth_challenge = generate_challenge_bytes();
1788        let assertion_client_data = serde_json::json!({
1789            "type": "webauthn.get",
1790            "challenge": base64_encode_urlsafe(&auth_challenge),
1791            "origin": "http://localhost:8080",
1792        });
1793        let assertion_client_data_bytes = serde_json::to_vec(&assertion_client_data).unwrap();
1794        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
1795        let mut assertion_auth_data = rp_id_hash;
1796        assertion_auth_data.push(FLAG_UP | FLAG_BS);
1797        assertion_auth_data.extend_from_slice(&1u32.to_be_bytes());
1798
1799        use sha2::Digest as _;
1800        let mut signed_data = assertion_auth_data.clone();
1801        signed_data.extend_from_slice(&sha2::Sha256::digest(&assertion_client_data_bytes));
1802        let signature = key_pair.sign(&signed_data);
1803
1804        let cred_b64 = base64_encode_urlsafe(&credential_id);
1805        let result = verify_authentication(&AuthenticationParams {
1806            challenge_bytes: auth_challenge,
1807            client_data_json_b64: base64_encode_urlsafe(&assertion_client_data_bytes),
1808            authenticator_data_b64: base64_encode_urlsafe(&assertion_auth_data),
1809            signature_b64: base64_encode_urlsafe(signature.as_ref()),
1810            credential_id_b64: cred_b64.clone(),
1811            public_key_cose: cose_key,
1812            current_sign_count: 0,
1813            allowed_credential_ids: vec![cred_b64],
1814            rp_id: "localhost".to_string(),
1815            rp_origins: vec!["http://localhost:8080".to_string()],
1816            policy: CredentialPolicy::default(),
1817        })
1818        .unwrap();
1819        assert_eq!(result.new_sign_count, 1);
1820        assert!(result.backup_state);
1821        assert!(!result.backup_eligible);
1822    }
1823
1824    /// Algorithm-confusion via the ceremony entry point: attested credential
1825    /// data carrying a P-256 key that claims `alg = -35` (ES384) must be
1826    /// rejected (the RS256 bug class, replayed against ES384).
1827    #[test]
1828    fn registration_rejects_p256_key_claiming_es384() {
1829        let cose_key = build_cose_ec2_key_alg(1, -35, &[0xAA; 32], &[0xBB; 32]); // crv 1 = P-256
1830        let auth_data =
1831            build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1832        let att_obj = build_attestation_object(&auth_data);
1833        let challenge = generate_challenge_bytes();
1834        let client_data = serde_json::json!({
1835            "type": "webauthn.create",
1836            "challenge": base64_encode_urlsafe(&challenge),
1837            "origin": "http://localhost:8080",
1838        });
1839        let result = verify_registration(
1840            &challenge,
1841            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1842            &base64_encode_urlsafe(&att_obj),
1843            "",
1844            "localhost",
1845            &["http://localhost:8080".to_string()],
1846            &AttestationPolicy::default(),
1847            &CredentialPolicy::default(),
1848        );
1849        assert!(matches!(
1850            result,
1851            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1852        ));
1853    }
1854
1855    // ---- UV enforcement matrix (REQ-WA-142) ----
1856
1857    /// Build a minimal valid registration for the given flags; returns the
1858    /// ceremony inputs.
1859    fn registration_inputs(flags: u8) -> (Vec<u8>, String, String) {
1860        let cose_key = build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]);
1861        let auth_data =
1862            build_auth_data_with_credential("localhost", flags, 0, &[0x01, 0x02], &cose_key);
1863        let challenge = generate_challenge_bytes();
1864        let client_data = serde_json::json!({
1865            "type": "webauthn.create",
1866            "challenge": base64_encode_urlsafe(&challenge),
1867            "origin": "http://localhost:8080",
1868        });
1869        (
1870            challenge,
1871            base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1872            base64_encode_urlsafe(&build_attestation_object(&auth_data)),
1873        )
1874    }
1875
1876    #[test]
1877    fn uv_required_rejects_clear_flag_and_accepts_set() {
1878        use crate::policy::UserVerificationPolicy;
1879        let uv_required = CredentialPolicy {
1880            user_verification: UserVerificationPolicy::Required,
1881            ..CredentialPolicy::default()
1882        };
1883
1884        // Required + UV clear → rejected.
1885        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1886        let result = verify_registration(
1887            &challenge,
1888            &cd,
1889            &ao,
1890            "",
1891            "localhost",
1892            &["http://localhost:8080".to_string()],
1893            &AttestationPolicy::default(),
1894            &uv_required,
1895        );
1896        assert!(matches!(
1897            result,
1898            Err(WebauthnError::UserVerificationRequired)
1899        ));
1900
1901        // Required + UV set → accepted.
1902        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_UV);
1903        let result = verify_registration(
1904            &challenge,
1905            &cd,
1906            &ao,
1907            "",
1908            "localhost",
1909            &["http://localhost:8080".to_string()],
1910            &AttestationPolicy::default(),
1911            &uv_required,
1912        );
1913        assert!(result.is_ok());
1914    }
1915
1916    /// Preferred/Discouraged never reject on UV; the flag is only reported.
1917    #[test]
1918    fn uv_preferred_and_discouraged_report_only() {
1919        use crate::policy::UserVerificationPolicy;
1920        // UP set, UV clear.
1921        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
1922
1923        for uv in [
1924            UserVerificationPolicy::Preferred,
1925            UserVerificationPolicy::Discouraged,
1926        ] {
1927            let policy = CredentialPolicy {
1928                user_verification: uv,
1929                ..CredentialPolicy::default()
1930            };
1931            let result = verify_registration(
1932                &challenge,
1933                &cd,
1934                &ao,
1935                "",
1936                "localhost",
1937                &["http://localhost:8080".to_string()],
1938                &AttestationPolicy::default(),
1939                &policy,
1940            )
1941            .unwrap();
1942            assert!(!result.user_verified, "{uv:?} must report, not reject");
1943        }
1944    }
1945
1946    // ---- algorithm allowlist (REQ-WA-143) ----
1947
1948    /// A credential whose algorithm is outside the configured allowlist is
1949    /// rejected at registration, even though the kit could verify it.
1950    #[test]
1951    fn registration_rejects_algorithm_outside_allowlist() {
1952        // P-384/ES384 credential with an ES256-only allowlist.
1953        use ring::signature::{EcdsaKeyPair, KeyPair as _, ECDSA_P384_SHA384_FIXED_SIGNING};
1954        let rng = ring::rand::SystemRandom::new();
1955        let pkcs8 = EcdsaKeyPair::generate_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, &rng).unwrap();
1956        let key_pair =
1957            EcdsaKeyPair::from_pkcs8(&ECDSA_P384_SHA384_FIXED_SIGNING, pkcs8.as_ref(), &rng)
1958                .unwrap();
1959        let pub_bytes = key_pair.public_key().as_ref();
1960        let cose_key = build_cose_ec2_key_alg(2, -35, &pub_bytes[1..49], &pub_bytes[49..97]);
1961
1962        let auth_data =
1963            build_auth_data_with_credential("localhost", FLAG_UP | FLAG_AT, 0, &[0x01], &cose_key);
1964        let att_obj = build_attestation_object(&auth_data);
1965        let challenge = generate_challenge_bytes();
1966        let client_data = serde_json::json!({
1967            "type": "webauthn.create",
1968            "challenge": base64_encode_urlsafe(&challenge),
1969            "origin": "http://localhost:8080",
1970        });
1971        let common = (
1972            challenge,
1973            base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
1974            base64_encode_urlsafe(&att_obj),
1975        );
1976
1977        // Disallowed: ES384 not on the ES256-only list.
1978        let es256_only = CredentialPolicy {
1979            allowed_algorithms: vec![COSE_ALG_ES256],
1980            ..CredentialPolicy::default()
1981        };
1982        let result = verify_registration(
1983            &common.0,
1984            &common.1,
1985            &common.2,
1986            "",
1987            "localhost",
1988            &["http://localhost:8080".to_string()],
1989            &AttestationPolicy::default(),
1990            &es256_only,
1991        );
1992        assert!(matches!(
1993            result,
1994            Err(WebauthnError::UnsupportedAlgorithm(COSE_ALG_ES384))
1995        ));
1996
1997        // Allowed once ES384 is listed.
1998        let with_es384 = CredentialPolicy {
1999            allowed_algorithms: vec![COSE_ALG_ES256, COSE_ALG_ES384],
2000            ..CredentialPolicy::default()
2001        };
2002        let result = verify_registration(
2003            &common.0,
2004            &common.1,
2005            &common.2,
2006            "",
2007            "localhost",
2008            &["http://localhost:8080".to_string()],
2009            &AttestationPolicy::default(),
2010            &with_es384,
2011        );
2012        assert!(result.is_ok());
2013
2014        // Empty allowlist = accept everything supported.
2015        let result = verify_registration(
2016            &common.0,
2017            &common.1,
2018            &common.2,
2019            "",
2020            "localhost",
2021            &["http://localhost:8080".to_string()],
2022            &AttestationPolicy::default(),
2023            &CredentialPolicy::default(),
2024        );
2025        assert!(result.is_ok());
2026    }
2027
2028    // ---- backup eligibility / backup state policy (REQ-WA-144) ----
2029
2030    /// `RequireDeviceBound` rejects synced (BE=1) credentials at
2031    /// registration; device-bound (BE=0) registrations pass.
2032    #[test]
2033    fn backup_policy_device_bound_enforced_at_registration() {
2034        use crate::policy::BackupPolicy;
2035        let device_bound = CredentialPolicy {
2036            backup: BackupPolicy::RequireDeviceBound,
2037            ..CredentialPolicy::default()
2038        };
2039
2040        // BE=1 (synced) → rejected.
2041        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE);
2042        let result = verify_registration(
2043            &challenge,
2044            &cd,
2045            &ao,
2046            "",
2047            "localhost",
2048            &["http://localhost:8080".to_string()],
2049            &AttestationPolicy::default(),
2050            &device_bound,
2051        );
2052        assert!(matches!(result, Err(WebauthnError::PolicyViolation(_))));
2053
2054        // BE=0 (device-bound) → accepted; BS reported as-is.
2055        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT);
2056        let result = verify_registration(
2057            &challenge,
2058            &cd,
2059            &ao,
2060            "",
2061            "localhost",
2062            &["http://localhost:8080".to_string()],
2063            &AttestationPolicy::default(),
2064            &device_bound,
2065        );
2066        let registration = result.unwrap();
2067        assert!(!registration.backup_eligible);
2068        assert!(!registration.backup_state);
2069    }
2070
2071    /// `Allow` (default) accepts synced credentials and reports both flags.
2072    #[test]
2073    fn backup_policy_allow_reports_flags() {
2074        let (challenge, cd, ao) = registration_inputs(FLAG_UP | FLAG_AT | FLAG_BE | FLAG_BS);
2075        let registration = verify_registration(
2076            &challenge,
2077            &cd,
2078            &ao,
2079            "",
2080            "localhost",
2081            &["http://localhost:8080".to_string()],
2082            &AttestationPolicy::default(),
2083            &CredentialPolicy::default(),
2084        )
2085        .unwrap();
2086        assert!(registration.backup_eligible);
2087        assert!(registration.backup_state);
2088    }
2089
2090    /// `RequireDeviceBound` also rejects BE=1 assertions at authentication.
2091    #[test]
2092    fn backup_policy_device_bound_enforced_at_authentication() {
2093        use crate::policy::BackupPolicy;
2094        use ring::signature::{EcdsaKeyPair, KeyPair as _};
2095
2096        let rng = ring::rand::SystemRandom::new();
2097        let pkcs8 =
2098            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2099                .unwrap();
2100        let key_pair = EcdsaKeyPair::from_pkcs8(
2101            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2102            pkcs8.as_ref(),
2103            &rng,
2104        )
2105        .unwrap();
2106        let pub_bytes = key_pair.public_key().as_ref();
2107        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2108
2109        let challenge = generate_challenge_bytes();
2110        let client_data = serde_json::json!({
2111            "type": "webauthn.get",
2112            "challenge": base64_encode_urlsafe(&challenge),
2113            "origin": "http://localhost:8080",
2114        });
2115        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2116        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2117        let mut auth_data = rp_id_hash;
2118        auth_data.push(FLAG_UP | FLAG_BE); // synced credential assertion
2119        auth_data.extend_from_slice(&1u32.to_be_bytes());
2120
2121        use sha2::Digest as _;
2122        let mut signed_data = auth_data.clone();
2123        signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2124        let signature = key_pair.sign(&rng, &signed_data).unwrap();
2125
2126        let cred_b64 = base64_encode_urlsafe(&[0x01]);
2127        let params = AuthenticationParams {
2128            challenge_bytes: challenge,
2129            client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2130            authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2131            signature_b64: base64_encode_urlsafe(signature.as_ref()),
2132            credential_id_b64: cred_b64.clone(),
2133            public_key_cose: cose_key,
2134            current_sign_count: 0,
2135            allowed_credential_ids: vec![cred_b64],
2136            rp_id: "localhost".to_string(),
2137            rp_origins: vec!["http://localhost:8080".to_string()],
2138            policy: CredentialPolicy {
2139                backup: BackupPolicy::RequireDeviceBound,
2140                ..CredentialPolicy::default()
2141            },
2142        };
2143        assert!(matches!(
2144            verify_authentication(&params),
2145            Err(WebauthnError::PolicyViolation(_))
2146        ));
2147    }
2148
2149    /// UV Required is enforced at authentication: a validly signed
2150    /// assertion with a clear UV flag fails with
2151    /// `UserVerificationRequired`.
2152    #[test]
2153    fn uv_required_enforced_at_authentication() {
2154        use crate::policy::UserVerificationPolicy;
2155        use ring::signature::{EcdsaKeyPair, KeyPair as _};
2156
2157        let rng = ring::rand::SystemRandom::new();
2158        let pkcs8 =
2159            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
2160                .unwrap();
2161        let key_pair = EcdsaKeyPair::from_pkcs8(
2162            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
2163            pkcs8.as_ref(),
2164            &rng,
2165        )
2166        .unwrap();
2167        let pub_bytes = key_pair.public_key().as_ref();
2168        let cose_key = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
2169
2170        let challenge = generate_challenge_bytes();
2171        let client_data = serde_json::json!({
2172            "type": "webauthn.get",
2173            "challenge": base64_encode_urlsafe(&challenge),
2174            "origin": "http://localhost:8080",
2175        });
2176        let client_data_bytes = serde_json::to_vec(&client_data).unwrap();
2177        let rp_id_hash = sha2::Sha256::digest(b"localhost").to_vec();
2178        let mut auth_data = rp_id_hash;
2179        auth_data.push(FLAG_UP); // UV clear
2180        auth_data.extend_from_slice(&1u32.to_be_bytes());
2181
2182        use sha2::Digest as _;
2183        let mut signed_data = auth_data.clone();
2184        signed_data.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
2185        let signature = key_pair.sign(&rng, &signed_data).unwrap();
2186
2187        let cred_b64 = base64_encode_urlsafe(&[0x01]);
2188        let params = AuthenticationParams {
2189            challenge_bytes: challenge,
2190            client_data_json_b64: base64_encode_urlsafe(&client_data_bytes),
2191            authenticator_data_b64: base64_encode_urlsafe(&auth_data),
2192            signature_b64: base64_encode_urlsafe(signature.as_ref()),
2193            credential_id_b64: cred_b64.clone(),
2194            public_key_cose: cose_key,
2195            current_sign_count: 0,
2196            allowed_credential_ids: vec![cred_b64],
2197            rp_id: "localhost".to_string(),
2198            rp_origins: vec!["http://localhost:8080".to_string()],
2199            policy: CredentialPolicy {
2200                user_verification: UserVerificationPolicy::Required,
2201                ..CredentialPolicy::default()
2202            },
2203        };
2204        assert!(matches!(
2205            verify_authentication(&params),
2206            Err(WebauthnError::UserVerificationRequired)
2207        ));
2208
2209        // The identical assertion under the default policy is accepted.
2210        let mut default_params = params.clone();
2211        default_params.policy = CredentialPolicy::default();
2212        assert!(verify_authentication(&default_params).is_ok());
2213    }
2214
2215    /// A malformed attestation *statement* (packed without `alg`) surfaces
2216    /// as an `AttestationError` through the registration entry point.
2217    #[test]
2218    fn registration_propagates_attestation_statement_errors() {
2219        let auth_data = build_auth_data_with_credential(
2220            "localhost",
2221            FLAG_UP | FLAG_AT,
2222            0,
2223            &[0x01],
2224            &build_cose_ec2_key(&[0xAA; 32], &[0xBB; 32]),
2225        );
2226        let entries = vec![
2227            (
2228                ciborium::Value::Integer(1.into()),
2229                ciborium::Value::Text("packed".to_string()),
2230            ),
2231            (
2232                ciborium::Value::Integer(2.into()),
2233                ciborium::Value::Bytes(auth_data),
2234            ),
2235            (
2236                ciborium::Value::Integer(3.into()),
2237                ciborium::Value::Map(vec![]), // packed attStmt without alg/sig
2238            ),
2239        ];
2240        let mut buf = Vec::new();
2241        ciborium::ser::into_writer(&ciborium::Value::Map(entries), &mut buf).unwrap();
2242
2243        let challenge = generate_challenge_bytes();
2244        let client_data = serde_json::json!({
2245            "type": "webauthn.create",
2246            "challenge": base64_encode_urlsafe(&challenge),
2247            "origin": "http://localhost:8080",
2248        });
2249        let result = verify_registration(
2250            &challenge,
2251            &base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap()),
2252            &base64_encode_urlsafe(&buf),
2253            "",
2254            "localhost",
2255            &["http://localhost:8080".to_string()],
2256            &AttestationPolicy::default(),
2257            &CredentialPolicy::default(),
2258        );
2259        assert!(matches!(result, Err(WebauthnError::AttestationError(_))));
2260    }
2261
2262    /// Truncation checks in `parse_authenticator_data` are exact and
2263    /// stage-specific: a blob at the minimum length passes the prefix
2264    /// bound and fails at the *next* stage, and a blob below the real
2265    /// bound (but above any mutated one) is rejected at the prefix stage.
2266    #[test]
2267    fn authenticator_data_truncation_boundaries_are_exact() {
2268        // 37 header + 16 AAGUID + 2 credential-length = 55 bytes: passes
2269        // the prefix check exactly, then fails at the public-key stage.
2270        let at_minimum = build_auth_data_with_credential("localhost", FLAG_AT, 0, &[], &[]);
2271        assert_eq!(at_minimum.len(), 55);
2272        let err = parse_authenticator_data(&at_minimum)
2273            .unwrap_err()
2274            .to_string();
2275        assert!(
2276            err.contains("public key"),
2277            "exact-prefix blob must reach the public-key stage: {err}"
2278        );
2279
2280        // 40 bytes with AT: below the real prefix bound (55) but above the
2281        // header bound (37) — must be rejected at the AAGUID+length stage.
2282        let mut short = vec![0u8; 32];
2283        short.push(FLAG_AT);
2284        short.extend_from_slice(&[0u8; 7]);
2285        let err = parse_authenticator_data(&short).unwrap_err().to_string();
2286        assert!(
2287            err.contains("AAGUID + length"),
2288            "40-byte AT blob must fail at the prefix stage: {err}"
2289        );
2290
2291        // Exact-fit credential ID: 37 + 16 + 2 + 2 = 57 bytes, credential
2292        // id fully present, public-key stage reached.
2293        let cred_exact =
2294            build_auth_data_with_credential("localhost", FLAG_AT, 0, &[0x01, 0x02], &[]);
2295        assert_eq!(cred_exact.len(), 57);
2296        let err = parse_authenticator_data(&cred_exact)
2297            .unwrap_err()
2298            .to_string();
2299        assert!(
2300            err.contains("public key"),
2301            "exact-fit credential id must reach the public-key stage: {err}"
2302        );
2303    }
2304
2305    /// Registration auth data without the AT flag must be rejected with
2306    /// the specific attested-credential error — not by a downstream,
2307    /// looser check. Pins the FLAG_AT gate itself.
2308    #[test]
2309    fn registration_without_attested_credential_flag_names_the_missing_flag() {
2310        let auth_data = build_auth_data_with_credential(
2311            "localhost",
2312            FLAG_UP, // no FLAG_AT
2313            0,
2314            &[],
2315            &[],
2316        );
2317        let att_obj = build_attestation_object(&auth_data);
2318
2319        let challenge = generate_challenge_bytes();
2320        let client_data = serde_json::json!({
2321            "type": "webauthn.create",
2322            "challenge": base64_encode_urlsafe(&challenge),
2323            "origin": "http://localhost:8080",
2324        });
2325        let client_data_b64 = base64_encode_urlsafe(&serde_json::to_vec(&client_data).unwrap());
2326
2327        let result = verify_registration(
2328            &challenge,
2329            &client_data_b64,
2330            &base64_encode_urlsafe(&att_obj),
2331            "",
2332            "localhost",
2333            &["http://localhost:8080".to_string()],
2334            &AttestationPolicy::default(),
2335            &CredentialPolicy::default(),
2336        );
2337        let err = result.unwrap_err().to_string();
2338        assert!(
2339            err.contains("Attested Credential Data flag not set"),
2340            "AT-less registration must be rejected at the flag gate: {err}"
2341        );
2342    }
2343}