Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/src/webauthn.rs

16.5 KiB, 11 runs

created by r1870400018:36147, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! WebAuthn assertion verification -- the server half of the
2//! `navigator.credentials.get()` ceremony.
3//!
4//! This is deliberately the *assertion* path only, and deliberately small. The
5//! one-time *registration* ceremony (`credentials.create()`) carries the only
6//! CBOR/COSE in WebAuthn -- the attestation object -- and a single-admin,
7//! out-of-band enrolment never needs the server to parse it: the operator reads
8//! the public key from `response.getPublicKey()` in the browser and records it
9//! in an allowlist. So nothing here touches CBOR. What remains is a byte-layout
10//! parse of `authenticatorData`, a handful of equality and flag checks on the
11//! `clientDataJSON`, and one ECDSA (or EdDSA) signature verification over the
12//! primitives this crate already carries.
13//!
14//! The signed message a platform authenticator produces is
15//! `authenticatorData || SHA-256(clientDataJSON)`; ES256 (COSE `-7`) signs it as
16//! a DER `SEQUENCE { r, s }` -- hence [`crate::ecdsa::verify_p256_sha256_asn1`],
17//! the ASN.1 sibling of the fixed-form verifier a browser's own WebCrypto key
18//! uses. ES256 is the near-universal default of Apple, Google and Windows
19//! authenticators; EdDSA (COSE `-8`) is modelled too for the rare Ed25519
20//! passkey, over `fe2o3_crypto`'s strict Ed25519 verifier.
21//!
22//! A second downstream caller (a device-key admin surface, an operator console)
23//! reuses this verbatim, which is why it lives here rather than in one app.
24//!
25//! Reference: WebAuthn Level 2, §5.8.1 (attested/authenticator data) and §7.2
26//! (verifying an authentication assertion).
27
28use crate::acme::jose::base64url_decode;
29use crate::ecdsa::verify_p256_sha256_asn1;
30
31use oxedyne_fe2o3_core::prelude::*;
32use oxedyne_fe2o3_jdat::{
33 prelude::*,
34 string::dec::DecoderConfig,
35};
36
37use oxedyne_fe2o3_crypto::sign::verify_ed25519;
38
39use ring::digest::{
40 Context,
41 SHA256,
42};
43
44
45/// The COSE signature algorithm a stored credential was registered under. Only
46/// the two a platform authenticator emits are modelled; the caller records which
47/// at enrolment and passes it back on every assertion.
48#[derive(Clone, Copy, Debug, Eq, PartialEq)]
49pub enum CoseAlg {
50 Es256, // COSE -7: ECDSA P-256 + SHA-256, DER signature, 65-byte SEC1 key
51 EdDsa, // COSE -8: Ed25519, 64-byte signature, 32-byte key
52}
53
54/// A verified assertion. The caller advances its own per-credential replay guard
55/// from `counter` (reject a value not greater than the stored one) and may record
56/// which user-verification the authenticator asserted.
57#[derive(Clone, Copy, Debug)]
58pub struct VerifiedAssertion {
59 pub counter: u32, // authenticator signature counter, for replay defence
60 pub user_present: bool, // UP flag was set
61 pub user_verified: bool, // UV flag was set (PIN/biometric on this assertion)
62}
63
64// authenticatorData flag bits (WebAuthn §6.1).
65const FLAG_UP: u8 = 0x01; // user present
66const FLAG_UV: u8 = 0x04; // user verified
67
68// rpIdHash(32) || flags(1) || signCount(4): the fixed head every assertion carries.
69const AUTH_DATA_MIN_LEN: usize = 37;
70
71
72fn sha256(data: &[u8]) -> [u8; 32] {
73 let mut ctx = Context::new(&SHA256);
74 ctx.update(data);
75 let mut out = [0u8; 32];
76 out.copy_from_slice(ctx.finish().as_ref());
77 out
78}
79
80/// Verify a WebAuthn authentication assertion, in the order that fails cheapest
81/// and most specifically first.
82///
83/// # Arguments
84///
85/// - `alg`, `stored_key`: the credential's algorithm and public key as recorded
86/// at enrolment -- a 65-byte uncompressed SEC1 point for ES256, a 32-byte key
87/// for EdDSA.
88/// - `expect_challenge`: the raw challenge bytes the server issued for this
89/// login (one-time, short TTL, consumed by the caller). Compared against the
90/// base64url `challenge` inside `clientDataJSON`.
91/// - `expect_origin`: the exact `origin` the ceremony must have run at, e.g.
92/// `https://admin.example.com` -- the anti-phishing bind.
93/// - `rp_id`: the relying-party id, e.g. `admin.example.com`; its SHA-256 must
94/// equal the first 32 bytes of `authenticator_data`.
95/// - `authenticator_data`, `client_data_json`, `signature`: the three fields the
96/// browser returns from `credentials.get()`.
97pub fn verify_assertion(
98 alg: CoseAlg,
99 stored_key: &[u8],
100 expect_challenge: &[u8],
101 expect_origin: &str,
102 rp_id: &str,
103 authenticator_data: &[u8],
104 client_data_json: &[u8],
105 signature: &[u8],
106)
107 -> Outcome<VerifiedAssertion>
108{
109 // clientDataJSON: type, challenge and origin. Parsed as JSON via jdat, the
110 // same decoder the app dialects use.
111 let cfg = DecoderConfig::<(), ()>::json(None);
112 let text = String::from_utf8_lossy(client_data_json).to_string();
113 let cd = match res!(Dat::decode_string_with_config(text, &cfg)) {
114 Dat::Map(m) => m,
115 other => return Err(err!(
116 "WebAuthn clientDataJSON is not a JSON object, got {:?}.", other.kind();
117 Invalid, Input, Decode)),
118 };
119 let cd_str = |k: &str| match cd.get(&dat!(k)) {
120 Some(Dat::Str(s)) => Some(s.clone()),
121 _ => None,
122 };
123
124 match cd_str("type").as_deref() {
125 Some("webauthn.get") => (),
126 other => return Err(err!(
127 "WebAuthn clientDataJSON type is {:?}, expected \"webauthn.get\".", other;
128 Invalid, Input, Mismatch)),
129 }
130
131 let challenge_b64 = res!(cd_str("challenge").ok_or_else(|| err!(
132 "WebAuthn clientDataJSON carries no challenge."; Invalid, Input, Missing)));
133 let got_challenge = res!(base64url_decode(&challenge_b64));
134 if got_challenge != expect_challenge {
135 return Err(err!(
136 "WebAuthn assertion challenge does not match the one issued; \
137 possible replay or a stale login.";
138 Invalid, Input, Mismatch, Security));
139 }
140
141 match cd_str("origin").as_deref() {
142 Some(o) if o == expect_origin => (),
143 other => return Err(err!(
144 "WebAuthn assertion origin is {:?}, expected {:?} (anti-phishing bind).",
145 other, expect_origin;
146 Invalid, Input, Mismatch, Security)),
147 }
148
149 // authenticatorData: rpIdHash, flags and the signature counter.
150 if authenticator_data.len() < AUTH_DATA_MIN_LEN {
151 return Err(err!(
152 "WebAuthn authenticatorData is {} bytes, need at least {}.",
153 authenticator_data.len(), AUTH_DATA_MIN_LEN;
154 Invalid, Input, Size));
155 }
156 if authenticator_data[0..32] != sha256(rp_id.as_bytes()) {
157 return Err(err!(
158 "WebAuthn rpIdHash does not match SHA-256({:?}).", rp_id;
159 Invalid, Input, Mismatch, Security));
160 }
161 let flags = authenticator_data[32];
162 let user_present = flags & FLAG_UP != 0;
163 let user_verified = flags & FLAG_UV != 0;
164 if !user_present {
165 return Err(err!(
166 "WebAuthn assertion has the user-present (UP) flag clear.";
167 Invalid, Input, Security));
168 }
169 if !user_verified {
170 return Err(err!(
171 "WebAuthn assertion has the user-verified (UV) flag clear; \
172 userVerification:'required' was expected.";
173 Invalid, Input, Security));
174 }
175 let counter = u32::from_be_bytes([
176 authenticator_data[33],
177 authenticator_data[34],
178 authenticator_data[35],
179 authenticator_data[36],
180 ]);
181
182 // The signature is over authenticatorData || SHA-256(clientDataJSON).
183 let mut signed = Vec::with_capacity(authenticator_data.len() + 32);
184 signed.extend_from_slice(authenticator_data);
185 signed.extend_from_slice(&sha256(client_data_json));
186
187 let ok = match alg {
188 CoseAlg::Es256 => verify_p256_sha256_asn1(stored_key, &signed, signature),
189 // Hematite's one Ed25519 verifier, which refuses a small-order key that
190 // `ring`'s would take as signing anything.
191 CoseAlg::EdDsa => matches!(verify_ed25519(stored_key, &signed, signature), Ok(true)),
192 };
193 if !ok {
194 return Err(err!(
195 "WebAuthn assertion signature does not verify against the stored credential key.";
196 Invalid, Input, Security));
197 }
198
199 Ok(VerifiedAssertion { counter, user_present, user_verified })
200}
201
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206
207 use crate::acme::jose::base64url_encode;
208
209 use ring::{
210 rand::SystemRandom,
211 signature::{
212 EcdsaKeyPair,
213 KeyPair,
214 ECDSA_P256_SHA256_ASN1_SIGNING,
215 },
216 };
217
218 const RP_ID: &str = "admin.sideye.oxegen.io";
219 const ORIGIN: &str = "https://admin.sideye.oxegen.io";
220
221 /// Build the `authenticatorData` head an authenticator would emit: the
222 /// SHA-256 of the rpId, a flags byte, and a big-endian counter. Assertions
223 /// carry no attested credential data, so 37 bytes is the whole of it.
224 fn make_auth_data(rp_id: &str, flags: u8, counter: u32) -> Vec<u8> {
225 let mut v = Vec::with_capacity(AUTH_DATA_MIN_LEN);
226 v.extend_from_slice(&sha256(rp_id.as_bytes()));
227 v.push(flags);
228 v.extend_from_slice(&counter.to_be_bytes());
229 v
230 }
231
232 fn client_data(ty: &str, challenge: &[u8], origin: &str) -> Vec<u8> {
233 // The browser emits compact JSON; the exact bytes are what gets hashed,
234 // so build them directly rather than through an encoder.
235 fmt!(
236 "{{\"type\":\"{}\",\"challenge\":\"{}\",\"origin\":\"{}\"}}",
237 ty, base64url_encode(challenge), origin,
238 ).into_bytes()
239 }
240
241 struct Es256Key {
242 kp: EcdsaKeyPair,
243 rng: SystemRandom,
244 pubkey: Vec<u8>,
245 }
246
247 impl Es256Key {
248 fn new() -> Outcome<Self> {
249 let rng = SystemRandom::new();
250 let pkcs8 = match EcdsaKeyPair::generate_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &rng) {
251 Ok(d) => d,
252 Err(e) => return Err(err!("ring pkcs8 gen failed: {}.", e; Test, Init)),
253 };
254 let kp = match EcdsaKeyPair::from_pkcs8(
255 &ECDSA_P256_SHA256_ASN1_SIGNING, pkcs8.as_ref(), &rng) {
256 Ok(k) => k,
257 Err(e) => return Err(err!("ring pkcs8 load failed: {}.", e; Test, Init)),
258 };
259 let pubkey = kp.public_key().as_ref().to_vec();
260 Ok(Self { kp, rng, pubkey })
261 }
262
263 fn sign(&self, msg: &[u8]) -> Outcome<Vec<u8>> {
264 match self.kp.sign(&self.rng, msg) {
265 Ok(s) => Ok(s.as_ref().to_vec()),
266 Err(e) => Err(err!("ring sign failed: {}.", e; Test, Data)),
267 }
268 }
269
270 /// Produce a valid (authenticatorData, clientDataJSON, signature) triple.
271 fn assert_over(&self, challenge: &[u8], flags: u8, counter: u32)
272 -> Outcome<(Vec<u8>, Vec<u8>, Vec<u8>)>
273 {
274 let ad = make_auth_data(RP_ID, flags, counter);
275 let cdj = client_data("webauthn.get", challenge, ORIGIN);
276 let mut signed = ad.clone();
277 signed.extend_from_slice(&sha256(&cdj));
278 let sig = res!(self.sign(&signed));
279 Ok((ad, cdj, sig))
280 }
281 }
282
283 /// An EdDSA assertion signed by a real Ed25519 key verifies, and one "signed"
284 /// for the identity as a key, with R the identity and S zero, is refused.
285 /// That pair satisfies the cofactorless equation over any message, and
286 /// `ring`'s Ed25519 verifier, which this path used before, takes it, as the
287 /// second assertion shows.
288 #[test]
289 fn test_verify_assertion_eddsa_refuses_a_small_order_key() -> Outcome<()> {
290 use oxedyne_fe2o3_crypto::sign::SignatureScheme;
291 use oxedyne_fe2o3_iop_crypto::{
292 keys::KeyManager,
293 sign::Signer,
294 };
295
296 let challenge = b"eddsa-challenge";
297 let ad = make_auth_data(RP_ID, FLAG_UP | FLAG_UV, 3);
298 let cdj = client_data("webauthn.get", challenge, ORIGIN);
299 let mut signed = ad.clone();
300 signed.extend_from_slice(&sha256(&cdj));
301
302 let key = SignatureScheme::new_ed25519();
303 let public = match res!(key.get_public_key()) {
304 Some(pk) => pk.to_vec(),
305 None => return Err(err!("A new Ed25519 key has no public half."; Test, Missing)),
306 };
307 let sig = res!(key.sign(&signed));
308 let v = res!(verify_assertion(
309 CoseAlg::EdDsa, &public, challenge, ORIGIN, RP_ID, &ad, &cdj, &sig));
310 assert_eq!(v.counter, 3);
311
312 let mut identity = [0u8; 32];
313 identity[0] = 0x01;
314 let mut forged = [0u8; 64];
315 forged[0] = 0x01;
316 assert!(ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, &identity)
317 .verify(&signed, &forged).is_ok(),
318 "ring's verifier takes the forgery, which is why this path no longer uses it");
319 assert!(verify_assertion(
320 CoseAlg::EdDsa, &identity, challenge, ORIGIN, RP_ID, &ad, &cdj, &forged).is_err(),
321 "the identity key's forgery must be refused");
322 Ok(())
323 }
324
325 /// A well-formed ES256 assertion verifies, and its counter and flags come back.
326 #[test]
327 fn test_verify_assertion_es256_ok() -> Outcome<()> {
328 let key = res!(Es256Key::new());
329 let challenge = b"one-time-challenge-01";
330 let (ad, cdj, sig) = res!(key.assert_over(challenge, FLAG_UP | FLAG_UV, 7));
331 let v = res!(verify_assertion(
332 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, RP_ID, &ad, &cdj, &sig));
333 assert_eq!(v.counter, 7);
334 assert!(v.user_present && v.user_verified);
335 Ok(())
336 }
337
338 /// Each guard rejects the thing it guards: a wrong challenge (replay), a wrong
339 /// origin (phishing), a wrong rpId, a clear UV flag, a tampered signature, and
340 /// a key that did not sign it.
341 #[test]
342 fn test_verify_assertion_rejections() -> Outcome<()> {
343 let key = res!(Es256Key::new());
344 let challenge = b"the-real-challenge";
345
346 // Wrong challenge -- what a replayed assertion from an earlier login looks like.
347 let (ad, cdj, sig) = res!(key.assert_over(challenge, FLAG_UP | FLAG_UV, 1));
348 assert!(verify_assertion(
349 CoseAlg::Es256, &key.pubkey, b"a-different-challenge", ORIGIN, RP_ID,
350 &ad, &cdj, &sig).is_err(),
351 "a mismatched challenge must be rejected");
352
353 // Wrong expected origin.
354 assert!(verify_assertion(
355 CoseAlg::Es256, &key.pubkey, challenge, "https://evil.example.com", RP_ID,
356 &ad, &cdj, &sig).is_err(),
357 "a mismatched origin must be rejected");
358
359 // Wrong rpId -> rpIdHash mismatch.
360 assert!(verify_assertion(
361 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, "other.example.com",
362 &ad, &cdj, &sig).is_err(),
363 "a mismatched rpId must be rejected");
364
365 // UV flag clear, though the signature is otherwise valid.
366 let (ad_no_uv, cdj2, sig2) = res!(key.assert_over(challenge, FLAG_UP, 2));
367 assert!(verify_assertion(
368 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, RP_ID, &ad_no_uv, &cdj2, &sig2)
369 .is_err(),
370 "a clear UV flag must be rejected");
371
372 // Tampered signature.
373 let mut bad_sig = sig.clone();
374 let last = bad_sig.len() - 1;
375 bad_sig[last] ^= 0x01;
376 assert!(verify_assertion(
377 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, RP_ID, &ad, &cdj, &bad_sig)
378 .is_err(),
379 "a tampered signature must be rejected");
380
381 // A different key did not sign this assertion.
382 let other = res!(Es256Key::new());
383 assert!(verify_assertion(
384 CoseAlg::Es256, &other.pubkey, challenge, ORIGIN, RP_ID, &ad, &cdj, &sig)
385 .is_err(),
386 "the wrong credential key must be rejected");
387
388 Ok(())
389 }
390
391 /// A short authenticatorData fails gracefully rather than panicking.
392 #[test]
393 fn test_verify_assertion_short_auth_data() -> Outcome<()> {
394 let key = res!(Es256Key::new());
395 let challenge = b"c";
396 let (_ad, cdj, sig) = res!(key.assert_over(challenge, FLAG_UP | FLAG_UV, 1));
397 let short = vec![0u8; 10];
398 assert!(verify_assertion(
399 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, RP_ID, &short, &cdj, &sig)
400 .is_err(),
401 "a truncated authenticatorData must be rejected, not panic");
402 Ok(())
403 }
404
405 /// Non-JSON clientDataJSON is refused, not a panic.
406 #[test]
407 fn test_verify_assertion_junk_client_data() -> Outcome<()> {
408 let key = res!(Es256Key::new());
409 let challenge = b"c";
410 let (ad, _cdj, sig) = res!(key.assert_over(challenge, FLAG_UP | FLAG_UV, 1));
411 assert!(verify_assertion(
412 CoseAlg::Es256, &key.pubkey, challenge, ORIGIN, RP_ID, &ad,
413 b"not json at all", &sig).is_err(),
414 "junk clientDataJSON must be refused");
415 Ok(())
416 }
417}