Skip to main content

webauthn/
authentication.rs

1//! Authentication ceremony — W3C WebAuthn §7.2.
2//!
3//! The authentication ceremony is how a user proves possession of a previously
4//! registered credential. The relying party's job:
5//!
6//! 1. Verify the response was produced for *this* challenge and *this* origin.
7//! 2. Verify the authenticator data is bound to *this* RP ID.
8//! 3. Verify the ECDSA signature over `authData || SHA-256(clientDataJSON)`.
9//! 4. Check the sign count to detect cloned authenticators.
10//!
11//! Spec: <https://www.w3.org/TR/webauthn-2/#sctn-verifying-assertion>
12
13use crate::authenticator_data;
14use crate::challenge::CHALLENGE_MAX_AGE_SECS;
15use crate::client_data;
16use crate::credential::{AuthenticationResult, Challenge, Credential, PublicKey};
17use crate::crypto::{
18    rsa_components_to_der, sha256, verify_eddsa, verify_es256, verify_es384, verify_rs256,
19};
20use crate::error::{Result, WebAuthnError};
21use crate::registration::RelyingParty;
22
23/// The browser's response after a `navigator.credentials.get()` call.
24///
25/// All fields carry **raw decoded bytes** — base64url decoding must happen
26/// outside the library before constructing this struct.
27#[derive(Debug, Clone)]
28pub struct AuthenticatorAssertionResponse {
29    /// Raw UTF-8 bytes of the `clientDataJSON` object.
30    pub client_data_json: Vec<u8>,
31
32    /// Raw bytes of the `authenticatorData` structure.
33    pub authenticator_data: Vec<u8>,
34
35    /// DER-encoded ECDSA signature bytes.
36    pub signature: Vec<u8>,
37
38    /// Optional raw user handle bytes (some authenticators omit it).
39    pub user_handle: Option<Vec<u8>>,
40}
41
42impl RelyingParty {
43    /// Verify an authentication ceremony response (W3C WebAuthn §7.2).
44    ///
45    /// Call this after the client returns an `AuthenticatorAssertionResponse`.
46    /// On `Ok`, update the stored credential's `sign_count` to
47    /// `result.new_sign_count` before responding to the client.
48    ///
49    /// # Arguments
50    /// * `stored_credential` — Retrieved from your database by credential ID.
51    /// * `challenge`         — The challenge you issued for this ceremony.
52    /// * `response`          — The assertion response from the authenticator.
53    ///
54    /// # Errors
55    /// Returns a [`crate::error::WebAuthnError`] variant indicating exactly which
56    /// verification step failed, including `SignCountInvalid` for suspected
57    /// authenticator clones.
58    pub fn verify_authentication(
59        &self,
60        stored_credential: &Credential,
61        challenge: &Challenge,
62        response: &AuthenticatorAssertionResponse,
63    ) -> Result<AuthenticationResult> {
64        verify_authentication_inner(self, stored_credential, challenge, response)
65    }
66}
67
68// ─── Ceremony implementation ──────────────────────────────────────────────────
69
70fn verify_authentication_inner(
71    rp: &RelyingParty,
72    stored_credential: &Credential,
73    challenge: &Challenge,
74    response: &AuthenticatorAssertionResponse,
75) -> Result<AuthenticationResult> {
76    // ── Pre-check: challenge expiry ───────────────────────────────────────────
77    if challenge.is_expired(CHALLENGE_MAX_AGE_SECS) {
78        return Err(WebAuthnError::ChallengeExpired);
79    }
80
81    // ── §7.2 step 11 ─────────────────────────────────────────────────────────
82    // Parse clientDataJSON bytes (already raw UTF-8).
83    let parsed_cd = client_data::parse_client_data(&response.client_data_json)?;
84
85    // ── §7.2 step 13 ─────────────────────────────────────────────────────────
86    // Verify type == "webauthn.get".
87    // ── §7.2 step 14 ─────────────────────────────────────────────────────────
88    // Verify the challenge matches.
89    // ── §7.2 step 15 ─────────────────────────────────────────────────────────
90    // Verify the origin matches rp.origin.
91    // ── §7.2 step 12 ─────────────────────────────────────────────────────────
92    // If reject_cross_origin is set, reject crossOrigin: true.
93    client_data::validate_client_data(
94        &parsed_cd,
95        "webauthn.get",
96        &challenge.bytes,
97        &rp.allowed_origins,
98        rp.reject_cross_origin,
99    )?;
100
101    // ── Single-use challenge enforcement (opt-in) ─────────────────────────────
102    // Mirror the registration check: consume the challenge after it passes
103    // expiry and binding, before any further crypto work.
104    if let Some(ref used) = rp.used_challenges {
105        let mut set = used
106            .lock()
107            .expect("used_challenges mutex is poisoned — a previous ceremony panicked");
108        if set.contains(&challenge.bytes) {
109            return Err(WebAuthnError::ChallengePreviouslyUsed);
110        }
111        set.insert(challenge.bytes.clone());
112    }
113
114    // ── §7.2 step 17 ─────────────────────────────────────────────────────────
115    // Let hash be SHA-256(clientDataJSON bytes).
116    let client_data_hash = sha256(&parsed_cd.raw_json);
117
118    // ── §7.2 step 18 ─────────────────────────────────────────────────────────
119    // Parse the authenticator data binary structure.
120    let auth_data = authenticator_data::parse_authenticator_data(&response.authenticator_data)?;
121
122    // ── §7.2 step 19 ─────────────────────────────────────────────────────────
123    // Verify rpIdHash = SHA-256(stored credential's rp_id).
124    let expected_rp_id_hash = sha256(stored_credential.rp_id.as_bytes());
125    if auth_data.rp_id_hash != expected_rp_id_hash {
126        return Err(WebAuthnError::RpIdHashMismatch);
127    }
128
129    // ── §7.2 step 20 ─────────────────────────────────────────────────────────
130    // Verify the User Present (UP) flag.
131    if !auth_data.flags.user_present {
132        return Err(WebAuthnError::UserNotPresent);
133    }
134
135    // ── §7.2 step 21 ─────────────────────────────────────────────────────────
136    // If the RP requires user verification, the UV flag must be set.
137    if rp.require_user_verification && !auth_data.flags.user_verified {
138        return Err(WebAuthnError::UserNotVerified);
139    }
140
141    // ── §7.2 step 21 — Backup Eligibility policy ──────────────────────────────
142    if rp.reject_backup_eligible && auth_data.flags.backup_eligible {
143        return Err(WebAuthnError::BackupEligibleNotAllowed);
144    }
145    if rp.require_backup_eligible && !auth_data.flags.backup_eligible {
146        return Err(WebAuthnError::BackupEligibilityRequired);
147    }
148
149    // ── §7.2 step 21 — Backup Eligibility consistency ─────────────────────────
150    // BE is immutable per spec — a mismatch between the stored registration value
151    // and the current assertion signals a credential substitution attempt.
152    if auth_data.flags.backup_eligible != stored_credential.backup_eligible {
153        return Err(WebAuthnError::BackupEligibilityChanged);
154    }
155
156    // ── §7.2 step 24 ─────────────────────────────────────────────────────────
157    // Verify the signature over: authData || SHA-256(clientDataJSON).
158    //
159    // ES256 is ECDSA-P256-SHA256: ring hashes the *message* internally.
160    // The message is `authData_bytes || clientDataHash` (not pre-hashed again).
161    let mut signed_data = auth_data.raw.clone();
162    signed_data.extend_from_slice(&client_data_hash);
163
164    // §7.2 step 24
165    match &stored_credential.public_key {
166        PublicKey::ES256 { x, y } => {
167            // Reconstruct the 65-byte uncompressed point ring expects.
168            let mut pk = Vec::with_capacity(65);
169            pk.push(0x04);
170            pk.extend_from_slice(x);
171            pk.extend_from_slice(y);
172            verify_es256(&pk, &signed_data, &response.signature)?;
173        }
174        PublicKey::ES384 { x, y } => {
175            // Reconstruct the 97-byte uncompressed P-384 point ring expects.
176            let mut pk = Vec::with_capacity(97);
177            pk.push(0x04);
178            pk.extend_from_slice(x);
179            pk.extend_from_slice(y);
180            verify_es384(&pk, &signed_data, &response.signature)?;
181        }
182        PublicKey::EdDSA(pk) => {
183            // Ed25519 signature is raw 64 bytes; ring processes the message directly.
184            verify_eddsa(pk, &signed_data, &response.signature)?;
185        }
186        PublicKey::RS256 { n, e } => {
187            let der = rsa_components_to_der(n, e)?;
188            verify_rs256(&der, &signed_data, &response.signature)?;
189        }
190    }
191
192    // ── §7.2 step 25 ─────────────────────────────────────────────────────────
193    // Verify the sign count is strictly greater than the stored value.
194    //
195    // Per spec: if either counter is non-zero, the received counter must exceed
196    // the stored one. Both being 0 indicates a counter-less authenticator, which
197    // is accepted. A stored non-zero counter with received=0 is rejected: it
198    // could indicate a wrap-around (overflow) or a counter-less authenticator
199    // being substituted for a counter-bearing one — both are suspicious.
200    let received = auth_data.sign_count;
201    let stored = stored_credential.sign_count;
202
203    if (stored > 0 || received > 0) && received <= stored {
204        return Err(WebAuthnError::SignCountInvalid { stored, received });
205    }
206
207    Ok(AuthenticationResult {
208        credential_id: stored_credential.id.clone(),
209        new_sign_count: received,
210        user_present: auth_data.flags.user_present,
211        user_verified: auth_data.flags.user_verified,
212        backup_eligible: auth_data.flags.backup_eligible,
213        backup_state: auth_data.flags.backup_state,
214        extensions: auth_data.extensions,
215    })
216}