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
118impl RelyingParty {
119 /// Create a new `RelyingParty` that accepts a single origin.
120 ///
121 /// # Arguments
122 /// * `id` — Relying party ID, e.g. `"example.com"`.
123 /// * `origin` — Full app origin, e.g. `"https://example.com"`.
124 /// * `name` — Human-readable service name.
125 pub fn new(id: &str, origin: &str, name: &str) -> Self {
126 Self {
127 id: id.to_string(),
128 allowed_origins: vec![origin.to_string()],
129 name: name.to_string(),
130 require_user_verification: false,
131 reject_cross_origin: false,
132 allowed_algorithms: vec![],
133 require_backup_eligible: false,
134 reject_backup_eligible: false,
135 }
136 }
137
138 /// Create a new `RelyingParty` that accepts multiple origins.
139 ///
140 /// Use this when your app is served from more than one origin — for example,
141 /// `https://example.com` in production and `http://localhost:8080` in
142 /// development — and you want a single `RelyingParty` instance to handle
143 /// both environments.
144 ///
145 /// # Arguments
146 /// * `id` — Relying party ID, e.g. `"example.com"`.
147 /// * `origins` — Iterator of accepted origins.
148 /// * `name` — Human-readable service name.
149 pub fn with_origins(
150 id: &str,
151 origins: impl IntoIterator<Item = impl Into<String>>,
152 name: &str,
153 ) -> Self {
154 Self {
155 id: id.to_string(),
156 allowed_origins: origins.into_iter().map(Into::into).collect(),
157 name: name.to_string(),
158 require_user_verification: false,
159 reject_cross_origin: false,
160 allowed_algorithms: vec![],
161 require_backup_eligible: false,
162 reject_backup_eligible: false,
163 }
164 }
165
166 /// Require the UV (User Verification) flag on every authentication assertion.
167 ///
168 /// When `true`, `verify_authentication` returns
169 /// [`crate::error::WebAuthnError::UserNotVerified`] if the authenticator's
170 /// `UV` bit is not set — meaning the user was not verified via PIN,
171 /// biometric, or another local gesture.
172 ///
173 /// # Example
174 ///
175 /// ```rust,no_run
176 /// use webauthn::RelyingParty;
177 ///
178 /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
179 /// .require_user_verification(true);
180 /// ```
181 pub fn require_user_verification(mut self, required: bool) -> Self {
182 self.require_user_verification = required;
183 self
184 }
185
186 /// Reject `clientDataJSON` that contains `crossOrigin: true` (§7.1 step 10).
187 ///
188 /// When `true`, any registration or authentication response from a
189 /// cross-origin iframe is rejected with
190 /// [`crate::error::WebAuthnError::CrossOriginNotAllowed`].
191 ///
192 /// # Example
193 ///
194 /// ```rust,no_run
195 /// use webauthn::RelyingParty;
196 ///
197 /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
198 /// .reject_cross_origin(true);
199 /// ```
200 pub fn reject_cross_origin(mut self, reject: bool) -> Self {
201 self.reject_cross_origin = reject;
202 self
203 }
204
205 /// Restrict which COSE algorithms this RP accepts at registration time.
206 ///
207 /// When the list is non-empty, `verify_registration` rejects any credential
208 /// whose algorithm is not in this list with
209 /// [`crate::error::WebAuthnError::UnsupportedAlgorithm`].
210 /// An empty list (the default) accepts ES256, EdDSA, and RS256.
211 ///
212 /// # Example
213 ///
214 /// ```rust,no_run
215 /// use webauthn::{RelyingParty, COSE_ES256};
216 ///
217 /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
218 /// .allowed_algorithms([COSE_ES256]);
219 /// ```
220 pub fn allowed_algorithms(mut self, algs: impl IntoIterator<Item = i64>) -> Self {
221 self.allowed_algorithms = algs.into_iter().collect();
222 self
223 }
224
225 /// Require that credentials are backup-eligible (BE flag must be set).
226 ///
227 /// When `true`, `verify_registration` and `verify_authentication` return
228 /// [`crate::error::WebAuthnError::BackupEligibilityRequired`] for any
229 /// credential whose BE flag is not set.
230 ///
231 /// # Example
232 ///
233 /// ```rust,no_run
234 /// use webauthn::RelyingParty;
235 ///
236 /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
237 /// .require_backup_eligible(true);
238 /// ```
239 pub fn require_backup_eligible(mut self, required: bool) -> Self {
240 self.require_backup_eligible = required;
241 self
242 }
243
244 /// Reject credentials that are backup-eligible (BE flag must not be set).
245 ///
246 /// When `true`, `verify_registration` and `verify_authentication` return
247 /// [`crate::error::WebAuthnError::BackupEligibleNotAllowed`] for any
248 /// credential whose BE flag is set.
249 ///
250 /// # Example
251 ///
252 /// ```rust,no_run
253 /// use webauthn::RelyingParty;
254 ///
255 /// let rp = RelyingParty::new("example.com", "https://example.com", "My Service")
256 /// .reject_backup_eligible(true);
257 /// ```
258 pub fn reject_backup_eligible(mut self, reject: bool) -> Self {
259 self.reject_backup_eligible = reject;
260 self
261 }
262
263 /// Verify a registration ceremony response (W3C WebAuthn §7.1).
264 ///
265 /// Call this after the client returns an `AuthenticatorAttestationResponse`.
266 /// On `Ok`, persist `result.credential` in your database. On `Err`, reject
267 /// the registration and return an appropriate error to the client.
268 ///
269 /// # Arguments
270 /// * `challenge` — The challenge you issued for this ceremony.
271 /// * `response` — The raw attestation response from the authenticator.
272 /// * `user_id` — Your application's identifier for this user.
273 ///
274 /// # Errors
275 /// Returns a [`WebAuthnError`] variant indicating exactly which
276 /// verification step failed.
277 pub fn verify_registration(
278 &self,
279 challenge: &Challenge,
280 response: &AuthenticatorAttestationResponse,
281 user_id: &[u8],
282 ) -> Result<RegistrationResult> {
283 verify_registration_inner(self, challenge, response, user_id)
284 }
285}
286
287// ─── Ceremony implementation ──────────────────────────────────────────────────
288
289fn verify_registration_inner(
290 rp: &RelyingParty,
291 challenge: &Challenge,
292 response: &AuthenticatorAttestationResponse,
293 user_id: &[u8],
294) -> Result<RegistrationResult> {
295 // ── Pre-check: challenge expiry ───────────────────────────────────────────
296 // The spec does not specify where to check this, but rejecting an expired
297 // challenge before doing any crypto is the most efficient ordering.
298 if challenge.is_expired(CHALLENGE_MAX_AGE_SECS) {
299 return Err(WebAuthnError::ChallengeExpired);
300 }
301
302 // ── §7.1 step 5 ───────────────────────────────────────────────────────────
303 // Let JSONtext be the UTF-8 decoding of response.clientDataJSON.
304 // response.client_data_json already holds the raw bytes; validate UTF-8.
305 let _ = std::str::from_utf8(&response.client_data_json).map_err(|_| {
306 WebAuthnError::InvalidClientData("clientDataJSON is not valid UTF-8".to_string())
307 })?;
308
309 // ── §7.1 step 6 ───────────────────────────────────────────────────────────
310 // Parse clientDataJSON bytes into a CollectedClientData structure.
311 let parsed_cd = client_data::parse_client_data(&response.client_data_json)?;
312
313 // ── §7.1 step 7 ───────────────────────────────────────────────────────────
314 // Verify that C.type equals "webauthn.create".
315 // ── §7.1 step 8 ───────────────────────────────────────────────────────────
316 // Verify that C.challenge equals the issued challenge.
317 // ── §7.1 step 9 ───────────────────────────────────────────────────────────
318 // Verify that C.origin matches the relying party's origin.
319 // ── §7.1 step 10 ──────────────────────────────────────────────────────────
320 // If reject_cross_origin is set, reject crossOrigin: true.
321 client_data::validate_client_data(
322 &parsed_cd,
323 "webauthn.create",
324 &challenge.bytes,
325 &rp.allowed_origins,
326 rp.reject_cross_origin,
327 )?;
328
329 // ── §7.1 step 11 ──────────────────────────────────────────────────────────
330 // Let hash be SHA-256(clientDataJSON bytes).
331 let client_data_hash = sha256(&parsed_cd.raw_json);
332
333 // ── §7.1 step 12 ──────────────────────────────────────────────────────────
334 // Perform CBOR decoding on the attestationObject.
335 let (fmt, auth_data_bytes, att_stmt) = parse_attestation_object(&response.attestation_object)?;
336
337 // ── §7.1 step 9 (authData) ────────────────────────────────────────────────
338 // Parse the raw authenticator data bytes into a typed structure.
339 let auth_data = authenticator_data::parse_authenticator_data(&auth_data_bytes)?;
340
341 // ── §7.1 step 13 ──────────────────────────────────────────────────────────
342 // Verify that the rpIdHash in authData is SHA-256(rp.id).
343 let expected_rp_id_hash = sha256(rp.id.as_bytes());
344 if auth_data.rp_id_hash != expected_rp_id_hash {
345 return Err(WebAuthnError::RpIdHashMismatch);
346 }
347
348 // ── §7.1 step 14 ──────────────────────────────────────────────────────────
349 // Verify that the User Present (UP) flag is set.
350 // A registration without UP is invalid — the user must have been present.
351 if !auth_data.flags.user_present {
352 return Err(WebAuthnError::UserNotPresent);
353 }
354
355 // ── §7.1 step 18 ──────────────────────────────────────────────────────────
356 // Apply the RP's backup eligibility policy.
357 if rp.reject_backup_eligible && auth_data.flags.backup_eligible {
358 return Err(WebAuthnError::BackupEligibleNotAllowed);
359 }
360 if rp.require_backup_eligible && !auth_data.flags.backup_eligible {
361 return Err(WebAuthnError::BackupEligibilityRequired);
362 }
363
364 // ── §7.1 step 16 ──────────────────────────────────────────────────────────
365 // Verify that the AT (Attested Credential Data) flag is set.
366 // If absent, the authenticator did not include a public key — unusable.
367 let cred_data = auth_data.attested_credential_data.ok_or_else(|| {
368 WebAuthnError::InvalidAuthenticatorData(
369 "attested credential data (AT flag) is required for registration".to_string(),
370 )
371 })?;
372
373 // ── §7.1 step 17 ──────────────────────────────────────────────────────────
374 // Extract the COSE public key and convert it to a typed PublicKey.
375 // The parser already validated kty, crv (for EC2), and alg (for RSA).
376 // Here we additionally check that EC2 keys use ES256 (the only EC2 algorithm
377 // we support), and reject any combination we cannot verify.
378 //
379 // Read the algorithm integer first (by reference) so we can check the RP's
380 // allowlist before consuming the CoseKey.
381 let cose_alg: i64 = match &cred_data.public_key {
382 CoseKey::EC2 { alg, .. } => *alg,
383 CoseKey::OKP { alg, .. } => *alg,
384 CoseKey::RSA { .. } => COSE_RS256,
385 };
386
387 // §7.1 step 17 — reject the credential algorithm if not in the RP's allowlist.
388 if !rp.allowed_algorithms.is_empty() && !rp.allowed_algorithms.contains(&cose_alg) {
389 return Err(WebAuthnError::UnsupportedAlgorithm(cose_alg));
390 }
391
392 let public_key = match cred_data.public_key {
393 CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES256 => PublicKey::ES256 { x, y },
394 CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES384 => PublicKey::ES384 { x, y },
395 CoseKey::EC2 { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
396 CoseKey::OKP { alg, x, .. } if alg == COSE_EDDSA => PublicKey::EdDSA(x),
397 CoseKey::OKP { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
398 CoseKey::RSA { n, e, .. } => PublicKey::RS256 { n, e },
399 };
400
401 // ── §7.1 step 19 ──────────────────────────────────────────────────────────
402 // Verify the attestation statement. Pass the public key so packed
403 // self-attestation can verify the signature with the credential key.
404 // Pass credential_id for fido-u2f verificationData construction.
405 let attestation_type = attestation::verify(
406 &fmt,
407 &att_stmt,
408 &auth_data_bytes,
409 &client_data_hash,
410 &public_key,
411 &cred_data.credential_id,
412 )?;
413
414 let backup_eligible = auth_data.flags.backup_eligible;
415 let backup_state = auth_data.flags.backup_state;
416
417 // ── §7.1 step 25 ──────────────────────────────────────────────────────────
418 // Build the Credential. The caller must persist this object.
419 // backup_eligible is stored so authentication can enforce BE immutability.
420 let credential = Credential {
421 id: cred_data.credential_id,
422 public_key,
423 sign_count: auth_data.sign_count,
424 user_id: user_id.to_vec(),
425 rp_id: rp.id.clone(),
426 created_at: SystemTime::now(),
427 backup_eligible,
428 };
429
430 Ok(RegistrationResult {
431 credential,
432 attestation_type,
433 backup_eligible,
434 backup_state,
435 })
436}
437
438// ─── CBOR attestation object ──────────────────────────────────────────────────
439
440/// Decode the CBOR attestation object and return `(fmt, authData bytes, attStmt)`.
441///
442/// The attestation object is a CBOR map with at least:
443/// - `"fmt"` (text): attestation format
444/// - `"attStmt"` (map): attestation statement (forwarded to `attestation::verify`)
445/// - `"authData"` (bytes): raw authenticator data
446fn parse_attestation_object(data: &[u8]) -> Result<(String, Vec<u8>, Value)> {
447 let value: Value = ciborium::from_reader(data)
448 .map_err(|e| WebAuthnError::CborDecodeError(format!("attestation object: {e}")))?;
449
450 let map = match value {
451 Value::Map(m) => m,
452 _ => {
453 return Err(WebAuthnError::InvalidAttestationObject(
454 "attestation object must be a CBOR map".to_string(),
455 ))
456 }
457 };
458
459 let mut fmt: Option<String> = None;
460 let mut auth_data: Option<Result<Vec<u8>>> = None;
461 let mut att_stmt: Option<Value> = None;
462
463 for (k, v) in map {
464 match k {
465 Value::Text(ref key) if key == "fmt" => {
466 if let Value::Text(s) = v {
467 fmt = Some(s);
468 }
469 }
470 Value::Text(ref key) if key == "authData" => {
471 auth_data = Some(match v {
472 Value::Bytes(b) => Ok(b),
473 _ => Err(WebAuthnError::InvalidAttestationObject(
474 "authData must be CBOR bytes, not another type".to_string(),
475 )),
476 });
477 }
478 Value::Text(ref key) if key == "attStmt" => {
479 att_stmt = Some(v);
480 }
481 _ => {}
482 }
483 }
484
485 let fmt = fmt.ok_or_else(|| {
486 WebAuthnError::InvalidAttestationObject("missing required field: fmt".to_string())
487 })?;
488
489 let auth_data = auth_data.ok_or_else(|| {
490 WebAuthnError::InvalidAttestationObject("missing required field: authData".to_string())
491 })??; // first ? unwraps Option, second ? propagates the inner Result
492
493 let att_stmt = att_stmt.ok_or_else(|| {
494 WebAuthnError::InvalidAttestationObject("missing required field: attStmt".to_string())
495 })?;
496
497 Ok((fmt, auth_data, att_stmt))
498}
499
500// ─── Tests ────────────────────────────────────────────────────────────────────
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 #[test]
507 fn rejects_invalid_attestation_object_cbor() {
508 let bad_bytes = &[0xFF, 0x00, 0x00];
509 let result = parse_attestation_object(bad_bytes);
510 assert!(matches!(result, Err(WebAuthnError::CborDecodeError(_))));
511 }
512
513 #[test]
514 fn rejects_attestation_object_that_is_not_a_map() {
515 let integer_cbor = &[0x00u8]; // CBOR integer 0
516 let result = parse_attestation_object(integer_cbor);
517 assert!(matches!(
518 result,
519 Err(WebAuthnError::InvalidAttestationObject(_))
520 ));
521 }
522
523 #[test]
524 fn rejects_attestation_object_missing_fmt() {
525 let mut buf = Vec::new();
526 let v = Value::Map(vec![(
527 Value::Text("authData".to_string()),
528 Value::Bytes(vec![0u8; 37]),
529 )]);
530 ciborium::into_writer(&v, &mut buf).unwrap();
531 let result = parse_attestation_object(&buf);
532 assert!(matches!(
533 result,
534 Err(WebAuthnError::InvalidAttestationObject(_))
535 ));
536 }
537
538 #[test]
539 fn rejects_attestation_object_missing_auth_data() {
540 let mut buf = Vec::new();
541 let v = Value::Map(vec![
542 (
543 Value::Text("fmt".to_string()),
544 Value::Text("none".to_string()),
545 ),
546 (Value::Text("attStmt".to_string()), Value::Map(vec![])),
547 ]);
548 ciborium::into_writer(&v, &mut buf).unwrap();
549 let result = parse_attestation_object(&buf);
550 assert!(matches!(
551 result,
552 Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("authData")
553 ));
554 }
555
556 #[test]
557 fn rejects_attestation_object_missing_att_stmt() {
558 let mut buf = Vec::new();
559 let v = Value::Map(vec![
560 (
561 Value::Text("fmt".to_string()),
562 Value::Text("none".to_string()),
563 ),
564 (
565 Value::Text("authData".to_string()),
566 Value::Bytes(vec![0u8; 37]),
567 ),
568 ]);
569 ciborium::into_writer(&v, &mut buf).unwrap();
570 let result = parse_attestation_object(&buf);
571 assert!(matches!(
572 result,
573 Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("attStmt")
574 ));
575 }
576
577 #[test]
578 fn rejects_auth_data_not_bytes() {
579 // authData is present but is a text string, not bytes.
580 let mut buf = Vec::new();
581 let v = Value::Map(vec![
582 (
583 Value::Text("fmt".to_string()),
584 Value::Text("none".to_string()),
585 ),
586 (Value::Text("attStmt".to_string()), Value::Map(vec![])),
587 (
588 Value::Text("authData".to_string()),
589 Value::Text("not bytes".to_string()),
590 ),
591 ]);
592 ciborium::into_writer(&v, &mut buf).unwrap();
593 let result = parse_attestation_object(&buf);
594 assert!(matches!(
595 result,
596 Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("bytes")
597 ));
598 }
599}