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
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}