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