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}