Passkeys
Passkeys without openssl: WebAuthn registration and sign-in checked by hand, with challenges, clientDataJSON, authenticator data and its flags, none and packed attestation read from CBOR, COSE keys for ES256, EdDSA and RS256, and signature counters that catch a copied key.
Version 1.0.0 · 6 functions · 7 crates
Functions
What use passkeys.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use passkeys_challenge, passkeys_register_options from passkeys (Quillo 0.6).
passkeys_challenge() → stror an errorA new random challenge, 32 bytes as base64url. Keep it in the visitor's session and use it once.
passkeys_register_options(rp: any, user: any, options: any) → anyor an errorWhat navigator.credentials.create needs, ready for PublicKeyCredential.parseCreationOptionsFromJSON. rp is { id, name }, user { id, name, display_name } (id an opaque text of up to 64 bytes, never an e-mail address; made when left out). options: exclude (credential ids the user has), attestation (none, direct), user_verification, resident_key (preferred), attachment, timeout_ms, challenge.
passkeys_register_verify(response: any, expected: any) → anyor an errorChecks what navigator.credentials.create gave (its toJSON()). expected is { challenge, origin (or a list), rp_id, require_user_verification, algorithms, allow_cross_origin }. Gives what to keep for this user: { credential_id, public_key, algorithm, counter, transports, aaguid, attestation, certificate, user_verified, backup_eligible, backed_up }. Attestation none and packed are checked, self-attested or by its certificate, whose chain is yours to judge; other formats are refused, so ask for attestation: "none".
passkeys_signin_options(rp_id: str, credentials: any, options: any) → anyor an errorWhat navigator.credentials.get needs. credentials lists the user's ids (or kept credentials); an empty list lets the browser offer any passkey for this site. options: user_verification, timeout_ms, challenge.
passkeys_signin_verify(response: any, credential: any, expected: any) → anyor an errorChecks what navigator.credentials.get gave against a kept credential (credential_id, public_key, counter, as registration gave them, and user_handle if you hold one). expected as for registering. A counter that did not go up, when either side counts, is refused: the key may have been copied. Gives { credential_id, counter, user_verified, backed_up, user_handle }: save the new counter and backed_up.
passkeys_key_info(public_key: str) → anyor an errorWhat a kept COSE public key is: { algorithm, kty, curve }.
Get it
$ quillo install passkeys # passkeys.qwer into this folder$ quillo install passkeys --example # and its example, passkeys.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/passkeys.qwer$ curl -O https://quillolang.org/plugins/files/passkeys.qweThen, in your program, all of it or only what you call:
use passkeys.qweruse passkeys_challenge, passkeys_register_options from passkeys // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }base64 = "0.22"sha2 = { version = "0.10", features = ["oid"] }p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }ed25519-dalek = { version = "2", default-features = false, features = ["std"] }rsa = { version = "0.9", default-features = false, features = ["std"] }getrandom = "0.2"Example
// Passkeys for a site: registering, signing in, and everything a forged or copied answer gives.// A browser and its authenticator normally stand on the other side; here a software authenticator// plays them, making real keys and real signatures, reached through PASSKEY_AUTHENTICATOR_URL.//// PASSKEY_AUTHENTICATOR_URL=http://127.0.0.1:9000 quillo run passkeys.qwe// then open http://localhost:8080/flow/es256, /flow/ed25519, /flow/rs256, /attested and /refused//// In a real page the options go to the browser and its answer comes back as JSON:// const o = PublicKeyCredential.parseCreationOptionsFromJSON(await (await fetch("/passkey/options")).json());// const c = await navigator.credentials.create({ publicKey: o });// await fetch("/passkey/register", { method: "POST", body: JSON.stringify(c.toJSON()) });// Keep the challenge in the session, not in the page, and use it once.use "passkeys.qwer"let SITE = { id: "shop.example", name: "Example Shop" }let ORIGIN = "https://shop.example"let WANT = { origin: [ORIGIN, "https://www.shop.example"], rp_id: SITE.id }fn device(path, body) { let r = http.post(env("PASSKEY_AUTHENTICATOR_URL", "") + path, body, { "content-type": "application/json" }, { public_only: false })? if r.status != 200 { return err(r.body) } r.json}// One user, one key: returns the credential to keep.fn enrol(kind, attestation, extra) { let options = passkeys_register_options(SITE, { id: "user-1001", name: "ada@shop.example", display_name: "Ada" }, { attestation: attestation })? let answer = device("/create", merge({ options: options, origin: ORIGIN, kind: kind }, extra))? passkeys_register_verify(answer, merge(WANT, { challenge: options.challenge }))}fn sign_in(kept, extra, want) { let options = passkeys_signin_options(SITE.id, [kept.credential_id], {})? let answer = device("/get", merge({ options: options, origin: ORIGIN }, extra))? passkeys_signin_verify(answer, kept, merge(merge(WANT, { challenge: options.challenge }), want))}route GET /flow/:kind { let kept = enrol(kind, "none", {})? let first = sign_in(kept, {}, {})? let second = sign_in(merge(kept, { counter: first.counter }), {}, {})? { algorithm: kept.algorithm, attestation: kept.attestation, key: passkeys_key_info(kept.public_key)?, counters: [kept.counter, first.counter, second.counter], user_verified: first.user_verified, handle: first.user_handle, }}// Packed attestation: signed by the credential itself, and by a device certificate.route GET /attested { let by_self = enrol("es256", "direct", { attest: "self" })? let by_ed = enrol("ed25519", "direct", { attest: "self" })? let by_cert = enrol("es256", "direct", { attest: "cert" })? { self: [by_self.attestation, by_ed.attestation], cert: by_cert.attestation, has_certificate: by_cert.certificate != null, wrong_self: enrol("es256", "direct", { attest: "self", break: "attestation" }).error, wrong_cert: enrol("es256", "direct", { attest: "cert", break: "attestation" }).error, other_format: enrol("es256", "direct", { attest: "tpm" }).error, }}route GET /refused { let kept = enrol("es256", "none", {})? let other = passkeys_register_options(SITE, { name: "x" }, {})? let answer = device("/create", { options: other, origin: ORIGIN, kind: "es256" })? let good = merge(WANT, { challenge: other.challenge }) { challenge: passkeys_register_verify(answer, merge(WANT, { challenge: passkeys_challenge()? })).error, origin: passkeys_register_verify(answer, merge(good, { origin: "https://evil.example" })).error, rp_id: passkeys_register_verify(answer, merge(good, { rp_id: "other.example" })).error, kind: passkeys_signin_verify(answer, { credential_id: answer.id, public_key: "", counter: 0 }, good).error, no_verify: passkeys_register_verify(device("/create", { options: other, origin: ORIGIN, kind: "es256", uv: false })?, merge(good, { require_user_verification: true })).error, no_presence: passkeys_register_verify(device("/create", { options: other, origin: ORIGIN, kind: "es256", up: false })?, good).error, algorithm: passkeys_register_verify(device("/create", { options: other, origin: ORIGIN, kind: "ed25519" })?, merge(good, { algorithms: [-7] })).error, bad_signature: sign_in(kept, { break: "signature" }, {}).error, changed_data: sign_in(kept, { break: "client_data" }, {}).error, replayed_counter: sign_in(merge(kept, { counter: 100 }), {}, {}).error, wrong_user: sign_in(merge(kept, { user_handle: "bm90LXRoaXMtb25l" }), {}, {}).error, excluded: device("/create", { options: merge(other, { excludeCredentials: [{ type: "public-key", id: kept.credential_id }] }), origin: ORIGIN, kind: "es256" }).error, wrong_site: device("/create", { options: other, origin: "https://evil.example", kind: "es256" }).error, }}$ quillo run passkeys.qwe$ curl 'localhost:8080/flow/es256'$ curl 'localhost:8080/flow/ed25519'$ curl 'localhost:8080/flow/rs256'$ curl 'localhost:8080/attested'$ curl 'localhost:8080/refused'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! Passkeys: WebAuthn registration and sign-in checked by hand in pure Rust, with challenges, clientDataJSON, authenticator data and its flags, attestation (none and packed), COSE keys for ES256, EdDSA and RS256, and signature counters.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! base64 = "0.22"//! sha2 = { version = "0.10", features = ["oid"] }//! p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }//! ed25519-dalek = { version = "2", default-features = false, features = ["std"] }//! rsa = { version = "0.9", default-features = false, features = ["std"] }//! getrandom = "0.2"use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};use base64::Engine;use serde_json::{json, Value};use sha2::{Digest, Sha256};const ES256: i64 = -7;const EDDSA: i64 = -8;const RS256: i64 = -257;const UP: u8 = 0x01;const UV: u8 = 0x04;const BE: u8 = 0x08;const BS: u8 = 0x10;const AT: u8 = 0x40;const ED: u8 = 0x80;fn b64(x: &[u8]) -> String { URL_SAFE_NO_PAD.encode(x)}/// Base64url as WebAuthn writes it; padding or the other alphabet is forgiven, as some libraries send them.fn unb64(what: &str, x: &str) -> Result<Vec<u8>, String> { let t: String = x.trim_end_matches('=').chars().map(|c| match c { '+' => '-', '/' => '_', o => o }).collect(); URL_SAFE_NO_PAD.decode(t).map_err(|_| format!("{} is not base64url", what))}fn random(n: usize) -> Result<Vec<u8>, String> { let mut b = vec![0u8; n]; getrandom::getrandom(&mut b).map_err(|e| e.to_string())?; Ok(b)}fn sha256(x: &[u8]) -> Vec<u8> { Sha256::digest(x).to_vec()}/// CBOR, as much as WebAuthn uses.#[derive(Debug, Clone, PartialEq)]enum Cbor { Int(i128), Bytes(Vec<u8>), Text(String), Array(Vec<Cbor>), Map(Vec<(Cbor, Cbor)>), Bool(bool), Null,}impl Cbor { fn get(&self, key: &Cbor) -> Option<&Cbor> { if let Cbor::Map(m) = self { m.iter().find(|(k, _)| k == key).map(|(_, v)| v) } else { None } } fn text(&self, key: &str) -> Option<&Cbor> { self.get(&Cbor::Text(key.into())) } fn int(&self, key: i128) -> Option<&Cbor> { self.get(&Cbor::Int(key)) } fn bytes(&self) -> Option<&[u8]> { if let Cbor::Bytes(b) = self { Some(b) } else { None } } fn number(&self) -> Option<i128> { if let Cbor::Int(i) = self { Some(*i) } else { None } }}fn take<'a>(d: &'a [u8], p: &mut usize, n: usize) -> Result<&'a [u8], String> { let end = p.checked_add(n).filter(|e| *e <= d.len()).ok_or("the CBOR ends early")?; let s = &d[*p..end]; *p = end; Ok(s)}fn head(d: &[u8], p: &mut usize) -> Result<(u8, u64), String> { let b = take(d, p, 1)?[0]; let (major, info) = (b >> 5, b & 0x1f); let arg = match info { 0..=23 => info as u64, 24 => take(d, p, 1)?[0] as u64, 25 => u16::from_be_bytes(take(d, p, 2)?.try_into().unwrap()) as u64, 26 => u32::from_be_bytes(take(d, p, 4)?.try_into().unwrap()) as u64, 27 => u64::from_be_bytes(take(d, p, 8)?.try_into().unwrap()), _ => return Err(format!("CBOR additional information {} is not used by WebAuthn", info)), }; Ok((major, arg))}/// One CBOR item from position `p`, which moves past it. WebAuthn's CBOR is definite-length and shallow.fn cbor(d: &[u8], p: &mut usize, depth: usize) -> Result<Cbor, String> { if depth > 16 { return Err("the CBOR is nested too deep".into()); } let (major, arg) = head(d, p)?; Ok(match major { 0 => Cbor::Int(arg as i128), 1 => Cbor::Int(-1 - arg as i128), 2 => Cbor::Bytes(take(d, p, arg as usize)?.to_vec()), 3 => Cbor::Text(String::from_utf8(take(d, p, arg as usize)?.to_vec()).map_err(|_| "CBOR text is not UTF-8")?), 4 => { if arg as usize > d.len() { return Err("the CBOR ends early".into()); } Cbor::Array((0..arg).map(|_| cbor(d, p, depth + 1)).collect::<Result<_, _>>()?) } 5 => { if arg as usize > d.len() { return Err("the CBOR ends early".into()); } Cbor::Map((0..arg).map(|_| Ok((cbor(d, p, depth + 1)?, cbor(d, p, depth + 1)?))).collect::<Result<_, String>>()?) } 6 => return cbor(d, p, depth + 1), 7 => match arg { 20 => Cbor::Bool(false), 21 => Cbor::Bool(true), 22 | 23 => Cbor::Null, o => return Err(format!("CBOR simple value {} is not used by WebAuthn", o)), }, _ => unreachable!(), })}/// The COSE key's algorithm, checked to be whole: curve, coordinates, sizes.fn key_alg(key: &Cbor) -> Result<i64, String> { let kty = key.int(1).and_then(Cbor::number).ok_or("the COSE key has no kty")?; let alg = key.int(3).and_then(Cbor::number).ok_or("the COSE key has no alg")? as i64; let part = |label: i128, n: usize, name: &str| -> Result<(), String> { match key.int(label).and_then(Cbor::bytes) { Some(b) if b.len() == n => Ok(()), _ => Err(format!("the COSE key's {} should be {} bytes", name, n)), } }; match (kty, alg) { (2, ES256) => { if key.int(-1).and_then(Cbor::number) != Some(1) { return Err("an ES256 key must be on curve P-256 (crv 1)".into()); } part(-2, 32, "x")?; part(-3, 32, "y")?; } (1, EDDSA) => { if key.int(-1).and_then(Cbor::number) != Some(6) { return Err("an EdDSA key must be Ed25519 (crv 6)".into()); } part(-2, 32, "x")?; } (3, RS256) => { let n = key.int(-1).and_then(Cbor::bytes).ok_or("the RSA key has no n")?; if n.len() < 256 { return Err(format!("the RSA key has {} bits; at least 2048", n.len() * 8)); } key.int(-2).and_then(Cbor::bytes).ok_or("the RSA key has no e")?; } _ => return Err(format!("COSE key type {} with algorithm {} is not supported: ES256 (-7), EdDSA (-8) and RS256 (-257) are", kty, alg)), } Ok(alg)}fn alg_name(a: i64) -> &'static str { match a { ES256 => "ES256", EDDSA => "EdDSA", RS256 => "RS256", _ => "?" }}fn verify_es256(public: &[u8], msg: &[u8], sig: &[u8]) -> Result<bool, String> { use p256::ecdsa::signature::Verifier; let k = p256::ecdsa::VerifyingKey::from_sec1_bytes(public).map_err(|_| "the public key is not on P-256")?; let Ok(s) = p256::ecdsa::Signature::from_der(sig) else { return Ok(false) }; Ok(k.verify(msg, &s).is_ok())}/// Checks a signature with a COSE key.fn verify(key: &Cbor, msg: &[u8], sig: &[u8]) -> Result<bool, String> { match key_alg(key)? { ES256 => { let mut point = vec![4u8]; point.extend_from_slice(key.int(-2).and_then(Cbor::bytes).unwrap()); point.extend_from_slice(key.int(-3).and_then(Cbor::bytes).unwrap()); verify_es256(&point, msg, sig) } EDDSA => { let x: [u8; 32] = key.int(-2).and_then(Cbor::bytes).unwrap().try_into().unwrap(); let k = ed25519_dalek::VerifyingKey::from_bytes(&x).map_err(|_| "the public key is not an Ed25519 point")?; let Ok(s) = <[u8; 64]>::try_from(sig) else { return Ok(false) }; Ok(k.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&s)).is_ok()) } RS256 => { let n = rsa::BigUint::from_bytes_be(key.int(-1).and_then(Cbor::bytes).unwrap()); let e = rsa::BigUint::from_bytes_be(key.int(-2).and_then(Cbor::bytes).unwrap()); let k = rsa::RsaPublicKey::new(n, e).map_err(|e| e.to_string())?; Ok(k.verify(rsa::Pkcs1v15Sign::new::<Sha256>(), &Sha256::digest(msg), sig).is_ok()) } _ => unreachable!(), }}/// The next DER element at `p`: (tag, content, position after it).fn der(d: &[u8], p: usize) -> Result<(u8, &[u8], usize), String> { let short = || "the certificate ends early".to_string(); let tag = *d.get(p).ok_or_else(short)?; let first = *d.get(p + 1).ok_or_else(short)? as usize; let (len, at) = if first < 0x80 { (first, p + 2) } else { let n = first & 0x7f; if n == 0 || n > 4 { return Err("a certificate length is not one this reads".into()); } let bytes = d.get(p + 2..p + 2 + n).ok_or_else(short)?; (bytes.iter().fold(0usize, |a, b| a << 8 | *b as usize), p + 2 + n) }; let end = at.checked_add(len).filter(|e| *e <= d.len()).ok_or_else(short)?; Ok((tag, &d[at..end], end))}/// The uncompressed P-256 public key of an X.509 certificate. Only the walk to it: trust in the certificate is the caller's.fn cert_p256_key(cert: &[u8]) -> Result<Vec<u8>, String> { let (_, whole, _) = der(cert, 0)?; let (_, tbs, _) = der(whole, 0)?; let mut p = 0; let (tag, _, next) = der(tbs, p)?; if tag == 0xa0 { p = next; // the version } for _ in 0..5 { p = der(tbs, p)?.2; // serial, signature algorithm, issuer, validity, subject } let (_, spki, _) = der(tbs, p)?; let (_, algorithm, after) = der(spki, 0)?; let (_, oid, _) = der(algorithm, 0)?; if oid != [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01] { return Err("the attestation certificate's key is not an EC key".into()); } let (_, bits, _) = der(spki, after)?; let key = bits.get(1..).ok_or("the attestation certificate's key is empty")?; if key.len() != 65 { return Err("the attestation certificate's key is not on P-256".into()); } Ok(key.to_vec())}struct Auth { flags: u8, counter: u32, aaguid: Vec<u8>, credential_id: Vec<u8>, key: Option<Cbor>, key_bytes: Vec<u8>, extensions: Value,}fn read_auth(d: &[u8]) -> Result<Auth, String> { if d.len() < 37 { return Err(format!("authenticator data is {} bytes; at least 37", d.len())); } let flags = d[32]; let counter = u32::from_be_bytes(d[33..37].try_into().unwrap()); let mut a = Auth { flags, counter, aaguid: vec![], credential_id: vec![], key: None, key_bytes: vec![], extensions: Value::Null }; let mut p = 37; if flags & AT != 0 { if d.len() < p + 18 { return Err("authenticator data ends inside the attested credential".into()); } a.aaguid = d[p..p + 16].to_vec(); let n = u16::from_be_bytes([d[p + 16], d[p + 17]]) as usize; p += 18; if n > 1023 { return Err(format!("a credential id of {} bytes; at most 1023", n)); } a.credential_id = d.get(p..p + n).ok_or("authenticator data ends inside the credential id")?.to_vec(); p += n; let start = p; a.key = Some(cbor(d, &mut p, 0).map_err(|e| format!("the credential's public key: {}", e))?); a.key_bytes = d[start..p].to_vec(); } if flags & ED != 0 { cbor(d, &mut p, 0).map_err(|e| format!("extensions: {}", e))?; a.extensions = json!(true); } if p != d.len() { return Err(format!("{} bytes left over after the authenticator data", d.len() - p)); } Ok(a)}fn origins(expected: &Value) -> Vec<String> { match expected.get("origin") { Some(Value::String(s)) => vec![s.clone()], Some(Value::Array(a)) => a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(), _ => vec![], }}fn str_of<'a>(v: &'a Value, k: &str, what: &str) -> Result<&'a str, String> { v.get(k).and_then(Value::as_str).ok_or(format!("{}: no {}", what, k))}/// Parses and checks the clientDataJSON; gives its raw bytes.fn client_data(response: &Value, kind: &str, expected: &Value) -> Result<Vec<u8>, String> { let raw = unb64("clientDataJSON", str_of(&response["response"], "clientDataJSON", "response")?)?; let c: Value = serde_json::from_slice(&raw).map_err(|e| format!("clientDataJSON is not JSON: {}", e))?; if c["type"] != kind { return Err(format!("clientDataJSON's type is {}, and should be {}", c["type"], kind)); } let challenge = str_of(expected, "challenge", "expected")?; if challenge.is_empty() { return Err("expected: the challenge is empty".into()); } if unb64("the challenge", c["challenge"].as_str().unwrap_or(""))? != unb64("the expected challenge", challenge)? { return Err("the challenge does not match the one given out: an old or a stolen response".into()); } let allowed = origins(expected); if allowed.is_empty() { return Err("expected: no origin".into()); } let origin = c["origin"].as_str().unwrap_or(""); if !allowed.iter().any(|o| o == origin) { return Err(format!("origin {} is not {}", origin, allowed.join(" or "))); } if c["crossOrigin"] == true && !expected["allow_cross_origin"].as_bool().unwrap_or(false) { return Err("the ceremony ran in a cross-origin frame".into()); } Ok(raw)}fn check_flags(a: &Auth, auth_data: &[u8], expected: &Value) -> Result<(), String> { let rp = str_of(expected, "rp_id", "expected")?; if auth_data[..32] != sha256(rp.as_bytes())[..] { return Err(format!("the rpIdHash is not that of {}", rp)); } if a.flags & UP == 0 { return Err("the user was not present (flag UP is off)".into()); } if expected["require_user_verification"].as_bool().unwrap_or(false) && a.flags & UV == 0 { return Err("the user was not verified (flag UV is off), and this site requires it".into()); } if a.flags & BS != 0 && a.flags & BE == 0 { return Err("flag BS (backed up) is on without BE (backup eligible)".into()); } Ok(())}fn aaguid_text(a: &[u8]) -> String { if a.len() != 16 { return String::new(); } let h: String = a.iter().map(|b| format!("{:02x}", b)).collect(); format!("{}-{}-{}-{}-{}", &h[..8], &h[8..12], &h[12..16], &h[16..20], &h[20..])}fn check_id(response: &Value) -> Result<String, String> { if response["type"] != "public-key" { return Err(format!("the credential's type is {}, and should be public-key", response["type"])); } let id = str_of(response, "id", "credential")?; if let Some(raw) = response["rawId"].as_str() { if unb64("rawId", raw)? != unb64("id", id)? { return Err("id and rawId differ".into()); } } unb64("id", id)?; Ok(id.to_string())}/// A new random challenge, 32 bytes as base64url. Keep it in the visitor's session and use it once.pub fn passkeys_challenge() -> Result<String, String> { Ok(b64(&random(32)?))}/// What `navigator.credentials.create` needs, ready for `PublicKeyCredential.parseCreationOptionsFromJSON`. `rp` is `{ id, name }`, `user` `{ id, name, display_name }` (`id` an opaque text of up to 64 bytes, never an e-mail address; made when left out). `options`: `exclude` (credential ids the user has), `attestation` (none, direct), `user_verification`, `resident_key` (preferred), `attachment`, `timeout_ms`, `challenge`.pub fn passkeys_register_options(rp: serde_json::Value, user: serde_json::Value, options: serde_json::Value) -> Result<Value, String> { let rp_id = str_of(&rp, "id", "rp")?; let name = user.get("name").and_then(Value::as_str).filter(|x| !x.is_empty()).ok_or("user: no name")?; let display = user.get("display_name").and_then(Value::as_str).unwrap_or(name); let id = match user.get("id").and_then(Value::as_str).filter(|x| !x.is_empty()) { Some(x) if x.len() > 64 => return Err("user.id is over 64 bytes".into()), Some(x) => b64(x.as_bytes()), None => b64(&random(32)?), }; let challenge = match options.get("challenge").and_then(Value::as_str) { Some(c) => c.to_string(), None => passkeys_challenge()?, }; let g = |k: &str, d: &str| options.get(k).and_then(Value::as_str).unwrap_or(d).to_string(); let exclude: Vec<Value> = options["exclude"].as_array().into_iter().flatten().map(|c| json!({ "type": "public-key", "id": c.as_str().or(c["credential_id"].as_str()).unwrap_or("") })).collect(); let mut selection = json!({ "residentKey": g("resident_key", "preferred"), "userVerification": g("user_verification", "preferred") }); if selection["residentKey"] == "required" { selection["requireResidentKey"] = json!(true); } if let Some(a) = options.get("attachment").and_then(Value::as_str) { selection["authenticatorAttachment"] = json!(a); } Ok(json!({ "challenge": challenge, "rp": { "id": rp_id, "name": rp.get("name").and_then(Value::as_str).unwrap_or(rp_id) }, "user": { "id": id, "name": name, "displayName": display }, "pubKeyCredParams": [{ "type": "public-key", "alg": ES256 }, { "type": "public-key", "alg": EDDSA }, { "type": "public-key", "alg": RS256 }], "timeout": options.get("timeout_ms").and_then(Value::as_i64).unwrap_or(300000), "attestation": g("attestation", "none"), "excludeCredentials": exclude, "authenticatorSelection": selection, }))}/// Checks what `navigator.credentials.create` gave (its `toJSON()`). `expected` is `{ challenge, origin (or a list), rp_id, require_user_verification, algorithms, allow_cross_origin }`. Gives what to keep for this user: `{ credential_id, public_key, algorithm, counter, transports, aaguid, attestation, certificate, user_verified, backup_eligible, backed_up }`. Attestation `none` and `packed` are checked, self-attested or by its certificate, whose chain is yours to judge; other formats are refused, so ask for `attestation: "none"`.pub fn passkeys_register_verify(response: serde_json::Value, expected: serde_json::Value) -> Result<Value, String> { let id = check_id(&response)?; let raw_client = client_data(&response, "webauthn.create", &expected)?; let object = unb64("attestationObject", str_of(&response["response"], "attestationObject", "response")?)?; let mut p = 0; let att = cbor(&object, &mut p, 0).map_err(|e| format!("attestationObject: {}", e))?; let fmt = match att.text("fmt") { Some(Cbor::Text(t)) => t.clone(), _ => return Err("attestationObject has no fmt".into()) }; let auth_data = att.text("authData").and_then(Cbor::bytes).ok_or("attestationObject has no authData")?; let stmt = att.text("attStmt").ok_or("attestationObject has no attStmt")?; let a = read_auth(auth_data)?; check_flags(&a, auth_data, &expected)?; if a.flags & AT == 0 { return Err("a registration must carry attested credential data (flag AT is off)".into()); } let key = a.key.as_ref().unwrap(); let alg = key_alg(key)?; if let Some(list) = expected["algorithms"].as_array() { if !list.iter().any(|x| x.as_i64() == Some(alg)) { return Err(format!("algorithm {} is not among those allowed", alg_name(alg))); } } if a.credential_id != unb64("id", &id)? { return Err("the credential id in the authenticator data is not the response's".into()); } let mut signed = auth_data.to_vec(); signed.extend_from_slice(&sha256(&raw_client)); let (kind, certificate) = match fmt.as_str() { "none" => { if !matches!(stmt, Cbor::Map(m) if m.is_empty()) { return Err("attestation format none has a statement, and it must be empty".into()); } ("none", Value::Null) } "packed" => { let st_alg = stmt.text("alg").and_then(Cbor::number).ok_or("packed attestation: no alg")? as i64; let sig = stmt.text("sig").and_then(Cbor::bytes).ok_or("packed attestation: no sig")?; match stmt.text("x5c") { None => { if st_alg != alg { return Err(format!("packed self attestation says {} and the credential is {}", st_alg, alg)); } if !verify(key, &signed, sig)? { return Err("the packed self attestation's signature is wrong".into()); } ("self", Value::Null) } Some(Cbor::Array(chain)) => { let cert = chain.first().and_then(Cbor::bytes).ok_or("packed attestation: x5c is empty")?; if st_alg != ES256 { return Err("packed attestation by certificate: only ES256 is read".into()); } if !verify_es256(&cert_p256_key(cert)?, &signed, sig)? { return Err("the packed attestation's signature is wrong".into()); } ("basic", json!(STANDARD.encode(cert))) } Some(_) => return Err("packed attestation: x5c is not a list".into()), } } o => return Err(format!("attestation format `{}` is not checked here: ask the browser for attestation \"none\"", o)), }; Ok(json!({ "credential_id": id, "public_key": b64(&a.key_bytes), "algorithm": alg_name(alg), "counter": a.counter, "transports": response["response"]["transports"], "aaguid": aaguid_text(&a.aaguid), "attestation": kind, "certificate": certificate, "user_verified": a.flags & UV != 0, "backup_eligible": a.flags & BE != 0, "backed_up": a.flags & BS != 0, }))}/// What `navigator.credentials.get` needs. `credentials` lists the user's ids (or kept credentials); an empty list lets the browser offer any passkey for this site. `options`: `user_verification`, `timeout_ms`, `challenge`.pub fn passkeys_signin_options(rp_id: &str, credentials: serde_json::Value, options: serde_json::Value) -> Result<Value, String> { let allow: Vec<Value> = credentials.as_array().into_iter().flatten().map(|c| { let mut x = json!({ "type": "public-key", "id": c.as_str().or(c["credential_id"].as_str()).unwrap_or("") }); if c["transports"].is_array() { x["transports"] = c["transports"].clone(); } x }).collect(); let challenge = match options.get("challenge").and_then(Value::as_str) { Some(c) => c.to_string(), None => passkeys_challenge()?, }; Ok(json!({ "challenge": challenge, "rpId": rp_id, "allowCredentials": allow, "userVerification": options.get("user_verification").and_then(Value::as_str).unwrap_or("preferred"), "timeout": options.get("timeout_ms").and_then(Value::as_i64).unwrap_or(300000), }))}/// Checks what `navigator.credentials.get` gave against a kept `credential` (`credential_id`, `public_key`, `counter`, as registration gave them, and `user_handle` if you hold one). `expected` as for registering. A counter that did not go up, when either side counts, is refused: the key may have been copied. Gives `{ credential_id, counter, user_verified, backed_up, user_handle }`: save the new `counter` and `backed_up`.pub fn passkeys_signin_verify(response: serde_json::Value, credential: serde_json::Value, expected: serde_json::Value) -> Result<Value, String> { let id = check_id(&response)?; if credential["credential_id"] != json!(id) { return Err("this response is for another credential".into()); } let raw_client = client_data(&response, "webauthn.get", &expected)?; let r = &response["response"]; let auth_data = unb64("authenticatorData", str_of(r, "authenticatorData", "response")?)?; let a = read_auth(&auth_data)?; check_flags(&a, &auth_data, &expected)?; if credential["backup_eligible"] == json!(false) && a.flags & BE != 0 || credential["backup_eligible"] == json!(true) && a.flags & BE == 0 { return Err("backup eligibility changed since registration, which a credential cannot do".into()); } let key_bytes = unb64("public_key", str_of(&credential, "public_key", "credential")?)?; let mut p = 0; let key = cbor(&key_bytes, &mut p, 0).map_err(|e| format!("the kept public key: {}", e))?; let mut signed = auth_data.clone(); signed.extend_from_slice(&sha256(&raw_client)); let sig = unb64("signature", str_of(r, "signature", "response")?)?; if !verify(&key, &signed, &sig)? { return Err("the signature is wrong".into()); } let handle = r["userHandle"].as_str().filter(|x| !x.is_empty()).map(str::to_string); if let (Some(h), Some(want)) = (&handle, credential["user_handle"].as_str()) { if unb64("userHandle", h)? != unb64("user_handle", want)? { return Err("the userHandle is not this credential's user".into()); } } let stored = credential["counter"].as_u64().unwrap_or(0); if (a.counter != 0 || stored != 0) && a.counter as u64 <= stored { return Err(format!("the signature counter went from {} to {}: this key may have been copied", stored, a.counter)); } Ok(json!({ "credential_id": id, "counter": a.counter, "user_verified": a.flags & UV != 0, "backup_eligible": a.flags & BE != 0, "backed_up": a.flags & BS != 0, "user_handle": handle, }))}/// What a kept COSE public key is: `{ algorithm, kty, curve }`.pub fn passkeys_key_info(public_key: &str) -> Result<Value, String> { let b = unb64("public_key", public_key)?; let mut p = 0; let k = cbor(&b, &mut p, 0).map_err(|e| format!("the public key: {}", e))?; let alg = key_alg(&k)?; Ok(json!({ "algorithm": alg_name(alg), "kty": match alg { ES256 => "EC2", EDDSA => "OKP", _ => "RSA" }, "curve": match alg { ES256 => json!("P-256"), EDDSA => json!("Ed25519"), _ => Value::Null } }))}Related
oauth_pkce.qwer · 1.0.0Sign in with Google, GitHub or Microsoft made safe: PKCE verifier and challenge, state, the authorization link and the token request.6 views · 6 downloadsOpenID Connect sign-in oidc.qwer · 1.1.0OpenID Connect sign-in: discovery, auth URLs with state, nonce and PKCE, the code exchanged, ID tokens checked against the provider's JWKS (RS256 and ES256, issuer, audience, expiry, nonce, at_hash, new keys fetched), userinfo, refresh and sign-out; and for a program calling an API as itself, the client credentials grant and access tokens checked (RFC 9068).4 views · 7 downloadsTwo-factor codes totp.qwer · 1.0.0Two-factor sign-in with authenticator apps: secrets, the 6-digit codes, checking them, and the otpauth:// link to scan.6 views · 8 downloadsThread 0 posts
On Passkeys: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.