Skip to main content

webauthn_kit/
attestation.rs

1//! Attestation statement parsing and verification (WebAuthn Level 2 §8).
2//!
3//! Tranche 1 implements the three formats that cover the overwhelming
4//! majority of real registrations:
5//!
6//! - **`none`** (§8.1): no attestation statement; the ceremony reduces to
7//!   proving possession of the credential private key. Enforced strictly:
8//!   the statement must be an empty CBOR map ([`AttestationFormat::None`]).
9//! - **`packed`** (§8.2): self-attestation (signature with the credential
10//!   key) or basic/AttCA attestation (signature with the first certificate's
11//!   key, optionally chained to a configured trust anchor).
12//! - **`fido-u2f`** (§8.6): the legacy FIDO U2F attestation, verified over
13//!   the U2F transport encoding with exactly one attestation certificate.
14//!
15//! # Trust levels
16//!
17//! [`TrustLevel`] reports what a *verified* attestation actually proves:
18//!
19//! - [`TrustLevel::None`]: key possession only (`none` format).
20//! - [`TrustLevel::SelfAttested`]: the credential key signed the statement
21//!   (packed self-attestation) — possession, not provenance.
22//! - [`TrustLevel::BasicAtt`]: a certificate chain internally consistent and
23//!   rooted at a self-signed certificate, but **not** anchored to any RP-
24//!   configured trust anchor. Anyone can mint such a chain; treat exactly
25//!   like self-attestation. A warning is always emitted (see
26//!   [`warnings::UNANCHORED_CHAIN`]).
27//! - [`TrustLevel::AttCa`]: the chain terminates at a caller-configured
28//!   trust anchor — the only level that attests device provenance.
29//!
30//! # Security notes / threat assumptions
31//!
32//! - Every statement field arrives from the hostile client. Parsing is total:
33//!   malformed input yields `Err`, never a panic (fuzz-tested).
34//! - Signature checks are fail-closed: any unexpected certificate property
35//!   (unsupported algorithm, non-v3, expired, non-CA intermediate, AAGUID
36//!   extension mismatch) aborts verification.
37//! - Trust-anchor matching is caller policy: `AttestationPolicy::trust_anchors`
38//!   empty accepts unanchored chains *flagged* as [`TrustLevel::BasicAtt`]
39//!   with [`warnings::UNANCHORED_CHAIN`] — callers enforcing provenance MUST
40//!   configure anchors and inspect [`AttestationResult::trust_level`].
41//! - Certificate revocation (OCSP/CRL) is out of scope; see THREAT-MODEL.md.
42//!
43//! # Requirements
44//! REQ-WA-119, REQ-WA-120, REQ-WA-121, REQ-WA-122, REQ-WA-123, REQ-WA-124,
45//! REQ-WA-125, REQ-WA-126, REQ-WA-127, REQ-WA-128, REQ-WA-129, REQ-WA-130,
46//! REQ-WA-132
47
48use std::time::{SystemTime, UNIX_EPOCH};
49
50use ciborium::Value;
51use x509_parser::prelude::*;
52
53use crate::crypto::{
54    cbor_bytes, cbor_map_entries, verify_cose_signature, CosePublicKey, COSE_ALG_ES256,
55    COSE_ALG_RS256,
56};
57use crate::error::WebauthnError;
58
59// ---------------------------------------------------------------------------
60// Public API types
61// ---------------------------------------------------------------------------
62
63/// WebAuthn attestation statement format identifier (WebAuthn L2 §5.1.4).
64///
65/// Parsed from the `fmt` string of the attestation object. [`AttestationFormat::Unknown`]
66/// exists so a registration can be accepted under the documented
67/// `allow_unknown_formats` escape hatch while still reporting honestly that
68/// its statement was **not** verified.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[cfg_attr(feature = "serde", derive(serde::Serialize))]
71pub enum AttestationFormat {
72    /// `fmt = "none"`: no attestation statement.
73    None,
74    /// `fmt = "packed"`: self-attestation or basic/AttCA attestation.
75    Packed,
76    /// `fmt = "fido-u2f"`: legacy FIDO U2F attestation.
77    FidoU2f,
78    /// Any other `fmt` value (accepted only via the `allow_unknown_formats`
79    /// escape hatch; the statement is never verified).
80    Unknown,
81}
82
83impl AttestationFormat {
84    /// Parse an attestation-object `fmt` string.
85    pub fn from_fmt(fmt: &str) -> Self {
86        match fmt {
87            "none" => AttestationFormat::None,
88            "packed" => AttestationFormat::Packed,
89            "fido-u2f" => AttestationFormat::FidoU2f,
90            _ => AttestationFormat::Unknown,
91        }
92    }
93
94    /// Canonical `fmt` string for this format (unknown formats report
95    /// `"unknown"`; see [`AttestationFormat::Unknown`]).
96    pub fn as_fmt(&self) -> &'static str {
97        match self {
98            AttestationFormat::None => "none",
99            AttestationFormat::Packed => "packed",
100            AttestationFormat::FidoU2f => "fido-u2f",
101            AttestationFormat::Unknown => "unknown",
102        }
103    }
104}
105
106/// What a verified attestation proves about the credential's provenance.
107///
108/// Ordered from weakest to strongest. See the module docs for the exact
109/// semantics — in particular [`TrustLevel::BasicAtt`] chains are *not*
110/// anchored to a configured trust anchor and must be treated as
111/// [`TrustLevel::SelfAttested`] by policy.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
113#[cfg_attr(feature = "serde", derive(serde::Serialize))]
114pub enum TrustLevel {
115    /// No attestation claim (`none` format): key possession only.
116    None,
117    /// The credential key itself signed the statement (packed
118    /// self-attestation): key possession only.
119    SelfAttested,
120    /// A syntactically valid, internally consistent certificate chain rooted
121    /// at a self-signed certificate that is **not** an RP-configured trust
122    /// anchor. Equivalent in trust to [`TrustLevel::SelfAttested`]; always
123    /// accompanied by [`warnings::UNANCHORED_CHAIN`].
124    BasicAtt,
125    /// The chain terminates at an RP-configured trust anchor: real device
126    /// provenance (AttCA / basic attestation with a trusted root).
127    AttCa,
128}
129
130/// Warning strings emitted into [`AttestationResult::warnings`].
131///
132/// Warnings are stable string constants so callers can match on them.
133pub mod warnings {
134    /// A certificate chain was accepted without any configured trust anchor;
135    /// its provenance is equivalent to self-attestation.
136    pub const UNANCHORED_CHAIN: &str =
137        "attestation chain accepted without a configured trust anchor; \
138         treat as self-attested";
139    /// An unknown attestation format was accepted via the
140    /// `allow_unknown_formats` escape hatch; its statement was NOT verified.
141    pub const UNVERIFIED_FORMAT: &str =
142        "unknown attestation format accepted via allow_unknown_formats; \
143         statement was not verified";
144}
145
146/// Result of attestation verification for a registration.
147#[derive(Debug, Clone)]
148#[cfg_attr(feature = "serde", derive(serde::Serialize))]
149pub struct AttestationResult {
150    /// The attestation format that was used.
151    pub format: AttestationFormat,
152    /// AAGUID from attested credential data (always present during
153    /// registration; U2F authenticators report the all-zero AAGUID).
154    pub aaguid: Option<[u8; 16]>,
155    /// What the verified attestation proves. Callers enforcing device
156    /// provenance MUST require [`TrustLevel::AttCa`].
157    pub trust_level: TrustLevel,
158    /// Trust-relevant warnings (e.g. [`warnings::UNANCHORED_CHAIN`]).
159    pub warnings: Vec<&'static str>,
160}
161
162/// Caller policy for attestation verification.
163///
164/// # Security notes
165///
166/// - `trust_anchors` empty means *any* internally-consistent, self-rooted
167///   chain is accepted at [`TrustLevel::BasicAtt`] with
168///   [`warnings::UNANCHORED_CHAIN`] — a deliberate flag-not-block default so
169///   unmodified integrations keep working, documented as trust-weakening.
170///   Configure your attestation root certificates here to upgrade verified
171///   chains to [`TrustLevel::AttCa`].
172/// - `allow_unknown_formats = true` is an explicit trust-weakening escape
173///   hatch: registrations with unrecognized `fmt` values are accepted with
174///   [`warnings::UNVERIFIED_FORMAT`] instead of rejected. Leave it `false`
175///   unless you know you need it.
176#[derive(Debug, Clone, Default)]
177pub struct AttestationPolicy {
178    /// DER-encoded X.509 attestation root (trust anchor) certificates.
179    /// Empty accepts any self-rooted chain, flagged (see struct docs).
180    pub trust_anchors: Vec<Vec<u8>>,
181    /// Accept unknown attestation formats with a warning instead of
182    /// rejecting them (trust-weakening; see struct docs).
183    pub allow_unknown_formats: bool,
184}
185
186impl AttestationPolicy {
187    /// Strict policy: no trust anchors, unknown formats rejected.
188    ///
189    /// This still verifies `none`/`packed`/`fido-u2f` self-attestation and
190    /// unanchored chains (flagged); add `trust_anchors` to enforce
191    /// provenance ([`TrustLevel::AttCa`] only).
192    pub fn strict() -> Self {
193        Self::default()
194    }
195}
196
197/// Parsed attestation statement (CBOR `attStmt` map).
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub(crate) enum AttestationStatement {
200    /// Empty statement (`none` format).
201    None,
202    /// Packed statement: COSE algorithm, signature, optional cert chain.
203    Packed {
204        /// COSE algorithm identifier used for `sig`.
205        alg: i32,
206        /// Signature over `authData || clientDataHash`.
207        sig: Vec<u8>,
208        /// Optional X.509 certificate chain (DER), leaf first.
209        x5c: Vec<Vec<u8>>,
210    },
211    /// FIDO U2F statement: signature plus exactly one certificate.
212    FidoU2f {
213        /// Signature over the U2F registration message.
214        sig: Vec<u8>,
215        /// The single attestation certificate (DER).
216        cert: Vec<u8>,
217    },
218}
219
220// ---------------------------------------------------------------------------
221// OID constants (compared as DER-encoded bytes via `Oid::as_bytes`)
222// ---------------------------------------------------------------------------
223
224/// OID 1.2.840.113549.1.1.11 — sha256WithRSAEncryption.
225const OID_SIG_SHA256_WITH_RSA: &[u8] = &[0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B];
226/// OID 1.2.840.10045.4.3.2 — ecdsa-with-SHA256.
227const OID_SIG_ECDSA_WITH_SHA256: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02];
228/// OID 1.2.840.113549.1.1.1 — rsaEncryption.
229const OID_ALG_RSA_ENCRYPTION: &[u8] = &[0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01];
230/// OID 1.2.840.10045.2.1 — id-ecPublicKey.
231const OID_ALG_EC_PUBLIC_KEY: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01];
232/// OID 2.5.29.19 — basicConstraints.
233const OID_EXT_BASIC_CONSTRAINTS: &[u8] = &[0x55, 0x1D, 0x13];
234/// OID 1.3.6.1.4.1.45724.1.1.4 — id-fido-gen-ce-aaguid (FIDO 2.1 §8.2.1).
235const OID_EXT_FIDO_GEN_CE_AAGUID: &[u8] =
236    &[0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0xE5, 0x14, 0x01, 0x04];
237
238// ---------------------------------------------------------------------------
239// Statement parsing
240// ---------------------------------------------------------------------------
241
242/// CBOR `attStmt` label: `alg` (COSE algorithm identifier).
243const STMT_LABEL_ALG: i64 = 3;
244/// CBOR `attStmt` label: `sig` (signature byte string).
245const STMT_LABEL_SIG: i64 = 8;
246/// CBOR `attStmt` label: `x5c` (X.509 certificate chain array).
247const STMT_LABEL_X5C: i64 = 33;
248
249/// Parse the raw `attStmt` CBOR value for the given format.
250///
251/// `att_stmt` is `None` when the attestation object carried no statement
252/// entry at all (only legal for the `none` format).
253///
254/// # Requirements
255/// REQ-WA-119, REQ-WA-128, REQ-WA-132
256pub(crate) fn parse_statement(
257    format: &AttestationFormat,
258    att_stmt: Option<&Value>,
259) -> Result<AttestationStatement, WebauthnError> {
260    match format {
261        AttestationFormat::None => {
262            // WebAuthn L2 §8.1: attStmt MUST be an empty CBOR map.
263            match att_stmt {
264                None => Ok(AttestationStatement::None),
265                Some(Value::Map(entries)) if entries.is_empty() => Ok(AttestationStatement::None),
266                Some(_) => Err(WebauthnError::AttestationError(
267                    "attStmt must be an empty map for the 'none' format".to_string(),
268                )),
269            }
270        }
271        AttestationFormat::Packed => {
272            let val = att_stmt.ok_or_else(|| {
273                WebauthnError::AttestationError(
274                    "missing attStmt for the 'packed' format".to_string(),
275                )
276            })?;
277            let entries = cbor_map_entries(val).ok_or_else(|| {
278                WebauthnError::AttestationError(
279                    "attStmt is not a CBOR map for the 'packed' format".to_string(),
280                )
281            })?;
282
283            let mut alg: Option<i32> = None;
284            let mut sig: Option<Vec<u8>> = None;
285            let mut x5c: Vec<Vec<u8>> = Vec::new();
286
287            for (label, v) in &entries {
288                match *label {
289                    STMT_LABEL_ALG => alg = cbor_alg(v),
290                    STMT_LABEL_SIG => sig = cbor_bytes(v),
291                    STMT_LABEL_X5C => x5c = cbor_cert_array(v)?,
292                    _ => {}
293                }
294            }
295
296            let alg = alg.ok_or_else(|| {
297                WebauthnError::AttestationError("packed attStmt missing 'alg'".to_string())
298            })?;
299            let sig = sig.ok_or_else(|| {
300                WebauthnError::AttestationError("packed attStmt missing 'sig'".to_string())
301            })?;
302
303            Ok(AttestationStatement::Packed { alg, sig, x5c })
304        }
305        AttestationFormat::FidoU2f => {
306            let val = att_stmt.ok_or_else(|| {
307                WebauthnError::AttestationError(
308                    "missing attStmt for the 'fido-u2f' format".to_string(),
309                )
310            })?;
311            let entries = cbor_map_entries(val).ok_or_else(|| {
312                WebauthnError::AttestationError(
313                    "attStmt is not a CBOR map for the 'fido-u2f' format".to_string(),
314                )
315            })?;
316
317            let mut sig: Option<Vec<u8>> = None;
318            let mut x5c: Vec<Vec<u8>> = Vec::new();
319
320            for (label, v) in &entries {
321                match *label {
322                    STMT_LABEL_SIG => sig = cbor_bytes(v),
323                    STMT_LABEL_X5C => x5c = cbor_cert_array(v)?,
324                    _ => {}
325                }
326            }
327
328            let sig = sig.ok_or_else(|| {
329                WebauthnError::AttestationError("fido-u2f attStmt missing 'sig'".to_string())
330            })?;
331            // WebAuthn L2 §8.6: x5c carries exactly one attestation certificate.
332            let [cert] = x5c.as_slice() else {
333                return Err(WebauthnError::AttestationError(
334                    "fido-u2f attStmt must carry exactly one x5c certificate".to_string(),
335                ));
336            };
337
338            Ok(AttestationStatement::FidoU2f {
339                sig,
340                cert: cert.clone(),
341            })
342        }
343        // Unknown formats are a policy decision, never parsed.
344        AttestationFormat::Unknown => Err(WebauthnError::AttestationError(
345            "unknown attestation format cannot be parsed".to_string(),
346        )),
347    }
348}
349
350/// Extract an `i32` COSE algorithm from a CBOR value.
351fn cbor_alg(val: &Value) -> Option<i32> {
352    match val {
353        Value::Integer(i) => (*i).try_into().ok(),
354        _ => None,
355    }
356}
357
358/// Extract the `x5c` certificate array: a CBOR array of byte strings.
359fn cbor_cert_array(val: &Value) -> Result<Vec<Vec<u8>>, WebauthnError> {
360    match val {
361        Value::Array(items) => {
362            let mut certs = Vec::with_capacity(items.len());
363            for item in items {
364                certs.push(cbor_bytes(item).ok_or_else(|| {
365                    WebauthnError::AttestationError(
366                        "x5c contains a non-byte-string certificate".to_string(),
367                    )
368                })?);
369            }
370            Ok(certs)
371        }
372        _ => Err(WebauthnError::AttestationError(
373            "x5c is not a CBOR array".to_string(),
374        )),
375    }
376}
377
378// ---------------------------------------------------------------------------
379// Verification entry point
380// ---------------------------------------------------------------------------
381
382/// Verify an attestation statement for a registration ceremony.
383///
384/// Dispatches on the parsed format (see the module docs for the per-format
385/// verification procedures) and returns the attestation result on success.
386///
387/// Parameters:
388///
389/// - `fmt`: the raw `fmt` string from the attestation object.
390/// - `att_stmt`: the raw CBOR `attStmt` value (`None` if absent).
391/// - `auth_data`: the raw authenticator data bytes.
392/// - `client_data_hash`: SHA-256 of the raw `clientDataJSON`.
393/// - `credential_id`: the raw credential ID from attested credential data.
394/// - `credential_alg` / `credential_key`: the COSE algorithm and parsed
395///   public key of the *new credential* (from attested credential data).
396/// - `aaguid`: the 16-byte AAGUID from attested credential data.
397/// - `policy`: the caller's trust-anchor / unknown-format policy.
398///
399/// # Requirements
400/// REQ-WA-119, REQ-WA-120, REQ-WA-121, REQ-WA-122, REQ-WA-123, REQ-WA-124,
401/// REQ-WA-126, REQ-WA-127, REQ-WA-128, REQ-WA-129, REQ-WA-130
402#[allow(clippy::too_many_arguments)]
403pub fn verify_attestation(
404    fmt: &str,
405    att_stmt: Option<&Value>,
406    auth_data: &[u8],
407    client_data_hash: &[u8],
408    credential_id: &[u8],
409    credential_alg: i32,
410    credential_key: &CosePublicKey,
411    aaguid: [u8; 16],
412    policy: &AttestationPolicy,
413) -> Result<AttestationResult, WebauthnError> {
414    let format = AttestationFormat::from_fmt(fmt);
415
416    // Unknown format: reject unless the caller opted in (REQ-WA-126).
417    if format == AttestationFormat::Unknown {
418        if policy.allow_unknown_formats {
419            return Ok(AttestationResult {
420                format,
421                aaguid: Some(aaguid),
422                trust_level: TrustLevel::None,
423                warnings: vec![warnings::UNVERIFIED_FORMAT],
424            });
425        }
426        return Err(WebauthnError::AttestationError(format!(
427            "unsupported attestation format: '{fmt}'"
428        )));
429    }
430
431    let statement = parse_statement(&format, att_stmt)?;
432
433    match statement {
434        AttestationStatement::None => verify_none_statement(aaguid),
435        AttestationStatement::Packed { alg, sig, x5c } => {
436            if x5c.is_empty() {
437                verify_packed_self(
438                    &alg,
439                    &sig,
440                    auth_data,
441                    client_data_hash,
442                    credential_alg,
443                    credential_key,
444                    aaguid,
445                )
446            } else {
447                verify_packed_x5c(
448                    &alg,
449                    &sig,
450                    auth_data,
451                    client_data_hash,
452                    &x5c,
453                    aaguid,
454                    policy,
455                )
456            }
457        }
458        AttestationStatement::FidoU2f { sig, cert } => verify_fido_u2f(
459            &sig,
460            &cert,
461            auth_data,
462            client_data_hash,
463            credential_id,
464            credential_alg,
465            credential_key,
466            aaguid,
467            policy,
468        ),
469    }
470}
471
472/// `none` format: the statement itself was already checked to be an empty
473/// map by [`parse_statement`] — the registration ceremony establishes key
474/// possession and nothing more.
475///
476/// # Requirements
477/// REQ-WA-119, REQ-WA-128
478fn verify_none_statement(aaguid: [u8; 16]) -> Result<AttestationResult, WebauthnError> {
479    Ok(AttestationResult {
480        format: AttestationFormat::None,
481        aaguid: Some(aaguid),
482        trust_level: TrustLevel::None,
483        warnings: Vec::new(),
484    })
485}
486
487/// Packed self-attestation (no x5c): the signature over
488/// `authData || clientDataHash` MUST verify with the credential public key
489/// and the statement `alg` MUST match the credential key's algorithm.
490///
491/// # Requirements
492/// REQ-WA-120, REQ-WA-127
493fn verify_packed_self(
494    alg: &i32,
495    sig: &[u8],
496    auth_data: &[u8],
497    client_data_hash: &[u8],
498    credential_alg: i32,
499    credential_key: &CosePublicKey,
500    aaguid: [u8; 16],
501) -> Result<AttestationResult, WebauthnError> {
502    if *alg != credential_alg {
503        return Err(WebauthnError::AttestationError(format!(
504            "packed self-attestation alg {alg} does not match credential key alg {credential_alg}"
505        )));
506    }
507
508    let signed = signed_data(auth_data, client_data_hash);
509    // Cross-checks key type vs alg and verifies with ring (constant-time).
510    verify_cose_signature(*alg, credential_key, &signed, sig)?;
511
512    Ok(AttestationResult {
513        format: AttestationFormat::Packed,
514        aaguid: Some(aaguid),
515        trust_level: TrustLevel::SelfAttested,
516        warnings: Vec::new(),
517    })
518}
519
520/// Packed basic/AttCA attestation (x5c present): the signature MUST verify
521/// with the first certificate's public key, the statement `alg` MUST match
522/// the certificate key type, and the chain MUST satisfy the policy.
523///
524/// # Requirements
525/// REQ-WA-121, REQ-WA-122, REQ-WA-123, REQ-WA-125, REQ-WA-127
526fn verify_packed_x5c(
527    alg: &i32,
528    sig: &[u8],
529    auth_data: &[u8],
530    client_data_hash: &[u8],
531    x5c: &[Vec<u8>],
532    aaguid: [u8; 16],
533    policy: &AttestationPolicy,
534) -> Result<AttestationResult, WebauthnError> {
535    // The statement signs with the attestation certificate's key; its alg
536    // must be one of the algorithms this crate verifies at all.
537    if *alg != COSE_ALG_ES256 && *alg != COSE_ALG_RS256 {
538        return Err(WebauthnError::UnsupportedAlgorithm(*alg));
539    }
540
541    let leaf = Cert::from_der(x5c.first().ok_or_else(|| {
542        WebauthnError::AttestationError("packed attStmt has an empty x5c chain".to_string())
543    })?)?;
544
545    // Statement alg vs certificate key type cross-check (fail closed on
546    // mismatch, mirroring REQ-WA-107 semantics for COSE keys).
547    let expected = match *alg {
548        COSE_ALG_ES256 => KeyKind::Ec2,
549        _ => KeyKind::Rsa,
550    };
551    if leaf.kind != expected {
552        return Err(WebauthnError::AttestationError(
553            "packed attestation alg does not match the attestation certificate key type"
554                .to_string(),
555        ));
556    }
557
558    let signed = signed_data(auth_data, client_data_hash);
559    verify_signature_with_cert(&leaf, &signed, sig)?;
560
561    // AAGUID extension: if present, MUST match the attested credential data
562    // AAGUID (FIDO 2.1 §8.2.1).
563    check_aaguid_extension(&leaf, &aaguid)?;
564
565    let (trust_level, warnings) = verify_chain(x5c, policy)?;
566
567    Ok(AttestationResult {
568        format: AttestationFormat::Packed,
569        aaguid: Some(aaguid),
570        trust_level,
571        warnings,
572    })
573}
574
575/// FIDO U2F attestation (WebAuthn L2 §8.6): exactly one certificate; the
576/// signature MUST verify over
577/// `0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F`
578/// with the certificate's ES256 public key, where `publicKeyU2F` is the
579/// 65-byte `0x04 || X || Y` transport form of the credential's EC2 key.
580///
581/// # Requirements
582/// REQ-WA-124, REQ-WA-127, REQ-WA-129
583#[allow(clippy::indexing_slicing)] // rpIdHash slice is length-guarded above
584#[allow(clippy::too_many_arguments)]
585fn verify_fido_u2f(
586    sig: &[u8],
587    cert_der: &[u8],
588    auth_data: &[u8],
589    client_data_hash: &[u8],
590    credential_id: &[u8],
591    credential_alg: i32,
592    credential_key: &CosePublicKey,
593    aaguid: [u8; 16],
594    policy: &AttestationPolicy,
595) -> Result<AttestationResult, WebauthnError> {
596    // U2F credentials are always ES256 on P-256; anything else in the
597    // credential slot means this is not a U2F transport registration.
598    if credential_alg != COSE_ALG_ES256 {
599        return Err(WebauthnError::AttestationError(format!(
600            "fido-u2f registration requires an ES256 credential key, got alg {credential_alg}"
601        )));
602    }
603    let CosePublicKey::Ec2 { x, y } = credential_key else {
604        return Err(WebauthnError::AttestationError(
605            "fido-u2f registration requires an EC2 credential key".to_string(),
606        ));
607    };
608
609    let cert = Cert::from_der(cert_der)?;
610    if cert.kind != KeyKind::Ec2 {
611        return Err(WebauthnError::AttestationError(
612            "fido-u2f attestation certificate must carry an EC P-256 key".to_string(),
613        ));
614    }
615
616    // publicKeyU2F: 0x04 || X || Y (FIDO U2F Raw Message Formats §4.3).
617    if x.len() != 32 || y.len() != 32 {
618        return Err(WebauthnError::AttestationError(
619            "fido-u2f credential key coordinates must be 32 bytes (P-256)".to_string(),
620        ));
621    }
622    let mut public_key_u2f = Vec::with_capacity(65);
623    public_key_u2f.push(0x04);
624    public_key_u2f.extend_from_slice(x);
625    public_key_u2f.extend_from_slice(y);
626
627    if auth_data.len() < 32 {
628        return Err(WebauthnError::AttestationError(
629            "authenticator data too short for fido-u2f verification".to_string(),
630        ));
631    }
632    let mut verification_data = Vec::with_capacity(1 + 32 + 32 + credential_id.len() + 65);
633    verification_data.push(0x00);
634    // rpIdHash is the first 32 bytes of authData; length pre-checked above.
635    verification_data.extend_from_slice(&auth_data[..32]); // rpIdHash
636    verification_data.extend_from_slice(client_data_hash);
637    verification_data.extend_from_slice(credential_id);
638    verification_data.extend_from_slice(&public_key_u2f);
639
640    verify_signature_with_cert(&cert, &verification_data, sig)?;
641
642    let (trust_level, warnings) = verify_chain(&[cert_der.to_vec()], policy)?;
643
644    Ok(AttestationResult {
645        format: AttestationFormat::FidoU2f,
646        aaguid: Some(aaguid),
647        trust_level,
648        warnings,
649    })
650}
651
652// ---------------------------------------------------------------------------
653// X.509 certificate handling and chain verification
654// ---------------------------------------------------------------------------
655
656/// Key material kinds this crate can verify with (mirrors `CosePublicKey`).
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658enum KeyKind {
659    /// EC P-256 (`id-ecPublicKey`, ES256).
660    Ec2,
661    /// RSA (`rsaEncryption`, RS256, ≥2048-bit enforced by ring).
662    Rsa,
663}
664
665/// Certificate public key in the form `ring` consumes.
666enum CertPublicKey {
667    /// Uncompressed EC point `0x04 || X || Y`.
668    EcPoint(Vec<u8>),
669    /// SubjectPublicKeyInfo DER (ring RSA verifiers take SPKI).
670    SpkiDer(Vec<u8>),
671}
672
673/// A parsed, structurally validated X.509 certificate.
674struct Cert {
675    /// Raw TBS bytes (the signed content).
676    tbs: Vec<u8>,
677    /// DER signature value (without the BIT STRING wrapper).
678    signature: Vec<u8>,
679    /// Signature algorithm OID (DER bytes).
680    signature_alg_oid: Vec<u8>,
681    /// Verifier input for this certificate's own public key.
682    verifier_key: CertPublicKey,
683    /// Issuer name (raw DER, for chain linking).
684    issuer: Vec<u8>,
685    /// Subject name (raw DER, for chain linking).
686    subject: Vec<u8>,
687    /// This certificate's key kind.
688    kind: KeyKind,
689    /// Not-before / not-after (unix seconds).
690    not_before: i64,
691    not_after: i64,
692    /// Raw `(oid, value)` extension list.
693    extensions: Vec<(Vec<u8>, Vec<u8>)>,
694}
695
696impl Cert {
697    /// Parse and structurally validate a DER certificate.
698    ///
699    /// Rejects: malformed DER, trailing bytes after the certificate,
700    /// non-v3 certificates, and unsupported public key algorithms.
701    ///
702    /// # Requirements
703    /// REQ-WA-122, REQ-WA-130, REQ-WA-132
704    fn from_der(der: &[u8]) -> Result<Self, WebauthnError> {
705        let (rem, parsed) = parse_x509_certificate(der).map_err(|e| {
706            WebauthnError::AttestationError(format!("attestation certificate parse error: {e}"))
707        })?;
708        if !rem.is_empty() {
709            return Err(WebauthnError::AttestationError(
710                "trailing bytes after attestation certificate".to_string(),
711            ));
712        }
713
714        let tbs = &parsed.tbs_certificate;
715        if tbs.version() != X509Version::V3 {
716            return Err(WebauthnError::AttestationError(
717                "attestation certificate must be X.509 v3".to_string(),
718            ));
719        }
720
721        let spki = tbs.public_key();
722        let spki_alg = spki.algorithm.algorithm.as_bytes();
723        let (verifier_key, kind) = if spki_alg == OID_ALG_EC_PUBLIC_KEY {
724            let point = spki.subject_public_key.data.to_vec();
725            if point.first() != Some(&0x04) || point.len() != 65 {
726                return Err(WebauthnError::AttestationError(
727                    "attestation certificate EC key is not an uncompressed P-256 point".to_string(),
728                ));
729            }
730            (CertPublicKey::EcPoint(point), KeyKind::Ec2)
731        } else if spki_alg == OID_ALG_RSA_ENCRYPTION {
732            (CertPublicKey::SpkiDer(spki.raw.to_vec()), KeyKind::Rsa)
733        } else {
734            return Err(WebauthnError::AttestationError(
735                "attestation certificate uses an unsupported public key algorithm".to_string(),
736            ));
737        };
738
739        let extensions = tbs
740            .extensions()
741            .iter()
742            .map(|ext| (ext.oid.as_bytes().to_vec(), ext.value.to_vec()))
743            .collect();
744
745        Ok(Self {
746            tbs: tbs.as_ref().to_vec(),
747            signature: parsed.signature_value.data.to_vec(),
748            signature_alg_oid: parsed.signature_algorithm.algorithm.as_bytes().to_vec(),
749            verifier_key,
750            issuer: tbs.issuer().as_raw().to_vec(),
751            subject: tbs.subject().as_raw().to_vec(),
752            kind,
753            not_before: tbs.validity().not_before.timestamp(),
754            not_after: tbs.validity().not_after.timestamp(),
755            extensions,
756        })
757    }
758
759    /// Validity window contains `now` (unix seconds).
760    fn is_valid_at(&self, now: i64) -> bool {
761        self.not_before <= now && now <= self.not_after
762    }
763
764    /// Verify this certificate's own signature with the parent's key.
765    ///
766    /// Only `ecdsa-with-SHA256` (EC P-256 parent) and
767    /// `sha256WithRSAEncryption` (RSA parent) are accepted — anything else
768    /// fails closed.
769    ///
770    /// # Requirements
771    /// REQ-WA-122
772    fn verify_signed_by(&self, parent: &Cert) -> Result<(), WebauthnError> {
773        if self.signature_alg_oid == OID_SIG_ECDSA_WITH_SHA256 {
774            if parent.kind != KeyKind::Ec2 {
775                return Err(WebauthnError::AttestationError(
776                    "certificate signature algorithm does not match issuer key type".to_string(),
777                ));
778            }
779            let CertPublicKey::EcPoint(point) = &parent.verifier_key else {
780                return Err(WebauthnError::AttestationError(
781                    "issuer key is not an EC point".to_string(),
782                ));
783            };
784            let verifier = ring::signature::UnparsedPublicKey::new(
785                &ring::signature::ECDSA_P256_SHA256_FIXED,
786                point,
787            );
788            verifier
789                .verify(&self.tbs, &self.signature)
790                .map_err(|_| WebauthnError::SignatureVerificationFailed)
791        } else if self.signature_alg_oid == OID_SIG_SHA256_WITH_RSA {
792            if parent.kind != KeyKind::Rsa {
793                return Err(WebauthnError::AttestationError(
794                    "certificate signature algorithm does not match issuer key type".to_string(),
795                ));
796            }
797            let CertPublicKey::SpkiDer(spki) = &parent.verifier_key else {
798                return Err(WebauthnError::AttestationError(
799                    "issuer key is not an RSA SPKI".to_string(),
800                ));
801            };
802            let verifier = ring::signature::UnparsedPublicKey::new(
803                &ring::signature::RSA_PKCS1_2048_8192_SHA256,
804                spki,
805            );
806            verifier
807                .verify(&self.tbs, &self.signature)
808                .map_err(|_| WebauthnError::SignatureVerificationFailed)
809        } else {
810            Err(WebauthnError::AttestationError(
811                "certificate uses an unsupported signature algorithm (only ecdsa-with-SHA256 \
812                 and sha256WithRSAEncryption are accepted)"
813                    .to_string(),
814            ))
815        }
816    }
817
818    /// basicConstraints `cA` flag; `None` when the extension is absent.
819    fn is_ca(&self) -> Option<bool> {
820        let (_, value) = self
821            .extensions
822            .iter()
823            .find(|(oid, _)| oid == OID_EXT_BASIC_CONSTRAINTS)?;
824        parse_basic_constraints(value)
825    }
826}
827
828/// Parse a basicConstraints extension value:
829/// `SEQUENCE { BOOLEAN DEFAULT FALSE, INTEGER OPTIONAL }`.
830///
831/// Returns the `cA` flag — `false` when the BOOLEAN is omitted (DER
832/// defaulting) or when only a pathLenConstraint INTEGER is present.
833fn parse_basic_constraints(value: &[u8]) -> Option<bool> {
834    let content = der_content(value, 0x30)?;
835    if content.first() == Some(&0x01) && content.len() >= 3 {
836        // Explicit BOOLEAN: tag (0x01), length (0x01), value.
837        return Some(content.get(2).copied() == Some(0xFF));
838    }
839    Some(false)
840}
841
842/// Return the content octets of a DER TLV with the given tag byte, strictly
843/// validating that the declared length matches the available bytes.
844fn der_content(bytes: &[u8], tag: u8) -> Option<&[u8]> {
845    if bytes.first() != Some(&tag) {
846        return None;
847    }
848    let first = *bytes.get(1)?;
849    if first < 0x80 {
850        bytes.get(2..2 + first as usize)
851    } else {
852        let n = (first & 0x7F) as usize;
853        if n == 0 || n > 4 {
854            return None;
855        }
856        let mut len = 0usize;
857        for b in bytes.get(2..2 + n)? {
858            len = (len << 8) | (*b as usize);
859        }
860        bytes.get(2 + n..2 + n + len)
861    }
862}
863
864/// Verify the AAGUID extension (`id-fido-gen-ce-aaguid`) on the leaf, when
865/// present: its value is a DER OCTET STRING wrapping exactly 16 bytes that
866/// MUST equal the attested credential data AAGUID.
867///
868/// # Requirements
869/// REQ-WA-125
870fn check_aaguid_extension(cert: &Cert, aaguid: &[u8; 16]) -> Result<(), WebauthnError> {
871    let Some((_, value)) = cert
872        .extensions
873        .iter()
874        .find(|(oid, _)| oid == OID_EXT_FIDO_GEN_CE_AAGUID)
875    else {
876        return Ok(()); // extension is optional
877    };
878
879    // extnValue content: OCTET STRING (tag 0x04) of length 16 wrapping the
880    // AAGUID (FIDO 2.1 §8.2.1).
881    let bad = || {
882        WebauthnError::AttestationError("id-fido-gen-ce-aaguid extension is malformed".to_string())
883    };
884    let content = der_content(value, 0x04).ok_or_else(bad)?;
885    if content.len() != 16 {
886        return Err(bad());
887    }
888    if content != aaguid.as_slice() {
889        return Err(WebauthnError::AttestationError(
890            "id-fido-gen-ce-aaguid extension does not match the attested credential data AAGUID"
891                .to_string(),
892        ));
893    }
894    Ok(())
895}
896
897/// Verify the x5c chain and return the resulting trust level.
898///
899/// Checks, in order (fail closed):
900///
901/// 1. Every certificate parses and is X.509 v3 ([`Cert::from_der`]).
902/// 2. Every certificate is within its validity window *now* (REQ-WA-130).
903/// 3. Each certificate is signed by the next with a supported algorithm;
904///    issuer/subject names link byte-exactly; intermediates declare
905///    basicConstraints `cA = true` (REQ-WA-122).
906/// 4. Anchor policy (REQ-WA-123):
907///    - anchors configured → the chain root must equal an anchor, or be
908///      signed by one, → [`TrustLevel::AttCa`]; otherwise reject;
909///    - no anchors → the chain root must be self-signed (self-signature
910///      verified) → [`TrustLevel::BasicAtt`] +
911///      [`warnings::UNANCHORED_CHAIN`].
912fn verify_chain(
913    x5c: &[Vec<u8>],
914    policy: &AttestationPolicy,
915) -> Result<(TrustLevel, Vec<&'static str>), WebauthnError> {
916    let now = SystemTime::now()
917        .duration_since(UNIX_EPOCH)
918        .map(|d| d.as_secs() as i64)
919        .map_err(|_| {
920            WebauthnError::AttestationError("system clock before unix epoch".to_string())
921        })?;
922
923    let mut certs = Vec::with_capacity(x5c.len());
924    for der in x5c {
925        let cert = Cert::from_der(der)?;
926        if !cert.is_valid_at(now) {
927            return Err(WebauthnError::AttestationError(
928                "attestation certificate is expired or not yet valid".to_string(),
929            ));
930        }
931        certs.push(cert);
932    }
933
934    // Signature chain: leaf → intermediate(s) → root. `windows(2)` always
935    // yields exactly-2 slices, so the destructuring below is infallible;
936    // allow the lint rather than an unreachable expect.
937    #[allow(clippy::indexing_slicing)]
938    for pair in certs.windows(2) {
939        let (child, parent) = (&pair[0], &pair[1]);
940        if child.issuer != parent.subject {
941            return Err(WebauthnError::AttestationError(
942                "attestation chain issuer/subject names do not link".to_string(),
943            ));
944        }
945        child.verify_signed_by(parent)?;
946    }
947
948    // Non-leaf certificates must declare CA: true (basicConstraints).
949    for parent in certs.iter().skip(1) {
950        if parent.is_ca() != Some(true) {
951            return Err(WebauthnError::AttestationError(
952                "attestation chain intermediate lacks basicConstraints cA=true".to_string(),
953            ));
954        }
955    }
956
957    let root = certs
958        .last()
959        .ok_or_else(|| WebauthnError::AttestationError("attestation chain is empty".to_string()))?;
960    let root_der = x5c
961        .last()
962        .ok_or_else(|| WebauthnError::AttestationError("attestation chain is empty".to_string()))?;
963
964    // Anchor policy (REQ-WA-123).
965    for anchor in &policy.trust_anchors {
966        // Case 1: the chain carries the anchor certificate itself.
967        if root_der == anchor {
968            return Ok((TrustLevel::AttCa, Vec::new()));
969        }
970        // Case 2: the chain root is issued directly by the anchor.
971        let anchor_cert = Cert::from_der(anchor)?;
972        if root.issuer == anchor_cert.subject && root.verify_signed_by(&anchor_cert).is_ok() {
973            return Ok((TrustLevel::AttCa, Vec::new()));
974        }
975    }
976    if !policy.trust_anchors.is_empty() {
977        return Err(WebauthnError::AttestationError(
978            "attestation chain does not terminate at a configured trust anchor".to_string(),
979        ));
980    }
981
982    // No anchors configured: accept only a chain rooted at a self-signed
983    // certificate (self-signature verified), and flag it.
984    let self_signed = root.issuer == root.subject && root.verify_signed_by(root).is_ok();
985    if !self_signed {
986        return Err(WebauthnError::AttestationError(
987            "attestation chain root is not self-signed and no trust anchors are configured"
988                .to_string(),
989        ));
990    }
991
992    Ok((TrustLevel::BasicAtt, vec![warnings::UNANCHORED_CHAIN]))
993}
994
995// ---------------------------------------------------------------------------
996// Shared helpers
997// ---------------------------------------------------------------------------
998
999/// The bytes covered by a WebAuthn registration attestation signature:
1000/// `authenticatorData || SHA-256(clientDataJSON)` (the caller supplies the
1001/// precomputed hash).
1002fn signed_data(auth_data: &[u8], client_data_hash: &[u8]) -> Vec<u8> {
1003    let mut signed = Vec::with_capacity(auth_data.len() + client_data_hash.len());
1004    signed.extend_from_slice(auth_data);
1005    signed.extend_from_slice(client_data_hash);
1006    signed
1007}
1008
1009/// Verify `data`/`sig` with an X.509 certificate's public key via `ring`.
1010///
1011/// Only ES256 (EC P-256) and RS256 (RSA PKCS#1 v1.5, ≥2048-bit) are
1012/// supported, matching this crate's COSE algorithm support.
1013///
1014/// # Requirements
1015/// REQ-WA-121, REQ-WA-124, REQ-WA-127
1016fn verify_signature_with_cert(cert: &Cert, data: &[u8], sig: &[u8]) -> Result<(), WebauthnError> {
1017    match &cert.verifier_key {
1018        CertPublicKey::EcPoint(point) => {
1019            let verifier = ring::signature::UnparsedPublicKey::new(
1020                &ring::signature::ECDSA_P256_SHA256_FIXED,
1021                point,
1022            );
1023            verifier
1024                .verify(data, sig)
1025                .map_err(|_| WebauthnError::SignatureVerificationFailed)
1026        }
1027        CertPublicKey::SpkiDer(spki) => {
1028            let verifier = ring::signature::UnparsedPublicKey::new(
1029                &ring::signature::RSA_PKCS1_2048_8192_SHA256,
1030                spki,
1031            );
1032            verifier
1033                .verify(data, sig)
1034                .map_err(|_| WebauthnError::SignatureVerificationFailed)
1035        }
1036    }
1037}