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}