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