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