Skip to main content

webauthn/
registration.rs

1//! Registration ceremony — W3C WebAuthn §7.1.
2//!
3//! The registration ceremony is how a user's authenticator creates a new
4//! credential and proves it to the relying party. The relying party verifies:
5//!
6//! 1. The response was produced for *this* challenge and *this* origin.
7//! 2. The authenticator data is bound to *this* RP ID.
8//! 3. The public key is valid and can be stored for future authentication.
9//!
10//! Spec: <https://www.w3.org/TR/webauthn-2/#sctn-registering-a-new-credential>
11
12use ciborium::value::Value;
13use std::collections::HashSet;
14use std::sync::{Arc, Mutex};
15use std::time::SystemTime;
16
17use crate::algorithm::{COSE_EDDSA, COSE_ES256, COSE_ES384, COSE_RS256};
18use crate::attestation;
19use crate::authenticator_data::{self, CoseKey};
20use crate::challenge::CHALLENGE_MAX_AGE_SECS;
21use crate::client_data;
22use crate::credential::{
23    AuthenticatorAttestationResponse, Challenge, Credential, PublicKey, RegistrationResult,
24};
25use crate::crypto::sha256;
26use crate::error::{Result, WebAuthnError};
27
28// ─── RelyingParty ─────────────────────────────────────────────────────────────
29
30/// The relying party — your server application.
31///
32/// `RelyingParty` is the main entry point for ceremony verification. It is
33/// stateless with respect to credentials; callers pass in the data and receive
34/// result types back. This keeps the library storage-agnostic.
35///
36/// Create one instance per application configuration and reuse it across
37/// ceremonies. `RelyingParty` is `Clone`, so it can be shared via `Arc` in
38/// an async context.
39///
40/// # Example
41///
42/// ```rust,no_run
43/// use webauthn::RelyingParty;
44///
45/// // Single origin — typical production setup.
46/// let rp = RelyingParty::new("example.com", "https://example.com", "My Service");
47///
48/// // Multiple origins — e.g. prod + local dev in one instance.
49/// let rp = RelyingParty::with_origins(
50///     "example.com",
51///     ["https://example.com", "http://localhost:8080"],
52///     "My Service",
53/// );
54/// ```
55#[derive(Debug, Clone)]
56pub struct RelyingParty {
57    /// Relying party ID, e.g. `"example.com"`.
58    ///
59    /// Must match the `rpId` used in the browser's
60    /// `navigator.credentials.create()` / `get()` call options.
61    pub id: String,
62
63    /// The set of origins this RP accepts, e.g. `["https://example.com"]`.
64    ///
65    /// Each entry must match `window.location.origin` exactly — scheme, host,
66    /// and port all matter. A client-supplied origin is accepted if it equals
67    /// any entry in this list.
68    pub allowed_origins: Vec<String>,
69
70    /// Human-readable name shown to users, e.g. `"My Service"`.
71    pub name: String,
72
73    /// Whether the UV (User Verification) flag must be set in every
74    /// authentication assertion. Defaults to `false`.
75    ///
76    /// Set to `true` when your threat model requires the authenticator to
77    /// verify the user's identity (PIN, biometric, pattern) on every sign-in.
78    /// See [`RelyingParty::require_user_verification`] to enable this at
79    /// construction time using the builder pattern.
80    pub require_user_verification: bool,
81
82    /// Whether to reject `clientDataJSON` that contains `crossOrigin: true`.
83    /// Defaults to `false`.
84    ///
85    /// A cross-origin credential use occurs when the WebAuthn call is made
86    /// from an iframe whose origin differs from the top-level page. When your
87    /// application never embeds WebAuthn in an iframe, set this to `true` to
88    /// close that attack surface (§7.1 step 10 / §7.2 step 12).
89    /// See [`RelyingParty::reject_cross_origin`] to enable this using the
90    /// builder pattern.
91    pub reject_cross_origin: bool,
92
93    /// COSE algorithm identifiers this RP accepts at registration time.
94    ///
95    /// When non-empty, `verify_registration` returns
96    /// [`crate::error::WebAuthnError::UnsupportedAlgorithm`] if the credential's
97    /// algorithm is not in this list. An empty list (the default) accepts any
98    /// algorithm the library supports (ES256, EdDSA, RS256).
99    ///
100    /// Use [`RelyingParty::allowed_algorithms`] to set this at construction time.
101    pub allowed_algorithms: Vec<i64>,
102
103    /// Whether to reject credentials that are not backup-eligible (BE flag not set).
104    /// Defaults to `false`.
105    ///
106    /// Set to `true` for consumer passkey deployments that require cross-device
107    /// sign-in via platform sync services (iCloud Keychain, Google Password Manager).
108    /// See [`RelyingParty::require_backup_eligible`] to enable via the builder.
109    pub require_backup_eligible: bool,
110
111    /// Whether to reject credentials that are backup-eligible (BE flag is set).
112    /// Defaults to `false`.
113    ///
114    /// Set to `true` for high-security environments (banking, SSH) that require
115    /// hardware-bound keys that cannot leave the device.
116    /// See [`RelyingParty::reject_backup_eligible`] to enable via the builder.
117    pub reject_backup_eligible: bool,
118
119    /// DER-encoded root CA certificates used to verify the `x5c` attestation
120    /// chain returned by the authenticator.
121    ///
122    /// When non-empty, `verify_registration` walks the chain returned in `x5c`
123    /// and checks that its root is signed by one of these anchors. A successful
124    /// check upgrades the result to [`crate::credential::AttestationType::BasicVerified`].
125    ///
126    /// When empty (the default), the chain order is still validated (each
127    /// certificate must be signed by the next), but the root is not checked
128    /// against any CA set and `AttestationType::Basic` is returned instead.
129    ///
130    /// Use [`RelyingParty::trust_anchors`] to set this at construction time.
131    pub trust_anchors: Vec<Vec<u8>>,
132
133    /// Opt-in set of challenge bytes already consumed by a completed ceremony.
134    ///
135    /// `None` when single-use enforcement is disabled (the default). Enable
136    /// via [`RelyingParty::enforce_single_use_challenges`].
137    ///
138    /// The `Arc` lets `Clone`d instances share the same tracking set so all
139    /// ceremony paths that use copies of the same `RelyingParty` (e.g. behind
140    /// an `Arc<RelyingParty>` in an async web handler) enforce the policy
141    /// collectively rather than independently.
142    pub used_challenges: Option<Arc<Mutex<HashSet<Vec<u8>>>>>,
143}
144
145impl RelyingParty {
146    /// Create a new `RelyingParty` that accepts a single origin.
147    ///
148    /// # Arguments
149    /// * `id`     — Relying party ID, e.g. `"example.com"`.
150    /// * `origin` — Full app origin, e.g. `"https://example.com"`.
151    /// * `name`   — Human-readable service name.
152    pub fn new(id: &str, origin: &str, name: &str) -> Self {
153        Self {
154            id: id.to_string(),
155            allowed_origins: vec![origin.to_string()],
156            name: name.to_string(),
157            require_user_verification: false,
158            reject_cross_origin: false,
159            allowed_algorithms: vec![],
160            require_backup_eligible: false,
161            reject_backup_eligible: false,
162            trust_anchors: vec![],
163            used_challenges: None,
164        }
165    }
166
167    /// Create a new `RelyingParty` that accepts multiple origins.
168    ///
169    /// Use this when your app is served from more than one origin — for example,
170    /// `https://example.com` in production and `http://localhost:8080` in
171    /// development — and you want a single `RelyingParty` instance to handle
172    /// both environments.
173    ///
174    /// # Arguments
175    /// * `id`      — Relying party ID, e.g. `"example.com"`.
176    /// * `origins` — Iterator of accepted origins.
177    /// * `name`    — Human-readable service name.
178    pub fn with_origins(
179        id: &str,
180        origins: impl IntoIterator<Item = impl Into<String>>,
181        name: &str,
182    ) -> Self {
183        Self {
184            id: id.to_string(),
185            allowed_origins: origins.into_iter().map(Into::into).collect(),
186            name: name.to_string(),
187            require_user_verification: false,
188            reject_cross_origin: false,
189            allowed_algorithms: vec![],
190            require_backup_eligible: false,
191            reject_backup_eligible: false,
192            trust_anchors: vec![],
193            used_challenges: None,
194        }
195    }
196
197    /// Require the UV (User Verification) flag on every authentication assertion.
198    ///
199    /// When `true`, `verify_authentication` returns
200    /// [`crate::error::WebAuthnError::UserNotVerified`] if the authenticator's
201    /// `UV` bit is not set — meaning the user was not verified via PIN,
202    /// biometric, or another local gesture.
203    ///
204    /// # Example
205    ///
206    /// ```rust,no_run
207    /// use webauthn::RelyingParty;
208    ///
209    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
210    ///     .require_user_verification(true);
211    /// ```
212    pub fn require_user_verification(mut self, required: bool) -> Self {
213        self.require_user_verification = required;
214        self
215    }
216
217    /// Reject `clientDataJSON` that contains `crossOrigin: true` (§7.1 step 10).
218    ///
219    /// When `true`, any registration or authentication response from a
220    /// cross-origin iframe is rejected with
221    /// [`crate::error::WebAuthnError::CrossOriginNotAllowed`].
222    ///
223    /// # Example
224    ///
225    /// ```rust,no_run
226    /// use webauthn::RelyingParty;
227    ///
228    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
229    ///     .reject_cross_origin(true);
230    /// ```
231    pub fn reject_cross_origin(mut self, reject: bool) -> Self {
232        self.reject_cross_origin = reject;
233        self
234    }
235
236    /// Restrict which COSE algorithms this RP accepts at registration time.
237    ///
238    /// When the list is non-empty, `verify_registration` rejects any credential
239    /// whose algorithm is not in this list with
240    /// [`crate::error::WebAuthnError::UnsupportedAlgorithm`].
241    /// An empty list (the default) accepts ES256, EdDSA, and RS256.
242    ///
243    /// # Example
244    ///
245    /// ```rust,no_run
246    /// use webauthn::{RelyingParty, COSE_ES256};
247    ///
248    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
249    ///     .allowed_algorithms([COSE_ES256]);
250    /// ```
251    pub fn allowed_algorithms(mut self, algs: impl IntoIterator<Item = i64>) -> Self {
252        self.allowed_algorithms = algs.into_iter().collect();
253        self
254    }
255
256    /// Require that credentials are backup-eligible (BE flag must be set).
257    ///
258    /// When `true`, `verify_registration` and `verify_authentication` return
259    /// [`crate::error::WebAuthnError::BackupEligibilityRequired`] for any
260    /// credential whose BE flag is not set.
261    ///
262    /// # Example
263    ///
264    /// ```rust,no_run
265    /// use webauthn::RelyingParty;
266    ///
267    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
268    ///     .require_backup_eligible(true);
269    /// ```
270    pub fn require_backup_eligible(mut self, required: bool) -> Self {
271        self.require_backup_eligible = required;
272        self
273    }
274
275    /// Reject credentials that are backup-eligible (BE flag must not be set).
276    ///
277    /// When `true`, `verify_registration` and `verify_authentication` return
278    /// [`crate::error::WebAuthnError::BackupEligibleNotAllowed`] for any
279    /// credential whose BE flag is set.
280    ///
281    /// # Example
282    ///
283    /// ```rust,no_run
284    /// use webauthn::RelyingParty;
285    ///
286    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
287    ///     .reject_backup_eligible(true);
288    /// ```
289    pub fn reject_backup_eligible(mut self, reject: bool) -> Self {
290        self.reject_backup_eligible = reject;
291        self
292    }
293
294    /// Provide DER-encoded root CA certificates used to verify the `x5c` chain.
295    ///
296    /// When a non-empty set is supplied, `verify_registration` verifies that
297    /// the root of the attestation certificate chain is signed by one of these
298    /// anchors and returns [`crate::credential::AttestationType::BasicVerified`]
299    /// on success, or [`crate::error::WebAuthnError::AttestationRootUntrusted`]
300    /// on failure. Chain structure (each cert signed by the next) is always
301    /// checked regardless of this setting.
302    ///
303    /// # Example
304    ///
305    /// ```rust,no_run
306    /// use webauthn::RelyingParty;
307    ///
308    /// let fido_root_der: Vec<u8> = std::fs::read("fido-root.der").unwrap();
309    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
310    ///     .trust_anchors([fido_root_der]);
311    /// ```
312    pub fn trust_anchors(mut self, roots: impl IntoIterator<Item = Vec<u8>>) -> Self {
313        self.trust_anchors = roots.into_iter().collect();
314        self
315    }
316
317    /// Opt in to server-side single-use challenge enforcement.
318    ///
319    /// When `true`, the library maintains an internal set of challenge bytes
320    /// that have already been processed. After the challenge passes the normal
321    /// expiry and binding checks, it is looked up in this set:
322    ///
323    /// - If already present → the ceremony fails with
324    ///   [`crate::error::WebAuthnError::ChallengePreviouslyUsed`].
325    /// - If absent → it is inserted and the ceremony continues.
326    ///
327    /// A challenge is consumed even if later verification steps (e.g.
328    /// signature check) fail, so a failed ceremony with a valid challenge
329    /// cannot be retried with the same challenge bytes.
330    ///
331    /// The tracking set is shared across `Clone`d instances of this
332    /// `RelyingParty` via `Arc`, so all ceremony paths using copies of the
333    /// same instance enforce the policy collectively.
334    ///
335    /// When `false` (the default), single-use enforcement is the caller's
336    /// responsibility — track issued challenges in your session store and
337    /// delete each one after it is presented to a ceremony.
338    ///
339    /// # Example
340    ///
341    /// ```rust,no_run
342    /// use webauthn::RelyingParty;
343    ///
344    /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
345    ///     .enforce_single_use_challenges(true);
346    /// ```
347    pub fn enforce_single_use_challenges(mut self, enforce: bool) -> Self {
348        self.used_challenges = if enforce {
349            Some(Arc::new(Mutex::new(HashSet::new())))
350        } else {
351            None
352        };
353        self
354    }
355
356    /// Verify a registration ceremony response (W3C WebAuthn §7.1).
357    ///
358    /// Call this after the client returns an `AuthenticatorAttestationResponse`.
359    /// On `Ok`, persist `result.credential` in your database. On `Err`, reject
360    /// the registration and return an appropriate error to the client.
361    ///
362    /// # Arguments
363    /// * `challenge` — The challenge you issued for this ceremony.
364    /// * `response`  — The raw attestation response from the authenticator.
365    /// * `user_id`   — Your application's identifier for this user.
366    ///
367    /// # Errors
368    /// Returns a [`WebAuthnError`] variant indicating exactly which
369    /// verification step failed.
370    pub fn verify_registration(
371        &self,
372        challenge: &Challenge,
373        response: &AuthenticatorAttestationResponse,
374        user_id: &[u8],
375    ) -> Result<RegistrationResult> {
376        verify_registration_inner(self, challenge, response, user_id)
377    }
378}
379
380// ─── Ceremony implementation ──────────────────────────────────────────────────
381
382fn verify_registration_inner(
383    rp: &RelyingParty,
384    challenge: &Challenge,
385    response: &AuthenticatorAttestationResponse,
386    user_id: &[u8],
387) -> Result<RegistrationResult> {
388    // ── Pre-check: challenge expiry ───────────────────────────────────────────
389    // The spec does not specify where to check this, but rejecting an expired
390    // challenge before doing any crypto is the most efficient ordering.
391    if challenge.is_expired(CHALLENGE_MAX_AGE_SECS) {
392        return Err(WebAuthnError::ChallengeExpired);
393    }
394
395    // ── §7.1 step 5 ───────────────────────────────────────────────────────────
396    // Let JSONtext be the UTF-8 decoding of response.clientDataJSON.
397    // response.client_data_json already holds the raw bytes; validate UTF-8.
398    let _ = std::str::from_utf8(&response.client_data_json).map_err(|_| {
399        WebAuthnError::InvalidClientData("clientDataJSON is not valid UTF-8".to_string())
400    })?;
401
402    // ── §7.1 step 6 ───────────────────────────────────────────────────────────
403    // Parse clientDataJSON bytes into a CollectedClientData structure.
404    let parsed_cd = client_data::parse_client_data(&response.client_data_json)?;
405
406    // ── §7.1 step 7 ───────────────────────────────────────────────────────────
407    // Verify that C.type equals "webauthn.create".
408    // ── §7.1 step 8 ───────────────────────────────────────────────────────────
409    // Verify that C.challenge equals the issued challenge.
410    // ── §7.1 step 9 ───────────────────────────────────────────────────────────
411    // Verify that C.origin matches the relying party's origin.
412    // ── §7.1 step 10 ──────────────────────────────────────────────────────────
413    // If reject_cross_origin is set, reject crossOrigin: true.
414    client_data::validate_client_data(
415        &parsed_cd,
416        "webauthn.create",
417        &challenge.bytes,
418        &rp.allowed_origins,
419        rp.reject_cross_origin,
420    )?;
421
422    // ── Single-use challenge enforcement (opt-in) ─────────────────────────────
423    // After the challenge has passed the expiry and binding checks above,
424    // record it in the used-challenge set if enforcement is enabled. A
425    // challenge is consumed even if subsequent steps fail — this prevents
426    // retrying the same challenge with a corrected payload.
427    if let Some(ref used) = rp.used_challenges {
428        let mut set = used
429            .lock()
430            .expect("used_challenges mutex is poisoned — a previous ceremony panicked");
431        if set.contains(&challenge.bytes) {
432            return Err(WebAuthnError::ChallengePreviouslyUsed);
433        }
434        set.insert(challenge.bytes.clone());
435    }
436
437    // ── §7.1 step 11 ──────────────────────────────────────────────────────────
438    // Let hash be SHA-256(clientDataJSON bytes).
439    let client_data_hash = sha256(&parsed_cd.raw_json);
440
441    // ── §7.1 step 12 ──────────────────────────────────────────────────────────
442    // Perform CBOR decoding on the attestationObject.
443    let (fmt, auth_data_bytes, att_stmt) = parse_attestation_object(&response.attestation_object)?;
444
445    // ── §7.1 step 9 (authData) ────────────────────────────────────────────────
446    // Parse the raw authenticator data bytes into a typed structure.
447    let auth_data = authenticator_data::parse_authenticator_data(&auth_data_bytes)?;
448
449    // ── §7.1 step 13 ──────────────────────────────────────────────────────────
450    // Verify that the rpIdHash in authData is SHA-256(rp.id).
451    let expected_rp_id_hash = sha256(rp.id.as_bytes());
452    if auth_data.rp_id_hash != expected_rp_id_hash {
453        return Err(WebAuthnError::RpIdHashMismatch);
454    }
455
456    // ── §7.1 step 14 ──────────────────────────────────────────────────────────
457    // Verify that the User Present (UP) flag is set.
458    // A registration without UP is invalid — the user must have been present.
459    if !auth_data.flags.user_present {
460        return Err(WebAuthnError::UserNotPresent);
461    }
462
463    // ── §7.1 step 18 ──────────────────────────────────────────────────────────
464    // Apply the RP's backup eligibility policy.
465    if rp.reject_backup_eligible && auth_data.flags.backup_eligible {
466        return Err(WebAuthnError::BackupEligibleNotAllowed);
467    }
468    if rp.require_backup_eligible && !auth_data.flags.backup_eligible {
469        return Err(WebAuthnError::BackupEligibilityRequired);
470    }
471
472    // ── §7.1 step 16 ──────────────────────────────────────────────────────────
473    // Verify that the AT (Attested Credential Data) flag is set.
474    // If absent, the authenticator did not include a public key — unusable.
475    let cred_data = auth_data.attested_credential_data.ok_or_else(|| {
476        WebAuthnError::InvalidAuthenticatorData(
477            "attested credential data (AT flag) is required for registration".to_string(),
478        )
479    })?;
480
481    // ── §7.1 step 17 ──────────────────────────────────────────────────────────
482    // Extract the COSE public key and convert it to a typed PublicKey.
483    // The parser already validated kty, crv (for EC2), and alg (for RSA).
484    // Here we additionally check that EC2 keys use ES256 (the only EC2 algorithm
485    // we support), and reject any combination we cannot verify.
486    //
487    // Read the algorithm integer first (by reference) so we can check the RP's
488    // allowlist before consuming the CoseKey.
489    let cose_alg: i64 = match &cred_data.public_key {
490        CoseKey::EC2 { alg, .. } => *alg,
491        CoseKey::OKP { alg, .. } => *alg,
492        CoseKey::RSA { .. } => COSE_RS256,
493    };
494
495    // §7.1 step 17 — reject the credential algorithm if not in the RP's allowlist.
496    if !rp.allowed_algorithms.is_empty() && !rp.allowed_algorithms.contains(&cose_alg) {
497        return Err(WebAuthnError::UnsupportedAlgorithm(cose_alg));
498    }
499
500    let public_key = match cred_data.public_key {
501        CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES256 => PublicKey::ES256 { x, y },
502        CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES384 => PublicKey::ES384 { x, y },
503        CoseKey::EC2 { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
504        CoseKey::OKP { alg, x, .. } if alg == COSE_EDDSA => PublicKey::EdDSA(x),
505        CoseKey::OKP { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
506        CoseKey::RSA { n, e, .. } => PublicKey::RS256 { n, e },
507    };
508
509    // ── §7.1 step 19 ──────────────────────────────────────────────────────────
510    // Verify the attestation statement. Pass the public key so packed
511    // self-attestation can verify the signature with the credential key.
512    // Pass credential_id for fido-u2f verificationData construction.
513    // Pass trust_anchors for x5c chain root verification (§7.1 step 22).
514    let attestation_type = attestation::verify(
515        &fmt,
516        &att_stmt,
517        &auth_data_bytes,
518        &client_data_hash,
519        &public_key,
520        &cred_data.credential_id,
521        &rp.trust_anchors,
522    )?;
523
524    let backup_eligible = auth_data.flags.backup_eligible;
525    let backup_state = auth_data.flags.backup_state;
526    let extensions = auth_data.extensions;
527
528    // ── §7.1 step 25 ──────────────────────────────────────────────────────────
529    // Build the Credential. The caller must persist this object.
530    // backup_eligible is stored so authentication can enforce BE immutability.
531    // backup_state reflects the credential's sync state at registration time;
532    // callers must update it after each successful authentication.
533    let credential = Credential {
534        id: cred_data.credential_id,
535        public_key,
536        sign_count: auth_data.sign_count,
537        user_id: user_id.to_vec(),
538        rp_id: rp.id.clone(),
539        created_at: SystemTime::now(),
540        backup_eligible,
541        backup_state,
542    };
543
544    Ok(RegistrationResult {
545        credential,
546        attestation_type,
547        backup_eligible,
548        backup_state,
549        extensions,
550    })
551}
552
553// ─── CBOR attestation object ──────────────────────────────────────────────────
554
555/// Decode the CBOR attestation object and return `(fmt, authData bytes, attStmt)`.
556///
557/// The attestation object is a CBOR map with at least:
558/// - `"fmt"`      (text): attestation format
559/// - `"attStmt"`  (map):  attestation statement (forwarded to `attestation::verify`)
560/// - `"authData"` (bytes): raw authenticator data
561fn parse_attestation_object(data: &[u8]) -> Result<(String, Vec<u8>, Value)> {
562    let value: Value = ciborium::from_reader(data)
563        .map_err(|e| WebAuthnError::CborDecodeError(format!("attestation object: {e}")))?;
564
565    let map = match value {
566        Value::Map(m) => m,
567        _ => {
568            return Err(WebAuthnError::InvalidAttestationObject(
569                "attestation object must be a CBOR map".to_string(),
570            ))
571        }
572    };
573
574    let mut fmt: Option<String> = None;
575    let mut auth_data: Option<Result<Vec<u8>>> = None;
576    let mut att_stmt: Option<Value> = None;
577
578    for (k, v) in map {
579        match k {
580            Value::Text(ref key) if key == "fmt" => {
581                if let Value::Text(s) = v {
582                    fmt = Some(s);
583                }
584            }
585            Value::Text(ref key) if key == "authData" => {
586                auth_data = Some(match v {
587                    Value::Bytes(b) => Ok(b),
588                    _ => Err(WebAuthnError::InvalidAttestationObject(
589                        "authData must be CBOR bytes, not another type".to_string(),
590                    )),
591                });
592            }
593            Value::Text(ref key) if key == "attStmt" => {
594                att_stmt = Some(v);
595            }
596            _ => {}
597        }
598    }
599
600    let fmt = fmt.ok_or_else(|| {
601        WebAuthnError::InvalidAttestationObject("missing required field: fmt".to_string())
602    })?;
603
604    let auth_data = auth_data.ok_or_else(|| {
605        WebAuthnError::InvalidAttestationObject("missing required field: authData".to_string())
606    })??; // first ? unwraps Option, second ? propagates the inner Result
607
608    let att_stmt = att_stmt.ok_or_else(|| {
609        WebAuthnError::InvalidAttestationObject("missing required field: attStmt".to_string())
610    })?;
611
612    Ok((fmt, auth_data, att_stmt))
613}
614
615// ─── Tests ────────────────────────────────────────────────────────────────────
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    #[test]
622    fn rejects_invalid_attestation_object_cbor() {
623        let bad_bytes = &[0xFF, 0x00, 0x00];
624        let result = parse_attestation_object(bad_bytes);
625        assert!(matches!(result, Err(WebAuthnError::CborDecodeError(_))));
626    }
627
628    #[test]
629    fn rejects_attestation_object_that_is_not_a_map() {
630        let integer_cbor = &[0x00u8]; // CBOR integer 0
631        let result = parse_attestation_object(integer_cbor);
632        assert!(matches!(
633            result,
634            Err(WebAuthnError::InvalidAttestationObject(_))
635        ));
636    }
637
638    #[test]
639    fn rejects_attestation_object_missing_fmt() {
640        let mut buf = Vec::new();
641        let v = Value::Map(vec![(
642            Value::Text("authData".to_string()),
643            Value::Bytes(vec![0u8; 37]),
644        )]);
645        ciborium::into_writer(&v, &mut buf).expect("test setup");
646        let result = parse_attestation_object(&buf);
647        assert!(matches!(
648            result,
649            Err(WebAuthnError::InvalidAttestationObject(_))
650        ));
651    }
652
653    #[test]
654    fn rejects_attestation_object_missing_auth_data() {
655        let mut buf = Vec::new();
656        let v = Value::Map(vec![
657            (
658                Value::Text("fmt".to_string()),
659                Value::Text("none".to_string()),
660            ),
661            (Value::Text("attStmt".to_string()), Value::Map(vec![])),
662        ]);
663        ciborium::into_writer(&v, &mut buf).expect("test setup");
664        let result = parse_attestation_object(&buf);
665        assert!(matches!(
666            result,
667            Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("authData")
668        ));
669    }
670
671    #[test]
672    fn rejects_attestation_object_missing_att_stmt() {
673        let mut buf = Vec::new();
674        let v = Value::Map(vec![
675            (
676                Value::Text("fmt".to_string()),
677                Value::Text("none".to_string()),
678            ),
679            (
680                Value::Text("authData".to_string()),
681                Value::Bytes(vec![0u8; 37]),
682            ),
683        ]);
684        ciborium::into_writer(&v, &mut buf).expect("test setup");
685        let result = parse_attestation_object(&buf);
686        assert!(matches!(
687            result,
688            Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("attStmt")
689        ));
690    }
691
692    #[test]
693    fn rejects_auth_data_not_bytes() {
694        // authData is present but is a text string, not bytes.
695        let mut buf = Vec::new();
696        let v = Value::Map(vec![
697            (
698                Value::Text("fmt".to_string()),
699                Value::Text("none".to_string()),
700            ),
701            (Value::Text("attStmt".to_string()), Value::Map(vec![])),
702            (
703                Value::Text("authData".to_string()),
704                Value::Text("not bytes".to_string()),
705            ),
706        ]);
707        ciborium::into_writer(&v, &mut buf).expect("test setup");
708        let result = parse_attestation_object(&buf);
709        assert!(matches!(
710            result,
711            Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("bytes")
712        ));
713    }
714}