Skip to main content

webauthn/
authenticator_data.rs

1//! Authenticator data parsing.
2//!
3//! The authenticator data (`authData`) is a binary structure defined in
4//! [WebAuthn §6.1](https://www.w3.org/TR/webauthn-2/#authenticator-data).
5//! It is produced by the authenticator hardware and carries the RP ID binding,
6//! user-presence/verification flags, a sign counter, and (during registration)
7//! the new credential's public key encoded as a COSE_Key.
8//!
9//! ## Binary layout
10//!
11//! ```text
12//! Offset   Len   Field
13//! ──────   ───   ─────────────────────────────────────────────────
14//!      0    32   rpIdHash   — SHA-256 of the RP ID
15//!     32     1   flags      — bitmask (UP / UV / AT / ED)
16//!     33     4   signCount  — big-endian u32
17//!     37     *   attestedCredentialData (present iff AT flag is set)
18//!              16   aaguid
19//!               2   credentialIdLength  (big-endian u16)
20//!               *   credentialId
21//!               *   credentialPublicKey (CBOR-encoded COSE_Key)
22//!      *     *   extensions (CBOR map, present iff ED flag is set)
23//! ```
24
25use std::collections::HashMap;
26use std::io::Cursor;
27
28use ciborium::value::Value;
29
30use crate::algorithm::{
31    COSE_CRV_ED25519, COSE_CRV_P256, COSE_CRV_P384, COSE_EDDSA, COSE_ES256, COSE_ES384,
32    COSE_KTY_EC2, COSE_KTY_OKP, COSE_KTY_RSA, COSE_RS256,
33};
34use crate::error::{Result, WebAuthnError};
35
36// ─── Flags bitmask constants ──────────────────────────────────────────────────
37
38/// Bit 0: User Present — the authenticator confirmed physical user presence.
39const FLAG_UP: u8 = 0x01;
40/// Bit 2: User Verified — biometric / PIN check passed.
41const FLAG_UV: u8 = 0x04;
42/// Bit 3: Backup Eligibility — the credential may be synced to a platform account.
43const FLAG_BE: u8 = 0x08;
44/// Bit 4: Backup State — the credential is currently backed up.
45const FLAG_BS: u8 = 0x10;
46/// Bit 6: Attested Credential Data — `attestedCredentialData` is present.
47const FLAG_AT: u8 = 0x40;
48/// Bit 7: Extension Data — CBOR extensions follow the credential data.
49const FLAG_ED: u8 = 0x80;
50
51/// Maximum sane credential ID length. Values above this indicate corrupt data.
52const MAX_CREDENTIAL_ID_LEN: usize = 1023;
53
54/// Minimum RSA modulus length in bytes (2048-bit key = 256 bytes).
55const MIN_RSA_N_LEN: usize = 256;
56
57// ─── Types ───────────────────────────────────────────────────────────────────
58
59/// Decoded flags byte from authenticator data.
60#[derive(Debug, Clone, Copy)]
61pub struct AuthenticatorFlags {
62    /// UP: the authenticator confirmed physical user presence.
63    pub user_present: bool,
64    /// UV: biometric or PIN check passed.
65    pub user_verified: bool,
66    /// BE: the credential is eligible for backup to a platform sync service.
67    /// Immutable — set at registration and cannot change across ceremonies.
68    pub backup_eligible: bool,
69    /// BS: the credential is currently backed up. May change between ceremonies.
70    pub backup_state: bool,
71    /// AT: attested credential data is included (registration only).
72    pub attested_credential_data: bool,
73    /// ED: a CBOR extension map follows the credential data.
74    pub extension_data: bool,
75}
76
77/// A decoded COSE_Key from the credential's authenticator data.
78///
79/// Three key types are supported:
80/// - `EC2`: elliptic-curve (kty = 2). Carries `crv`, `x`, and `y`.
81/// - `OKP`: octet key pair (kty = 1). Carries the raw 32-byte Ed25519 key in `x`.
82/// - `RSA`: RSA public key (kty = 3). Carries `n` and `e`.
83///
84/// The `alg` field in each variant holds the COSE algorithm identifier;
85/// the registration ceremony validates it against the expected value.
86#[derive(Debug, Clone)]
87pub enum CoseKey {
88    /// EC2 key — P-256 with ES256 (alg = -7) or P-384 with ES384 (alg = -35).
89    EC2 {
90        /// Algorithm (COSE map key `3`). `-7` = ES256, `-35` = ES384.
91        alg: i64,
92        /// Curve (COSE map key `-1`). `1` = P-256, `2` = P-384.
93        crv: i64,
94        /// X coordinate (COSE map key `-2`). 32 bytes for P-256, 48 bytes for P-384.
95        x: Vec<u8>,
96        /// Y coordinate (COSE map key `-3`). 32 bytes for P-256, 48 bytes for P-384.
97        y: Vec<u8>,
98    },
99    /// OKP key — EdDSA with Ed25519 (alg = -8, crv = 6).
100    OKP {
101        /// Algorithm (COSE map key `3`). Value `-8` = EdDSA.
102        alg: i64,
103        /// Raw 32-byte Ed25519 public key (COSE map key `-2`).
104        x: Vec<u8>,
105    },
106    /// RSA key — RS256 (alg = -257).
107    RSA {
108        /// Algorithm (COSE map key `3`). Value `-257` = RS256.
109        alg: i64,
110        /// Modulus (COSE map key `-1`). At least 256 bytes for a 2048-bit key.
111        n: Vec<u8>,
112        /// Public exponent (COSE map key `-2`). Typically `[0x01, 0x00, 0x01]`.
113        e: Vec<u8>,
114    },
115}
116
117impl CoseKey {
118    /// Return the COSE algorithm identifier for this key.
119    pub fn alg(&self) -> i64 {
120        match self {
121            CoseKey::EC2 { alg, .. } => *alg,
122            CoseKey::OKP { alg, .. } => *alg,
123            CoseKey::RSA { alg, .. } => *alg,
124        }
125    }
126}
127
128/// Credential data embedded in the authenticator data during registration.
129#[derive(Debug)]
130pub struct AttestedCredentialData {
131    /// Authenticator Attestation GUID — identifies the authenticator model.
132    /// All-zeros is common for platform authenticators.
133    pub aaguid: [u8; 16],
134
135    /// Opaque credential identifier chosen by the authenticator.
136    pub credential_id: Vec<u8>,
137
138    /// The new credential's public key as a decoded COSE_Key.
139    pub public_key: CoseKey,
140}
141
142/// Fully parsed authenticator data.
143#[derive(Debug)]
144pub struct AuthenticatorData {
145    /// SHA-256 hash of the RP ID. Verified against `SHA-256(rp_id)` by the caller.
146    pub rp_id_hash: [u8; 32],
147
148    /// Decoded flags byte.
149    pub flags: AuthenticatorFlags,
150
151    /// Authenticator-maintained signature counter.
152    pub sign_count: u32,
153
154    /// Present only during registration (when the AT flag is set).
155    pub attested_credential_data: Option<AttestedCredentialData>,
156
157    /// Extension data from the authenticator (§6.1 / §10.5), present when the ED flag is set.
158    ///
159    /// Keys are extension identifiers (e.g. `"credProps"`, `"appid"`); values are raw CBOR.
160    /// Unknown extensions are stored as-is — the library never rejects an unrecognised key.
161    pub extensions: Option<HashMap<String, Value>>,
162
163    /// The original raw bytes of this authenticator data structure.
164    ///
165    /// Kept because the signed payload for authentication is
166    /// `authData || SHA-256(clientDataJSON)` — the exact bytes matter.
167    pub raw: Vec<u8>,
168}
169
170// ─── Public parsing functions ─────────────────────────────────────────────────
171
172/// Parse the raw authenticator data bytes into an [`AuthenticatorData`].
173///
174/// # Errors
175/// - [`WebAuthnError::InvalidAuthenticatorData`] — bytes too short or malformed.
176/// - [`WebAuthnError::InvalidPublicKey`] / [`WebAuthnError::CborDecodeError`]
177///   — the embedded COSE key cannot be decoded.
178pub fn parse_authenticator_data(data: &[u8]) -> Result<AuthenticatorData> {
179    // Minimum: 32 (rpIdHash) + 1 (flags) + 4 (signCount) = 37 bytes.
180    if data.len() < 37 {
181        return Err(WebAuthnError::InvalidAuthenticatorData(format!(
182            "too short: expected at least 37 bytes, got {}",
183            data.len()
184        )));
185    }
186
187    // §6.1: Parse rpIdHash (32 bytes). The bounds check above guarantees this.
188    let rp_id_hash: [u8; 32] = data[0..32]
189        .try_into()
190        .expect("slice of exactly 32 bytes always converts");
191
192    // §6.1: Parse flags byte.
193    let flags_byte = data[32];
194    let flags = AuthenticatorFlags {
195        user_present: flags_byte & FLAG_UP != 0,
196        user_verified: flags_byte & FLAG_UV != 0,
197        backup_eligible: flags_byte & FLAG_BE != 0,
198        backup_state: flags_byte & FLAG_BS != 0,
199        attested_credential_data: flags_byte & FLAG_AT != 0,
200        extension_data: flags_byte & FLAG_ED != 0,
201    };
202
203    // §6.1: BS can only be set when BE is also set — a credential cannot be backed
204    // up without first being backup-eligible.
205    if flags.backup_state && !flags.backup_eligible {
206        return Err(WebAuthnError::InvalidAuthenticatorData(
207            "BS flag (backup state) is set but BE flag (backup eligibility) is not — invalid per §6.1".to_string(),
208        ));
209    }
210
211    // §6.1: Parse sign count (big-endian u32). Bytes 33–36 are within the 37-byte minimum.
212    let sign_count = u32::from_be_bytes(
213        data[33..37]
214            .try_into()
215            .expect("slice of exactly 4 bytes always converts"),
216    );
217
218    // §6.1: Conditionally parse attested credential data.
219    let (attested_credential_data, at_bytes_consumed) = if flags.attested_credential_data {
220        let (cred_data, n) = parse_attested_credential_data(&data[37..])?;
221        (Some(cred_data), n)
222    } else {
223        (None, 0)
224    };
225
226    // §6.1: Conditionally parse the extensions CBOR map (ED flag = bit 7).
227    // Extensions follow AT data when present, otherwise they start immediately after
228    // the 37-byte fixed header.
229    let extensions = if flags.extension_data {
230        let ext_start = 37 + at_bytes_consumed;
231        Some(parse_extension_map(&data[ext_start..])?)
232    } else {
233        None
234    };
235
236    Ok(AuthenticatorData {
237        rp_id_hash,
238        flags,
239        sign_count,
240        attested_credential_data,
241        extensions,
242        raw: data.to_vec(),
243    })
244}
245
246/// Decode a CBOR-encoded COSE_Key and return it as a [`CoseKey`].
247///
248/// Both EC2 (kty = 2) and RSA (kty = 3) keys are supported.
249/// For EC2: dispatches on `alg` to validate crv and coordinate length —
250///   ES256 (alg = -7) requires crv = 1 and 32-byte x/y coordinates;
251///   ES384 (alg = -35) requires crv = 2 and 48-byte x/y coordinates.
252/// For RSA: validates alg == -257 and that n is at least 256 bytes.
253///
254/// # Errors
255/// - [`WebAuthnError::CborDecodeError`] — input is not valid CBOR.
256/// - [`WebAuthnError::InvalidPublicKey`] — missing required fields, wrong key type, or bad curve.
257/// - [`WebAuthnError::UnsupportedAlgorithm`] — `alg` is present but not a recognised value.
258pub fn parse_cose_key(data: &[u8]) -> Result<CoseKey> {
259    let value: Value = ciborium::from_reader(data)
260        .map_err(|e| WebAuthnError::CborDecodeError(format!("COSE key: {e}")))?;
261
262    let map = match value {
263        Value::Map(m) => m,
264        _ => {
265            return Err(WebAuthnError::InvalidPublicKey(
266                "COSE key must be a CBOR map".to_string(),
267            ))
268        }
269    };
270
271    // Check for duplicate integer keys — ambiguous and likely corrupt input.
272    {
273        let mut seen: Vec<i64> = Vec::new();
274        for (k, _) in &map {
275            if let Value::Integer(ki) = k {
276                if let Ok(n) = i64::try_from(*ki) {
277                    if seen.contains(&n) {
278                        return Err(WebAuthnError::InvalidPublicKey(format!(
279                            "duplicate COSE key: {n}"
280                        )));
281                    }
282                    seen.push(n);
283                }
284            }
285        }
286    }
287
288    let get_int = |key: i64| -> Option<i64> {
289        map.iter().find_map(|(k, v)| {
290            if let (Value::Integer(ki), Value::Integer(vi)) = (k, v) {
291                if i64::try_from(*ki).ok()? == key {
292                    return i64::try_from(*vi).ok();
293                }
294            }
295            None
296        })
297    };
298
299    let get_bytes = |key: i64| -> Option<Vec<u8>> {
300        map.iter().find_map(|(k, v)| {
301            if let Value::Integer(ki) = k {
302                if i64::try_from(*ki).ok()? == key {
303                    if let Value::Bytes(b) = v {
304                        return Some(b.clone());
305                    }
306                }
307            }
308            None
309        })
310    };
311
312    // COSE map key 1 = kty. Dispatch on key type.
313    let kty = get_int(1).ok_or_else(|| {
314        WebAuthnError::InvalidPublicKey("missing required field: kty".to_string())
315    })?;
316
317    if kty == COSE_KTY_OKP {
318        parse_okp_key(&get_int, &get_bytes)
319    } else if kty == COSE_KTY_EC2 {
320        parse_ec2_key(&get_int, &get_bytes)
321    } else if kty == COSE_KTY_RSA {
322        parse_rsa_key(&get_int, &get_bytes)
323    } else {
324        Err(WebAuthnError::InvalidPublicKey(format!(
325            "unsupported key type: {kty} (supported: OKP=1, EC2=2, RSA=3)"
326        )))
327    }
328}
329
330// ─── Internal helpers ─────────────────────────────────────────────────────────
331
332fn parse_okp_key(
333    get_int: &impl Fn(i64) -> Option<i64>,
334    get_bytes: &impl Fn(i64) -> Option<Vec<u8>>,
335) -> Result<CoseKey> {
336    // COSE map key 3 = alg. For OKP keys we require EdDSA = -8.
337    let alg = get_int(3).ok_or_else(|| {
338        WebAuthnError::InvalidPublicKey("missing required field: alg".to_string())
339    })?;
340
341    if alg != COSE_EDDSA {
342        return Err(WebAuthnError::UnsupportedAlgorithm(alg));
343    }
344
345    // COSE map key -1 = crv. Value 6 = Ed25519.
346    let crv = get_int(-1).ok_or_else(|| {
347        WebAuthnError::InvalidPublicKey("missing required field: crv".to_string())
348    })?;
349
350    if crv != COSE_CRV_ED25519 {
351        return Err(WebAuthnError::InvalidPublicKey(format!(
352            "unsupported OKP curve: {crv} (only Ed25519 / crv=6 is supported)"
353        )));
354    }
355
356    // COSE map key -2 = x (raw public key bytes). Ed25519 keys are always 32 bytes.
357    let x = get_bytes(-2)
358        .ok_or_else(|| WebAuthnError::InvalidPublicKey("missing required field: x".to_string()))?;
359
360    if x.len() != 32 {
361        return Err(WebAuthnError::InvalidPublicKey(format!(
362            "Ed25519 public key must be 32 bytes, got {}",
363            x.len()
364        )));
365    }
366
367    Ok(CoseKey::OKP { alg, x })
368}
369
370fn parse_ec2_key(
371    get_int: &impl Fn(i64) -> Option<i64>,
372    get_bytes: &impl Fn(i64) -> Option<Vec<u8>>,
373) -> Result<CoseKey> {
374    // COSE map key 3 = alg. Dispatch on algorithm to determine expected curve and
375    // coordinate size. Mixing alg and crv (e.g. ES256 with P-384) is rejected.
376    let alg = get_int(3).ok_or_else(|| {
377        WebAuthnError::InvalidPublicKey("missing required field: alg".to_string())
378    })?;
379
380    // COSE map key -1 = crv.
381    let crv = get_int(-1).ok_or_else(|| {
382        WebAuthnError::InvalidPublicKey("missing required field: crv".to_string())
383    })?;
384
385    let coord_size: usize = match alg {
386        COSE_ES256 => {
387            // ES256 (ECDSA P-256 SHA-256) requires crv=1 (P-256) and 32-byte coordinates.
388            if crv != COSE_CRV_P256 {
389                return Err(WebAuthnError::InvalidPublicKey(format!(
390                    "ES256 requires curve P-256 (crv=1), got {crv}"
391                )));
392            }
393            32
394        }
395        COSE_ES384 => {
396            // ES384 (ECDSA P-384 SHA-384) requires crv=2 (P-384) and 48-byte coordinates.
397            if crv != COSE_CRV_P384 {
398                return Err(WebAuthnError::InvalidPublicKey(format!(
399                    "ES384 requires curve P-384 (crv=2), got {crv}"
400                )));
401            }
402            48
403        }
404        _ => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
405    };
406
407    // COSE map key -2 = x coordinate.
408    let x = get_bytes(-2)
409        .ok_or_else(|| WebAuthnError::InvalidPublicKey("missing required field: x".to_string()))?;
410
411    // COSE map key -3 = y coordinate.
412    let y = get_bytes(-3)
413        .ok_or_else(|| WebAuthnError::InvalidPublicKey("missing required field: y".to_string()))?;
414
415    if x.len() != coord_size {
416        return Err(WebAuthnError::InvalidPublicKey(format!(
417            "x coordinate must be {coord_size} bytes, got {}",
418            x.len()
419        )));
420    }
421    if y.len() != coord_size {
422        return Err(WebAuthnError::InvalidPublicKey(format!(
423            "y coordinate must be {coord_size} bytes, got {}",
424            y.len()
425        )));
426    }
427
428    Ok(CoseKey::EC2 { alg, crv, x, y })
429}
430
431fn parse_rsa_key(
432    get_int: &impl Fn(i64) -> Option<i64>,
433    get_bytes: &impl Fn(i64) -> Option<Vec<u8>>,
434) -> Result<CoseKey> {
435    // COSE map key 3 = alg. For RSA keys we require RS256 = -257.
436    let alg = get_int(3).ok_or_else(|| {
437        WebAuthnError::InvalidPublicKey("missing required field: alg".to_string())
438    })?;
439
440    if alg != COSE_RS256 {
441        return Err(WebAuthnError::UnsupportedAlgorithm(alg));
442    }
443
444    // COSE map key -1 = n (modulus).
445    let n = get_bytes(-1)
446        .ok_or_else(|| WebAuthnError::InvalidPublicKey("missing required field: n".to_string()))?;
447
448    if n.len() < MIN_RSA_N_LEN {
449        return Err(WebAuthnError::InvalidPublicKey(format!(
450            "RSA modulus must be at least {} bytes (2048-bit), got {}",
451            MIN_RSA_N_LEN,
452            n.len()
453        )));
454    }
455
456    // COSE map key -2 = e (public exponent).
457    let e = get_bytes(-2)
458        .ok_or_else(|| WebAuthnError::InvalidPublicKey("missing required field: e".to_string()))?;
459
460    if e.is_empty() {
461        return Err(WebAuthnError::InvalidPublicKey(
462            "RSA exponent (e) must not be empty".to_string(),
463        ));
464    }
465
466    Ok(CoseKey::RSA { alg, n, e })
467}
468
469/// Returns `(AttestedCredentialData, bytes_consumed)` where `bytes_consumed` is the number of
470/// bytes read from `data`. Used by the caller to locate the extension map that may follow.
471fn parse_attested_credential_data(data: &[u8]) -> Result<(AttestedCredentialData, usize)> {
472    // Need at least: 16 (aaguid) + 2 (credentialIdLength) = 18 bytes.
473    if data.len() < 18 {
474        return Err(WebAuthnError::InvalidAuthenticatorData(format!(
475            "attested credential data too short: expected at least 18 bytes after flags/counter, got {}",
476            data.len()
477        )));
478    }
479
480    let mut offset = 0usize;
481
482    // aaguid: 16 bytes.
483    let aaguid: [u8; 16] = data
484        .get(offset..offset + 16)
485        .ok_or_else(|| {
486            WebAuthnError::InvalidAuthenticatorData("truncated before aaguid".to_string())
487        })?
488        .try_into()
489        .expect("slice of exactly 16 bytes always converts");
490    offset += 16;
491
492    // credentialIdLength: big-endian u16. Bytes 16 and 17 are within the 18-byte minimum.
493    let cred_id_len = u16::from_be_bytes([
494        *data.get(offset).ok_or_else(|| {
495            WebAuthnError::InvalidAuthenticatorData(
496                "truncated before credentialIdLength high byte".to_string(),
497            )
498        })?,
499        *data.get(offset + 1).ok_or_else(|| {
500            WebAuthnError::InvalidAuthenticatorData(
501                "truncated before credentialIdLength low byte".to_string(),
502            )
503        })?,
504    ]) as usize;
505    offset += 2;
506
507    if cred_id_len == 0 {
508        return Err(WebAuthnError::InvalidAuthenticatorData(
509            "credentialIdLength is 0 — empty credential ID is not valid".to_string(),
510        ));
511    }
512
513    if cred_id_len > MAX_CREDENTIAL_ID_LEN {
514        return Err(WebAuthnError::InvalidAuthenticatorData(format!(
515            "credentialIdLength {cred_id_len} exceeds maximum {MAX_CREDENTIAL_ID_LEN} — likely corrupt data"
516        )));
517    }
518
519    let credential_id = data
520        .get(offset..offset + cred_id_len)
521        .ok_or_else(|| {
522            WebAuthnError::InvalidAuthenticatorData(format!(
523                "credentialIdLength ({cred_id_len}) extends past end of buffer"
524            ))
525        })?
526        .to_vec();
527    offset += cred_id_len;
528
529    // credentialPublicKey: a CBOR-encoded COSE_Key that may be followed by extension bytes.
530    // Use a Cursor to determine exactly how many bytes the COSE key occupies so the caller
531    // can locate the extension map that may immediately follow.
532    let remaining = data.get(offset..).ok_or_else(|| {
533        WebAuthnError::InvalidAuthenticatorData("truncated before public key CBOR".to_string())
534    })?;
535
536    if remaining.is_empty() {
537        return Err(WebAuthnError::InvalidAuthenticatorData(
538            "no bytes remaining for credentialPublicKey CBOR".to_string(),
539        ));
540    }
541
542    // §6.1: Probe CBOR size with a cursor so we can report bytes_consumed accurately.
543    // parse_cose_key does the real validation; this call is only for the byte count.
544    let mut cursor = Cursor::new(remaining);
545    let _: Value = ciborium::from_reader(&mut cursor)
546        .map_err(|e| WebAuthnError::CborDecodeError(format!("COSE key: {e}")))?;
547    let cbor_len = cursor.position() as usize;
548
549    let public_key = parse_cose_key(&remaining[..cbor_len])?;
550
551    Ok((
552        AttestedCredentialData {
553            aaguid,
554            credential_id,
555            public_key,
556        },
557        offset + cbor_len,
558    ))
559}
560
561/// Decode the CBOR extension map from `data` (§6.1 / §10.5).
562///
563/// The extensions section is a CBOR map whose keys are extension identifier strings
564/// (e.g. `"credProps"`, `"appid"`) and whose values are extension-specific CBOR.
565/// Unknown extensions are stored as raw [`Value`] — the library never rejects an
566/// unrecognised extension key.
567fn parse_extension_map(data: &[u8]) -> Result<HashMap<String, Value>> {
568    if data.is_empty() {
569        return Err(WebAuthnError::InvalidAuthenticatorData(
570            "ED flag is set but no extension bytes are present".to_string(),
571        ));
572    }
573
574    let value: Value = ciborium::from_reader(data)
575        .map_err(|e| WebAuthnError::CborDecodeError(format!("extension map: {e}")))?;
576
577    let map = match value {
578        Value::Map(m) => m,
579        _ => {
580            return Err(WebAuthnError::InvalidAuthenticatorData(
581                "extension data must be a CBOR map".to_string(),
582            ))
583        }
584    };
585
586    let mut extensions = HashMap::new();
587    for (k, v) in map {
588        // §10.5: extension identifiers are strings. Non-text keys are skipped silently.
589        if let Value::Text(key) = k {
590            extensions.insert(key, v);
591        }
592    }
593
594    Ok(extensions)
595}
596
597// ─── Tests ────────────────────────────────────────────────────────────────────
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use ciborium::value::Value;
603
604    /// Build a minimal authenticator data buffer for testing.
605    pub fn make_auth_data(
606        rp_id_hash: &[u8; 32],
607        flags: u8,
608        sign_count: u32,
609        cred_data: Option<&[u8]>,
610    ) -> Vec<u8> {
611        let mut out = Vec::new();
612        out.extend_from_slice(rp_id_hash);
613        out.push(flags);
614        out.extend_from_slice(&sign_count.to_be_bytes());
615        if let Some(cd) = cred_data {
616            out.extend_from_slice(cd);
617        }
618        out
619    }
620
621    fn make_cose_key_cbor(x: &[u8], y: &[u8]) -> Vec<u8> {
622        make_cose_key_cbor_with_alg_crv(x, y, -7, 1)
623    }
624
625    fn make_cose_key_cbor_with_alg_crv(x: &[u8], y: &[u8], alg: i64, crv: i64) -> Vec<u8> {
626        let cose = Value::Map(vec![
627            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
628            (Value::Integer(3i64.into()), Value::Integer(alg.into())),
629            (Value::Integer((-1i64).into()), Value::Integer(crv.into())),
630            (Value::Integer((-2i64).into()), Value::Bytes(x.to_vec())),
631            (Value::Integer((-3i64).into()), Value::Bytes(y.to_vec())),
632        ]);
633        let mut buf = Vec::new();
634        ciborium::into_writer(&cose, &mut buf).expect("test setup");
635        buf
636    }
637
638    fn make_rsa_cose_key_cbor(n: &[u8], e: &[u8]) -> Vec<u8> {
639        let cose = Value::Map(vec![
640            (Value::Integer(1i64.into()), Value::Integer(3i64.into())), // kty: RSA
641            (
642                Value::Integer(3i64.into()),
643                Value::Integer((-257i64).into()),
644            ), // alg: RS256
645            (Value::Integer((-1i64).into()), Value::Bytes(n.to_vec())), // n
646            (Value::Integer((-2i64).into()), Value::Bytes(e.to_vec())), // e
647        ]);
648        let mut buf = Vec::new();
649        ciborium::into_writer(&cose, &mut buf).expect("test setup");
650        buf
651    }
652
653    fn make_attested_cred_data(cred_id: &[u8], pk_cbor: &[u8]) -> Vec<u8> {
654        let mut out = vec![0u8; 16]; // aaguid
655        out.extend_from_slice(&(cred_id.len() as u16).to_be_bytes());
656        out.extend_from_slice(cred_id);
657        out.extend_from_slice(pk_cbor);
658        out
659    }
660
661    // ── BE / BS flag parsing ─────────────────────────────────────────────────
662
663    #[test]
664    fn parses_be_flag() {
665        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_BE, 0, None);
666        let parsed = parse_authenticator_data(&data).expect("test setup");
667        assert!(parsed.flags.backup_eligible);
668        assert!(!parsed.flags.backup_state);
669    }
670
671    #[test]
672    fn parses_be_and_bs_flags() {
673        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_BE | FLAG_BS, 0, None);
674        let parsed = parse_authenticator_data(&data).expect("test setup");
675        assert!(parsed.flags.backup_eligible);
676        assert!(parsed.flags.backup_state);
677    }
678
679    #[test]
680    fn rejects_bs_without_be() {
681        // §6.1: BS set without BE is an invalid combination.
682        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_BS, 0, None);
683        let err = parse_authenticator_data(&data).expect_err("expected error");
684        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
685        assert!(err.to_string().contains("BS"));
686    }
687
688    // ── parse_authenticator_data ─────────────────────────────────────────────
689
690    #[test]
691    fn rejects_too_short() {
692        let result = parse_authenticator_data(&[0u8; 10]);
693        assert!(matches!(
694            result,
695            Err(WebAuthnError::InvalidAuthenticatorData(_))
696        ));
697    }
698
699    #[test]
700    fn rejects_exactly_36_bytes() {
701        let result = parse_authenticator_data(&[0u8; 36]);
702        assert!(matches!(
703            result,
704            Err(WebAuthnError::InvalidAuthenticatorData(_))
705        ));
706    }
707
708    #[test]
709    fn rejects_empty() {
710        let result = parse_authenticator_data(&[]);
711        assert!(matches!(
712            result,
713            Err(WebAuthnError::InvalidAuthenticatorData(_))
714        ));
715    }
716
717    #[test]
718    fn parses_minimal_auth_data() {
719        let rp_hash = [0xABu8; 32];
720        let data = make_auth_data(&rp_hash, FLAG_UP, 42, None);
721        let parsed = parse_authenticator_data(&data).expect("test setup");
722
723        assert_eq!(parsed.rp_id_hash, rp_hash);
724        assert!(parsed.flags.user_present);
725        assert!(!parsed.flags.user_verified);
726        assert_eq!(parsed.sign_count, 42);
727        assert!(parsed.attested_credential_data.is_none());
728        assert_eq!(parsed.raw, data);
729    }
730
731    #[test]
732    fn parses_up_and_uv_flags() {
733        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_UV, 0, None);
734        let parsed = parse_authenticator_data(&data).expect("test setup");
735        assert!(parsed.flags.user_present);
736        assert!(parsed.flags.user_verified);
737    }
738
739    #[test]
740    fn raw_field_equals_input_bytes() {
741        let data = make_auth_data(&[0u8; 32], FLAG_UP, 7, None);
742        let parsed = parse_authenticator_data(&data).expect("test setup");
743        assert_eq!(parsed.raw, data);
744    }
745
746    #[test]
747    fn all_flags_set_parses_without_panic() {
748        // All flags 0xFF — AT is set so it tries to parse attested cred data;
749        // since there's none, it returns an error (not a panic).
750        let data = make_auth_data(&[0u8; 32], 0xFF, 0, None);
751        let result = parse_authenticator_data(&data);
752        assert!(result.is_err());
753    }
754
755    #[test]
756    fn at_flag_set_but_no_data_returns_error() {
757        // AT flag set but no bytes after the 37-byte header.
758        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT, 0, None);
759        let err = parse_authenticator_data(&data).expect_err("expected error");
760        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
761    }
762
763    // ── attested credential data ─────────────────────────────────────────────
764
765    #[test]
766    fn rejects_zero_length_credential_id() {
767        let pk = make_cose_key_cbor(&[0x01u8; 32], &[0x02u8; 32]);
768        let cred_data = make_attested_cred_data(&[], &pk); // empty cred ID
769        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT, 0, Some(&cred_data));
770        let err = parse_authenticator_data(&data).expect_err("expected error");
771        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
772        // Verify the message is specific.
773        assert!(err.to_string().contains("0"));
774    }
775
776    #[test]
777    fn rejects_credential_id_too_large() {
778        // credentialIdLength = 1024 — above MAX_CREDENTIAL_ID_LEN.
779        let mut cred_data = vec![0u8; 16]; // aaguid
780        let oversized_len: u16 = 1024;
781        cred_data.extend_from_slice(&oversized_len.to_be_bytes());
782        // Don't append any bytes — the length check fires before the buffer read.
783        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT, 0, Some(&cred_data));
784        let err = parse_authenticator_data(&data).expect_err("expected error");
785        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
786        assert!(err.to_string().contains("1024"));
787    }
788
789    #[test]
790    fn rejects_credential_id_extends_past_buffer() {
791        let mut cred_data = vec![0u8; 16]; // aaguid
792        cred_data.extend_from_slice(&50u16.to_be_bytes()); // claims 50-byte ID
793        cred_data.extend_from_slice(&[0xABu8; 10]); // only 10 bytes present
794        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT, 0, Some(&cred_data));
795        let err = parse_authenticator_data(&data).expect_err("expected error");
796        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
797    }
798
799    #[test]
800    fn rejects_empty_cbor_after_credential_id() {
801        let mut cred_data = vec![0u8; 16]; // aaguid
802        cred_data.extend_from_slice(&1u16.to_be_bytes());
803        cred_data.push(0xAB); // credential ID (1 byte)
804                              // No CBOR bytes follow.
805        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT, 0, Some(&cred_data));
806        let err = parse_authenticator_data(&data).expect_err("expected error");
807        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
808    }
809
810    // ── extension parsing (§6.1 / §10.5) ────────────────────────────────────
811
812    fn make_extension_cbor(entries: &[(&str, Value)]) -> Vec<u8> {
813        let map = Value::Map(
814            entries
815                .iter()
816                .map(|(k, v)| (Value::Text(k.to_string()), v.clone()))
817                .collect(),
818        );
819        let mut buf = Vec::new();
820        ciborium::into_writer(&map, &mut buf).expect("test setup");
821        buf
822    }
823
824    #[test]
825    fn ed_flag_not_set_produces_none_extensions() {
826        let data = make_auth_data(&[0u8; 32], FLAG_UP, 1, None);
827        let parsed = parse_authenticator_data(&data).expect("test setup");
828        assert!(parsed.extensions.is_none());
829    }
830
831    #[test]
832    fn ed_flag_set_with_valid_map_populates_extensions() {
833        let ext_cbor = make_extension_cbor(&[("appid", Value::Bool(true))]);
834        let mut data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_ED, 0, None);
835        data.extend_from_slice(&ext_cbor);
836        let parsed = parse_authenticator_data(&data).expect("test setup");
837        let exts = parsed.extensions.expect("test setup");
838        assert_eq!(exts.get("appid"), Some(&Value::Bool(true)));
839    }
840
841    #[test]
842    fn ed_flag_set_with_multiple_extensions() {
843        let cred_props = Value::Map(vec![(Value::Text("rk".to_string()), Value::Bool(true))]);
844        let ext_cbor = make_extension_cbor(&[
845            ("credProps", cred_props.clone()),
846            ("appid", Value::Bool(false)),
847        ]);
848        let mut data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_ED, 0, None);
849        data.extend_from_slice(&ext_cbor);
850        let parsed = parse_authenticator_data(&data).expect("test setup");
851        let exts = parsed.extensions.expect("test setup");
852        assert_eq!(exts.get("credProps"), Some(&cred_props));
853        assert_eq!(exts.get("appid"), Some(&Value::Bool(false)));
854    }
855
856    #[test]
857    fn ed_flag_set_with_at_flag_parses_extensions_after_cose_key() {
858        // AT + ED: extensions must appear after the attested credential data (COSE key).
859        let pk_cbor = make_cose_key_cbor(&[0x01u8; 32], &[0x02u8; 32]);
860        let cred_data = make_attested_cred_data(&[0xAAu8; 8], &pk_cbor);
861        let ext_cbor = make_extension_cbor(&[("appid", Value::Bool(true))]);
862        let mut combined = cred_data;
863        combined.extend_from_slice(&ext_cbor);
864        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_AT | FLAG_ED, 0, Some(&combined));
865        let parsed = parse_authenticator_data(&data).expect("test setup");
866        assert!(parsed.attested_credential_data.is_some());
867        let exts = parsed.extensions.expect("test setup");
868        assert_eq!(exts.get("appid"), Some(&Value::Bool(true)));
869    }
870
871    #[test]
872    fn ed_flag_set_with_no_bytes_returns_error() {
873        // ED set but buffer ends at the 37-byte fixed header — no extension bytes present.
874        let data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_ED, 0, None);
875        let err = parse_authenticator_data(&data).expect_err("expected error");
876        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
877        assert!(err.to_string().contains("ED"));
878    }
879
880    #[test]
881    fn ed_flag_set_with_malformed_cbor_returns_error() {
882        let mut data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_ED, 0, None);
883        data.extend_from_slice(&[0xFF, 0xFF]); // not valid CBOR
884        let err = parse_authenticator_data(&data).expect_err("expected error");
885        assert!(matches!(err, WebAuthnError::CborDecodeError(_)));
886    }
887
888    #[test]
889    fn ed_flag_set_with_non_map_cbor_returns_error() {
890        let mut data = make_auth_data(&[0u8; 32], FLAG_UP | FLAG_ED, 0, None);
891        // Encode a CBOR integer instead of a map.
892        let not_a_map = Value::Integer(42i64.into());
893        let mut cbor_buf = Vec::new();
894        ciborium::into_writer(&not_a_map, &mut cbor_buf).expect("test setup");
895        data.extend_from_slice(&cbor_buf);
896        let err = parse_authenticator_data(&data).expect_err("expected error");
897        assert!(matches!(err, WebAuthnError::InvalidAuthenticatorData(_)));
898        assert!(err.to_string().contains("map"));
899    }
900
901    // ── parse_cose_key — EC2 ─────────────────────────────────────────────────
902
903    #[test]
904    fn parse_cose_key_valid_es256() {
905        let x = vec![0x01u8; 32];
906        let y = vec![0x02u8; 32];
907        let cbor = make_cose_key_cbor(&x, &y);
908        let key = parse_cose_key(&cbor).expect("test setup");
909        match key {
910            CoseKey::EC2 {
911                alg,
912                crv,
913                x: kx,
914                y: ky,
915            } => {
916                assert_eq!(alg, -7);
917                assert_eq!(crv, 1);
918                assert_eq!(kx, x);
919                assert_eq!(ky, y);
920            }
921            _ => panic!("expected EC2 key"),
922        }
923    }
924
925    #[test]
926    fn parse_cose_key_rejects_missing_kty() {
927        let cose = Value::Map(vec![
928            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
929            (Value::Integer((-1i64).into()), Value::Integer(1i64.into())),
930            (Value::Integer((-2i64).into()), Value::Bytes(vec![0u8; 32])),
931            (Value::Integer((-3i64).into()), Value::Bytes(vec![0u8; 32])),
932        ]);
933        let mut buf = Vec::new();
934        ciborium::into_writer(&cose, &mut buf).expect("test setup");
935        let err = parse_cose_key(&buf).expect_err("expected error");
936        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
937        assert!(err.to_string().contains("kty"));
938    }
939
940    #[test]
941    fn parse_cose_key_rejects_missing_alg() {
942        let cose = Value::Map(vec![
943            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
944            (Value::Integer((-1i64).into()), Value::Integer(1i64.into())),
945            (Value::Integer((-2i64).into()), Value::Bytes(vec![0u8; 32])),
946            (Value::Integer((-3i64).into()), Value::Bytes(vec![0u8; 32])),
947        ]);
948        let mut buf = Vec::new();
949        ciborium::into_writer(&cose, &mut buf).expect("test setup");
950        let err = parse_cose_key(&buf).expect_err("expected error");
951        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
952        assert!(err.to_string().contains("alg"));
953    }
954
955    #[test]
956    fn parse_cose_key_rejects_missing_crv() {
957        let cose = Value::Map(vec![
958            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
959            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
960            (Value::Integer((-2i64).into()), Value::Bytes(vec![0u8; 32])),
961            (Value::Integer((-3i64).into()), Value::Bytes(vec![0u8; 32])),
962        ]);
963        let mut buf = Vec::new();
964        ciborium::into_writer(&cose, &mut buf).expect("test setup");
965        let err = parse_cose_key(&buf).expect_err("expected error");
966        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
967        assert!(err.to_string().contains("crv"));
968    }
969
970    #[test]
971    fn parse_cose_key_rejects_missing_x() {
972        let cose = Value::Map(vec![
973            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
974            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
975            (Value::Integer((-1i64).into()), Value::Integer(1i64.into())),
976            (Value::Integer((-3i64).into()), Value::Bytes(vec![0u8; 32])),
977        ]);
978        let mut buf = Vec::new();
979        ciborium::into_writer(&cose, &mut buf).expect("test setup");
980        let err = parse_cose_key(&buf).expect_err("expected error");
981        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
982        assert!(err.to_string().contains("x"));
983    }
984
985    #[test]
986    fn parse_cose_key_rejects_missing_y() {
987        let cose = Value::Map(vec![
988            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
989            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
990            (Value::Integer((-1i64).into()), Value::Integer(1i64.into())),
991            (Value::Integer((-2i64).into()), Value::Bytes(vec![0u8; 32])),
992        ]);
993        let mut buf = Vec::new();
994        ciborium::into_writer(&cose, &mut buf).expect("test setup");
995        let err = parse_cose_key(&buf).expect_err("expected error");
996        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
997        assert!(err.to_string().contains("y"));
998    }
999
1000    #[test]
1001    fn parse_cose_key_rejects_unsupported_kty() {
1002        // kty = 4 (symmetric) — not supported at all
1003        let cose = Value::Map(vec![
1004            (Value::Integer(1i64.into()), Value::Integer(4i64.into())),
1005            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
1006        ]);
1007        let mut buf = Vec::new();
1008        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1009        let err = parse_cose_key(&buf).expect_err("expected error");
1010        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("4")));
1011    }
1012
1013    #[test]
1014    fn parse_cose_key_rejects_unsupported_crv() {
1015        // alg=-7 (ES256) with crv=2 (P-384) is an alg/curve mismatch — ES256 requires crv=1.
1016        let cbor = make_cose_key_cbor_with_alg_crv(&[0x01u8; 32], &[0x02u8; 32], -7, 2);
1017        let err = parse_cose_key(&cbor).expect_err("expected error");
1018        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("2")));
1019    }
1020
1021    #[test]
1022    fn parse_cose_key_valid_es384() {
1023        let x = vec![0x01u8; 48];
1024        let y = vec![0x02u8; 48];
1025        let cbor = make_cose_key_cbor_with_alg_crv(&x, &y, -35, 2);
1026        let key = parse_cose_key(&cbor).expect("test setup");
1027        match key {
1028            CoseKey::EC2 {
1029                alg,
1030                crv,
1031                x: kx,
1032                y: ky,
1033            } => {
1034                assert_eq!(alg, -35);
1035                assert_eq!(crv, 2);
1036                assert_eq!(kx, x);
1037                assert_eq!(ky, y);
1038            }
1039            _ => panic!("expected EC2 key"),
1040        }
1041    }
1042
1043    #[test]
1044    fn parse_cose_key_es384_rejects_short_x_coordinate() {
1045        let cbor = make_cose_key_cbor_with_alg_crv(&[0x01u8; 32], &[0x02u8; 48], -35, 2);
1046        let err = parse_cose_key(&cbor).expect_err("expected error");
1047        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("32")));
1048    }
1049
1050    #[test]
1051    fn parse_cose_key_es384_rejects_short_y_coordinate() {
1052        let cbor = make_cose_key_cbor_with_alg_crv(&[0x01u8; 48], &[0x02u8; 32], -35, 2);
1053        let err = parse_cose_key(&cbor).expect_err("expected error");
1054        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("32")));
1055    }
1056
1057    #[test]
1058    fn parse_cose_key_es384_rejects_wrong_crv() {
1059        // alg=-35 (ES384) with crv=1 (P-256) is an alg/curve mismatch — ES384 requires crv=2.
1060        let cbor = make_cose_key_cbor_with_alg_crv(&[0x01u8; 48], &[0x02u8; 48], -35, 1);
1061        let err = parse_cose_key(&cbor).expect_err("expected error");
1062        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("1")));
1063    }
1064
1065    #[test]
1066    fn parse_cose_key_rejects_short_x_coordinate() {
1067        let cbor = make_cose_key_cbor(&[0x01u8; 31], &[0x02u8; 32]);
1068        let err = parse_cose_key(&cbor).expect_err("expected error");
1069        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("31")));
1070    }
1071
1072    #[test]
1073    fn parse_cose_key_rejects_short_y_coordinate() {
1074        let cbor = make_cose_key_cbor(&[0x01u8; 32], &[0x02u8; 10]);
1075        let err = parse_cose_key(&cbor).expect_err("expected error");
1076        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("10")));
1077    }
1078
1079    #[test]
1080    fn parse_cose_key_rejects_long_x_coordinate() {
1081        let cbor = make_cose_key_cbor(&[0x01u8; 33], &[0x02u8; 32]);
1082        let err = parse_cose_key(&cbor).expect_err("expected error");
1083        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("33")));
1084    }
1085
1086    #[test]
1087    fn parse_cose_key_rejects_duplicate_key() {
1088        // Two entries with the same CBOR integer key (1 = kty).
1089        let cose = Value::Map(vec![
1090            (Value::Integer(1i64.into()), Value::Integer(2i64.into())),
1091            (Value::Integer(1i64.into()), Value::Integer(3i64.into())), // duplicate kty
1092            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
1093            (Value::Integer((-1i64).into()), Value::Integer(1i64.into())),
1094            (Value::Integer((-2i64).into()), Value::Bytes(vec![0u8; 32])),
1095            (Value::Integer((-3i64).into()), Value::Bytes(vec![0u8; 32])),
1096        ]);
1097        let mut buf = Vec::new();
1098        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1099        let err = parse_cose_key(&buf).expect_err("expected error");
1100        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("duplicate")));
1101    }
1102
1103    #[test]
1104    fn parse_cose_key_rejects_not_a_map() {
1105        // A CBOR integer instead of a map.
1106        let cose = Value::Integer(42i64.into());
1107        let mut buf = Vec::new();
1108        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1109        let err = parse_cose_key(&buf).expect_err("expected error");
1110        assert!(matches!(err, WebAuthnError::InvalidPublicKey(_)));
1111    }
1112
1113    #[test]
1114    fn parse_cose_key_rejects_empty_input() {
1115        let err = parse_cose_key(&[]).expect_err("expected error");
1116        assert!(matches!(err, WebAuthnError::CborDecodeError(_)));
1117    }
1118
1119    // ── parse_cose_key — RS256 ───────────────────────────────────────────────
1120
1121    #[test]
1122    fn parse_cose_key_valid_rs256() {
1123        let n = vec![0x01u8; 256]; // 2048-bit modulus (high bit clear — no DER pad needed here)
1124        let e = vec![0x01u8, 0x00, 0x01];
1125        let cbor = make_rsa_cose_key_cbor(&n, &e);
1126        let key = parse_cose_key(&cbor).expect("test setup");
1127        match key {
1128            CoseKey::RSA { alg, n: kn, e: ke } => {
1129                assert_eq!(alg, -257);
1130                assert_eq!(kn, n);
1131                assert_eq!(ke, e);
1132            }
1133            _ => panic!("expected RSA key"),
1134        }
1135    }
1136
1137    #[test]
1138    fn parse_cose_key_rs256_rejects_short_modulus() {
1139        // n = 255 bytes — below the 256-byte (2048-bit) minimum.
1140        let n = vec![0x01u8; 255];
1141        let e = vec![0x01u8, 0x00, 0x01];
1142        let cbor = make_rsa_cose_key_cbor(&n, &e);
1143        let err = parse_cose_key(&cbor).expect_err("expected error");
1144        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("255")));
1145    }
1146
1147    #[test]
1148    fn parse_cose_key_rs256_rejects_missing_n() {
1149        let cose = Value::Map(vec![
1150            (Value::Integer(1i64.into()), Value::Integer(3i64.into())),
1151            (
1152                Value::Integer(3i64.into()),
1153                Value::Integer((-257i64).into()),
1154            ),
1155            // -1 (n) absent
1156            (
1157                Value::Integer((-2i64).into()),
1158                Value::Bytes(vec![0x01, 0x00, 0x01]),
1159            ),
1160        ]);
1161        let mut buf = Vec::new();
1162        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1163        let err = parse_cose_key(&buf).expect_err("expected error");
1164        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("n")));
1165    }
1166
1167    #[test]
1168    fn parse_cose_key_rs256_rejects_missing_e() {
1169        let cose = Value::Map(vec![
1170            (Value::Integer(1i64.into()), Value::Integer(3i64.into())),
1171            (
1172                Value::Integer(3i64.into()),
1173                Value::Integer((-257i64).into()),
1174            ),
1175            (
1176                Value::Integer((-1i64).into()),
1177                Value::Bytes(vec![0x01u8; 256]),
1178            ),
1179            // -2 (e) absent
1180        ]);
1181        let mut buf = Vec::new();
1182        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1183        let err = parse_cose_key(&buf).expect_err("expected error");
1184        assert!(matches!(err, WebAuthnError::InvalidPublicKey(ref m) if m.contains("e")));
1185    }
1186
1187    #[test]
1188    fn parse_cose_key_rsa_with_wrong_alg_returns_unsupported() {
1189        // kty=3 (RSA) but alg=-7 (ES256) — algorithm mismatch
1190        let cose = Value::Map(vec![
1191            (Value::Integer(1i64.into()), Value::Integer(3i64.into())),
1192            (Value::Integer(3i64.into()), Value::Integer((-7i64).into())),
1193            (
1194                Value::Integer((-1i64).into()),
1195                Value::Bytes(vec![0x01u8; 256]),
1196            ),
1197            (
1198                Value::Integer((-2i64).into()),
1199                Value::Bytes(vec![0x01, 0x00, 0x01]),
1200            ),
1201        ]);
1202        let mut buf = Vec::new();
1203        ciborium::into_writer(&cose, &mut buf).expect("test setup");
1204        let err = parse_cose_key(&buf).expect_err("expected error");
1205        assert!(matches!(err, WebAuthnError::UnsupportedAlgorithm(-7)));
1206    }
1207}