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}