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 // ── §7.2 step 17 ─────────────────────────────────────────────────────────
102 // Let hash be SHA-256(clientDataJSON bytes).
103 let client_data_hash = sha256(&parsed_cd.raw_json);
104
105 // ── §7.2 step 18 ─────────────────────────────────────────────────────────
106 // Parse the authenticator data binary structure.
107 let auth_data = authenticator_data::parse_authenticator_data(&response.authenticator_data)?;
108
109 // ── §7.2 step 19 ─────────────────────────────────────────────────────────
110 // Verify rpIdHash = SHA-256(stored credential's rp_id).
111 let expected_rp_id_hash = sha256(stored_credential.rp_id.as_bytes());
112 if auth_data.rp_id_hash != expected_rp_id_hash {
113 return Err(WebAuthnError::RpIdHashMismatch);
114 }
115
116 // ── §7.2 step 20 ─────────────────────────────────────────────────────────
117 // Verify the User Present (UP) flag.
118 if !auth_data.flags.user_present {
119 return Err(WebAuthnError::UserNotPresent);
120 }
121
122 // ── §7.2 step 21 ─────────────────────────────────────────────────────────
123 // If the RP requires user verification, the UV flag must be set.
124 if rp.require_user_verification && !auth_data.flags.user_verified {
125 return Err(WebAuthnError::UserNotVerified);
126 }
127
128 // ── §7.2 step 21 — Backup Eligibility policy ──────────────────────────────
129 if rp.reject_backup_eligible && auth_data.flags.backup_eligible {
130 return Err(WebAuthnError::BackupEligibleNotAllowed);
131 }
132 if rp.require_backup_eligible && !auth_data.flags.backup_eligible {
133 return Err(WebAuthnError::BackupEligibilityRequired);
134 }
135
136 // ── §7.2 step 21 — Backup Eligibility consistency ─────────────────────────
137 // BE is immutable per spec — a mismatch between the stored registration value
138 // and the current assertion signals a credential substitution attempt.
139 if auth_data.flags.backup_eligible != stored_credential.backup_eligible {
140 return Err(WebAuthnError::BackupEligibilityChanged);
141 }
142
143 // ── §7.2 step 24 ─────────────────────────────────────────────────────────
144 // Verify the signature over: authData || SHA-256(clientDataJSON).
145 //
146 // ES256 is ECDSA-P256-SHA256: ring hashes the *message* internally.
147 // The message is `authData_bytes || clientDataHash` (not pre-hashed again).
148 let mut signed_data = auth_data.raw.clone();
149 signed_data.extend_from_slice(&client_data_hash);
150
151 // §7.2 step 24
152 match &stored_credential.public_key {
153 PublicKey::ES256 { x, y } => {
154 // Reconstruct the 65-byte uncompressed point ring expects.
155 let mut pk = Vec::with_capacity(65);
156 pk.push(0x04);
157 pk.extend_from_slice(x);
158 pk.extend_from_slice(y);
159 verify_es256(&pk, &signed_data, &response.signature)?;
160 }
161 PublicKey::ES384 { x, y } => {
162 // Reconstruct the 97-byte uncompressed P-384 point ring expects.
163 let mut pk = Vec::with_capacity(97);
164 pk.push(0x04);
165 pk.extend_from_slice(x);
166 pk.extend_from_slice(y);
167 verify_es384(&pk, &signed_data, &response.signature)?;
168 }
169 PublicKey::EdDSA(pk) => {
170 // Ed25519 signature is raw 64 bytes; ring processes the message directly.
171 verify_eddsa(pk, &signed_data, &response.signature)?;
172 }
173 PublicKey::RS256 { n, e } => {
174 let der = rsa_components_to_der(n, e)?;
175 verify_rs256(&der, &signed_data, &response.signature)?;
176 }
177 }
178
179 // ── §7.2 step 25 ─────────────────────────────────────────────────────────
180 // Verify the sign count is strictly greater than the stored value.
181 //
182 // Per spec: if either counter is non-zero, the received counter must exceed
183 // the stored one. Both being 0 indicates a counter-less authenticator, which
184 // is accepted. A stored non-zero counter with received=0 is rejected: it
185 // could indicate a wrap-around (overflow) or a counter-less authenticator
186 // being substituted for a counter-bearing one — both are suspicious.
187 let received = auth_data.sign_count;
188 let stored = stored_credential.sign_count;
189
190 if (stored > 0 || received > 0) && received <= stored {
191 return Err(WebAuthnError::SignCountInvalid { stored, received });
192 }
193
194 Ok(AuthenticationResult {
195 credential_id: stored_credential.id.clone(),
196 new_sign_count: received,
197 user_present: auth_data.flags.user_present,
198 user_verified: auth_data.flags.user_verified,
199 backup_eligible: auth_data.flags.backup_eligible,
200 backup_state: auth_data.flags.backup_state,
201 extensions: auth_data.extensions,
202 })
203}