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 | |
| 28 | use crate::acme::jose::base64url_decode; |
| 29 | use crate::ecdsa::verify_p256_sha256_asn1; |
| 30 | |
| 31 | use oxedyne_fe2o3_core::prelude::*; |
| 32 | use oxedyne_fe2o3_jdat::{ |
| 33 | prelude::*, |
| 34 | string::dec::DecoderConfig, |
| 35 | }; |
| 36 | |
| 37 | use oxedyne_fe2o3_crypto::sign::verify_ed25519; |
| 38 | |
| 39 | use 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)] |
| 49 | pub 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)] |
| 58 | pub 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). |
| 65 | const FLAG_UP: u8 = 0x01; // user present |
| 66 | const FLAG_UV: u8 = 0x04; // user verified |
| 67 | |
| 68 | // rpIdHash(32) || flags(1) || signCount(4): the fixed head every assertion carries. |
| 69 | const AUTH_DATA_MIN_LEN: usize = 37; |
| 70 | |
| 71 | |
| 72 | fn 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()`. |
| 97 | pub 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)] |
| 204 | mod 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 | } |