Public: anyone can read this; the crew can change it.
src/auth/webauthn.ts verifies passkey registration and assertion (ES256, RS256) with no dependency, tested with a software authenticator #1700
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/1693-src-auth-webauthn-ts-verifies-passkey-re → main 2 commits fork 4 threads · 4 unresolved
scripts/gate.ts +1
| @@ -32,6 +32,7 @@ export const GATE: readonly string[] = [ | ||
| 32 | 32 | "test/apps-idle.test.ts", |
| 33 | 33 | "test/body-limits.test.ts", |
| 34 | 34 | "test/boards-query.test.ts", |
| 35 | "test/webauthn.test.ts", | |
| 35 | 36 | "test/chaos.test.ts", |
| 36 | 37 | "test/fuzz-found.test.ts", |
| 37 | 38 | "test/fuzz-parsers.test.ts", |
src/auth/webauthn.ts added +273
| @@ -0,0 +1,273 @@ | ||
| 1 | /** | |
| 2 | * WebAuthn passkey verification with no dependency: registration and | |
| 3 | * assertion for ES256 and RS256. Plain data in (base64url strings as the | |
| 4 | * browser sends them), plain data out (a result or a named refusal). It | |
| 5 | * holds no state and has no route; `challenges()` is the one-time store a | |
| 6 | * caller keeps between issuing a challenge and verifying its answer. | |
| 7 | * Only `none` attestation is accepted : the key is trusted on first use. | |
| 8 | */ | |
| 9 | ||
| 10 | export type Jwk = { kty: "EC"; crv: "P-256"; x: string; y: string } | { kty: "RSA"; n: string; e: string }; | |
| 11 | export type Alg = "ES256" | "RS256"; | |
| 12 | export type Credential = { id: string; alg: Alg; key: Jwk; signCount: number }; | |
| 13 | export type Refusal = { ok: false; error: string }; | |
| 14 | ||
| 15 | const MAX_INPUT = 16 * 1024; | |
| 16 | const COSE_ALG = { "-7": "ES256", "-257": "RS256" } as const; | |
| 17 | const te = new TextEncoder(); | |
| 18 | const fail = (error: string): Refusal => ({ ok: false, error }); | |
| 19 | ||
| 20 | export function b64u(bytes: Uint8Array): string { | |
| 21 | return Buffer.from(bytes).toString("base64url"); | |
| 22 | } | |
| 23 | ||
| 24 | type Bytes = Uint8Array<ArrayBuffer>; | |
| 25 | ||
| 26 | function unb64u(s: unknown): Bytes | null { | |
| 27 | if (typeof s !== "string" || s.length > MAX_INPUT || !/^[A-Za-z0-9_-]*$/.test(s)) return null; | |
| 28 | return new Uint8Array(Buffer.from(s, "base64url")); | |
| 29 | } | |
| 30 | ||
| 31 | function equal(a: Uint8Array, b: Uint8Array): boolean { | |
| 32 | if (a.length !== b.length) return false; | |
| 33 | let d = 0; | |
| 34 | for (let i = 0; i < a.length; i++) d |= a[i]! ^ b[i]!; | |
| 35 | return d === 0; | |
| 36 | } | |
| 37 | ||
| 38 | /** A fresh random challenge, base64url. */ | |
| 39 | export function newChallenge(): string { | |
| 40 | return b64u(crypto.getRandomValues(new Uint8Array(32))); | |
| 41 | } | |
| 42 | ||
| 43 | /** | |
| 44 | * Outstanding challenges: each answers once, and only before it expires. | |
| 45 | * One global store: when it gets a route, rate-limit `issue()` per socket | |
| 46 | * address or key challenges by session, or a flood evicts real ones. | |
| 47 | */ | |
| 48 | export function challenges(ttlMs = 5 * 60_000, now: () => number = Date.now, max = 10_000) { | |
| 49 | const open = new Map<string, number>(); | |
| 50 | return { | |
| 51 | issue(): string { | |
| 52 | for (const [c, at] of open) if (at <= now()) open.delete(c); | |
| 53 | if (open.size >= max) open.delete(open.keys().next().value!); | |
| 54 | const c = newChallenge(); | |
| 55 | open.set(c, now() + ttlMs); | |
| 56 | return c; | |
| 57 | }, | |
| 58 | /** True once per issued, unexpired challenge; a replay is false. */ | |
| 59 | consume(challenge: unknown): boolean { | |
| 60 | if (typeof challenge !== "string") return false; | |
| 61 | const at = open.get(challenge); | |
| 62 | open.delete(challenge); | |
| 63 | return at !== undefined && at > now(); | |
| 64 | }, | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | // --- CBOR (the subset WebAuthn uses) --------------------------------------- | |
| 69 | ||
| 70 | type Cbor = number | Uint8Array | string | Cbor[] | Map<Cbor, Cbor> | boolean | null; | |
| 71 | ||
| 72 | function cbor(b: Uint8Array, at = 0, depth = 0): [Cbor, number] { | |
| 73 | if (depth > 8 || at >= b.length) throw new Error("cbor"); | |
| 74 | const major = b[at]! >> 5; | |
| 75 | const info = b[at]! & 31; | |
| 76 | let p = at + 1; | |
| 77 | let n = info; | |
| 78 | if (info === 24) n = b[p++]!; | |
| 79 | else if (info === 25) { n = (b[p]! << 8) | b[p + 1]!; p += 2; } | |
| 80 | else if (info === 26) { n = new DataView(b.buffer, b.byteOffset).getUint32(p); p += 4; } | |
| 81 | else if (info > 26) { | |
| 82 | if (major === 7 && info >= 20 && info <= 22) return [info === 20 ? false : info === 21 ? true : null, p]; | |
| 83 | throw new Error("cbor"); | |
| 84 | } | |
| 85 | if (p > b.length) throw new Error("cbor"); | |
| 86 | switch (major) { | |
| 87 | case 0: return [n, p]; | |
| 88 | case 1: return [-1 - n, p]; | |
| 89 | case 2: | |
| 90 | case 3: { | |
| 91 | if (p + n > b.length) throw new Error("cbor"); | |
| 92 | const s = b.subarray(p, p + n); | |
| 93 | return [major === 2 ? s : new TextDecoder("utf-8", { fatal: true }).decode(s), p + n]; | |
| 94 | } | |
| 95 | case 4: { | |
| 96 | if (n > 64) throw new Error("cbor"); | |
| 97 | const a: Cbor[] = []; | |
| 98 | for (let i = 0; i < n; i++) { const [v, q] = cbor(b, p, depth + 1); a.push(v); p = q; } | |
| 99 | return [a, p]; | |
| 100 | } | |
| 101 | case 5: { | |
| 102 | if (n > 64) throw new Error("cbor"); | |
| 103 | const m = new Map<Cbor, Cbor>(); | |
| 104 | for (let i = 0; i < n; i++) { | |
| 105 | const [k, q] = cbor(b, p, depth + 1); | |
| 106 | const [v, r] = cbor(b, q, depth + 1); | |
| 107 | m.set(k, v); | |
| 108 | p = r; | |
| 109 | } | |
| 110 | return [m, p]; | |
| 111 | } | |
| 112 | default: throw new Error("cbor"); | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | // --- shared checks ---------------------------------------------------------- | |
| 117 | ||
| 118 | /** User verification (PIN or biometric) is required unless the caller says "preferred". */ | |
| 119 | type Expect = { challenge: string; origin: string; rpId: string; userVerification?: "required" | "preferred" }; | |
| 120 | ||
| 121 | function flagsRefusal(flags: number, want: Expect): Refusal | null { | |
| 122 | if (!(flags & 0x01)) return fail("user presence missing"); | |
outdated
No test fails if this check is removed, or the |
||
| 123 | if (want.userVerification !== "preferred" && !(flags & 0x04)) return fail("user verification missing"); | |
| 124 | return null; | |
| 125 | } | |
| 126 | ||
| 127 | function clientData(raw: unknown, type: string, want: Expect): Refusal | null { | |
| 128 | const bytes = unb64u(raw); | |
| 129 | if (!bytes) return fail("clientDataJSON is not base64url"); | |
| 130 | let cd: any; | |
| 131 | try { cd = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); } catch { return fail("clientDataJSON is not JSON"); } | |
| 132 | if (!cd || typeof cd !== "object") return fail("clientDataJSON is not JSON"); | |
| 133 | if (cd.type !== type) return fail("wrong ceremony type"); | |
| 134 | if (typeof cd.challenge !== "string" || !equal(te.encode(cd.challenge), te.encode(want.challenge))) return fail("challenge does not match"); | |
| 135 | if (cd.origin !== want.origin) return fail("origin does not match"); | |
| 136 | if (cd.crossOrigin === true) return fail("cross-origin ceremony refused"); | |
| 137 | return null; | |
| 138 | } | |
| 139 | ||
| 140 | async function rpIdHashOk(authData: Uint8Array, rpId: string): Promise<boolean> { | |
| 141 | const h = new Uint8Array(await crypto.subtle.digest("SHA-256", te.encode(rpId))); | |
| 142 | return equal(authData.subarray(0, 32), h); | |
| 143 | } | |
| 144 | ||
| 145 | const u32 = (b: Uint8Array, at: number) => new DataView(b.buffer, b.byteOffset).getUint32(at); | |
| 146 | ||
| 147 | // --- registration ----------------------------------------------------------- | |
| 148 | ||
| 149 | export type Registration = { | |
| 150 | clientDataJSON: string; | |
| 151 | attestationObject: string; | |
| 152 | }; | |
| 153 | ||
| 154 | /** Verify a `navigator.credentials.create` answer; return the credential to store. */ | |
| 155 | export async function verifyRegistration( | |
| 156 | response: Registration, | |
| 157 | want: Expect, | |
| 158 | ): Promise<({ ok: true; uv: boolean } & Credential) | Refusal> { | |
| 159 | try { | |
| 160 | const bad = clientData(response?.clientDataJSON, "webauthn.create", want); | |
| 161 | if (bad) return bad; | |
| 162 | const att = unb64u(response.attestationObject); | |
| 163 | if (!att) return fail("attestationObject is not base64url"); | |
| 164 | const [obj] = cbor(att); | |
| 165 | if (!(obj instanceof Map)) return fail("attestationObject is malformed"); | |
| 166 | if (obj.get("fmt") !== "none") return fail("only none attestation is accepted"); | |
| 167 | const ad = obj.get("authData"); | |
| 168 | if (!(ad instanceof Uint8Array) || ad.length < 55) return fail("authData is malformed"); | |
| 169 | if (!(await rpIdHashOk(ad, want.rpId))) return fail("rpId does not match"); | |
| 170 | const flags = ad[32]!; | |
| 171 | const flagBad = flagsRefusal(flags, want); | |
| 172 | if (flagBad) return flagBad; | |
| 173 | if (!(flags & 0x40)) return fail("no credential in authData"); | |
| 174 | const idLen = (ad[53]! << 8) | ad[54]!; | |
| 175 | if (idLen < 1 || idLen > 1023 || 55 + idLen >= ad.length) return fail("authData is malformed"); | |
| 176 | const id = ad.subarray(55, 55 + idLen); | |
| 177 | const [cose] = cbor(ad, 55 + idLen); | |
| 178 | if (!(cose instanceof Map)) return fail("public key is malformed"); | |
| 179 | const alg = COSE_ALG[String(cose.get(3)) as keyof typeof COSE_ALG]; | |
| 180 | if (!alg) return fail("unsupported algorithm"); | |
| 181 | let key: Jwk; | |
| 182 | if (alg === "ES256") { | |
| 183 | const x = cose.get(-2), y = cose.get(-3); | |
| 184 | if (cose.get(1) !== 2 || cose.get(-1) !== 1 || !(x instanceof Uint8Array) || !(y instanceof Uint8Array) || x.length !== 32 || y.length !== 32) | |
| 185 | return fail("public key is malformed"); | |
| 186 | key = { kty: "EC", crv: "P-256", x: b64u(x), y: b64u(y) }; | |
| 187 | } else { | |
| 188 | const n = cose.get(-1), e = cose.get(-2); | |
| 189 | if (cose.get(1) !== 3 || !(n instanceof Uint8Array) || !(e instanceof Uint8Array) || n.length < 256 || n.length > 1024 || e.length < 1 || e.length > 8) | |
| 190 | return fail("public key is malformed"); | |
| 191 | key = { kty: "RSA", n: b64u(n), e: b64u(e) }; | |
| 192 | } | |
| 193 | await importKey(alg, key); // refuse a key the platform cannot use | |
| 194 | return { ok: true, id: b64u(id), alg, key, signCount: u32(ad, 33), uv: !!(flags & 0x04) }; | |
| 195 | } catch { | |
| 196 | return fail("registration is malformed"); | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | function importKey(alg: Alg, jwk: Jwk): Promise<CryptoKey> { | |
| 201 | const algo = alg === "ES256" ? { name: "ECDSA", namedCurve: "P-256" } : { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }; | |
| 202 | return crypto.subtle.importKey("jwk", jwk as any, algo, false, ["verify"]); | |
| 203 | } | |
| 204 | ||
| 205 | // --- assertion -------------------------------------------------------------- | |
| 206 | ||
| 207 | export type Assertion = { | |
| 208 | clientDataJSON: string; | |
| 209 | authenticatorData: string; | |
| 210 | signature: string; | |
| 211 | }; | |
| 212 | ||
| 213 | /** ASN.1 DER ECDSA signature to the raw r||s WebCrypto verifies. */ | |
| 214 | export function derToRaw(der: Uint8Array): Bytes | null { | |
| 215 | let p = 0; | |
| 216 | const len = () => { | |
| 217 | let l = der[p++]!; | |
| 218 | if (l & 0x80) { const k = l & 0x7f; if (k < 1 || k > 2) return -1; l = 0; for (let i = 0; i < k; i++) l = (l << 8) | der[p++]!; } | |
| 219 | return l; | |
| 220 | }; | |
| 221 | if (der[p++] !== 0x30) return null; | |
| 222 | if (len() !== der.length - p) return null; | |
| 223 | const out: Bytes = new Uint8Array(64); | |
| 224 | for (let half = 0; half < 2; half++) { | |
| 225 | if (der[p++] !== 0x02) return null; | |
| 226 | let l = len(); | |
| 227 | if (l < 1 || p + l > der.length) return null; | |
| 228 | let int = der.subarray(p, p + l); | |
| 229 | p += l; | |
| 230 | while (int.length > 32 && int[0] === 0) int = int.subarray(1); | |
outdated
High: the caller cannot see the UV flag (0x04), so it cannot require user verification. Require UV by default, with an explicit opt-out, or at least return |
||
| 231 | if (int.length > 32) return null; | |
| 232 | out.set(int, half * 32 + 32 - int.length); | |
| 233 | } | |
| 234 | return p === der.length ? out : null; | |
| 235 | } | |
| 236 | ||
| 237 | /** Verify a `navigator.credentials.get` answer against the stored credential. */ | |
| 238 | export async function verifyAssertion( | |
| 239 | response: Assertion, | |
| 240 | want: Expect, | |
outdated
No test fails if this user-presence check (or the one at line 160) is removed. Add an authenticator option that clears 0x01 and assert the refusal. |
||
| 241 | stored: Credential, | |
| 242 | ): Promise<{ ok: true; signCount: number; uv: boolean } | Refusal> { | |
| 243 | try { | |
| 244 | const bad = clientData(response?.clientDataJSON, "webauthn.get", want); | |
| 245 | if (bad) return bad; | |
| 246 | const ad = unb64u(response.authenticatorData); | |
| 247 | const sig = unb64u(response.signature); | |
| 248 | const cdBytes = unb64u(response.clientDataJSON)!; | |
| 249 | if (!ad || ad.length < 37) return fail("authenticatorData is malformed"); | |
| 250 | if (!sig || sig.length === 0) return fail("signature is malformed"); | |
| 251 | if (!(await rpIdHashOk(ad, want.rpId))) return fail("rpId does not match"); | |
| 252 | const flagBad = flagsRefusal(ad[32]!, want); | |
| 253 | if (flagBad) return flagBad; | |
| 254 | const signed: Bytes = new Uint8Array(ad.length + 32); | |
| 255 | signed.set(ad); | |
| 256 | signed.set(new Uint8Array(await crypto.subtle.digest("SHA-256", cdBytes)), ad.length); | |
| 257 | const key = await importKey(stored.alg, stored.key); | |
| 258 | let ok: boolean; | |
| 259 | if (stored.alg === "ES256") { | |
| 260 | const raw = derToRaw(sig); | |
| 261 | ok = !!raw && (await crypto.subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, raw, signed)); | |
| 262 | } else { | |
| 263 | ok = await crypto.subtle.verify("RSASSA-PKCS1-v1_5", key, sig, signed); | |
| 264 | } | |
| 265 | if (!ok) return fail("signature does not verify"); | |
| 266 | const signCount = u32(ad, 33); | |
| 267 | if ((signCount !== 0 || stored.signCount !== 0) && signCount <= stored.signCount) | |
| 268 | return fail("signCount did not increase: the authenticator may be cloned"); | |
| 269 | return { ok: true, signCount, uv: !!(ad[32]! & 0x04) }; | |
| 270 | } catch { | |
| 271 | return fail("assertion is malformed"); | |
| 272 | } | |
| 273 | } |
test/webauthn.test.ts added +189
| @@ -0,0 +1,189 @@ | ||
| 1 | /** | |
| 2 | * Passkey verification, against a software authenticator that makes real | |
| 3 | * keys and signs like a browser's: registration and assertion for ES256 and | |
| 4 | * RS256, and the refusals (wrong origin, replayed challenge, signCount | |
| 5 | * going backwards, wrong rpId, bad signature, attestation other than none). | |
| 6 | */ | |
| 7 | import { describe, expect, test } from "bun:test"; | |
| 8 | import { b64u, challenges, derToRaw, verifyAssertion, verifyRegistration, type Alg, type Credential } from "../src/auth/webauthn.ts"; | |
| 9 | ||
| 10 | const ORIGIN = "https://forge.example"; | |
| 11 | const RP = "forge.example"; | |
| 12 | const te = new TextEncoder(); | |
| 13 | const sha = async (d: Uint8Array<ArrayBuffer>) => new Uint8Array(await crypto.subtle.digest("SHA-256", d)); | |
| 14 | const cat = (...p: Uint8Array[]): Uint8Array<ArrayBuffer> => { const o: Uint8Array<ArrayBuffer> = new Uint8Array(p.reduce((n, a) => n + a.length, 0)); let i = 0; for (const a of p) { o.set(a, i); i += a.length; } return o; }; | |
| 15 | ||
| 16 | // A minimal CBOR encoder, enough for an attestation object. | |
| 17 | function head(major: number, n: number): Uint8Array { | |
| 18 | if (n < 24) return Uint8Array.of((major << 5) | n); | |
| 19 | if (n < 256) return Uint8Array.of((major << 5) | 24, n); | |
| 20 | return Uint8Array.of((major << 5) | 25, n >> 8, n & 255); | |
| 21 | } | |
| 22 | type V = number | string | Uint8Array | [V, V][]; | |
| 23 | function enc(v: V): Uint8Array { | |
| 24 | if (typeof v === "number") return v >= 0 ? head(0, v) : head(1, -1 - v); | |
| 25 | if (typeof v === "string") { const b = te.encode(v); return cat(head(3, b.length), b); } | |
| 26 | if (v instanceof Uint8Array) return cat(head(2, v.length), v); | |
| 27 | return cat(head(5, v.length), ...v.flatMap(([k, x]) => [enc(k), enc(x)])); | |
| 28 | } | |
| 29 | ||
| 30 | function rawToDer(raw: Uint8Array): Uint8Array<ArrayBuffer> { | |
| 31 | const int = (b: Uint8Array) => { let i = 0; while (i < b.length - 1 && b[i] === 0) i++; b = b.subarray(i); if (b[0]! & 0x80) b = cat(Uint8Array.of(0), b); return cat(Uint8Array.of(2, b.length), b); }; | |
| 32 | const body = cat(int(raw.subarray(0, 32)), int(raw.subarray(32))); | |
| 33 | return cat(Uint8Array.of(0x30, body.length), body); | |
| 34 | } | |
| 35 | ||
| 36 | async function authenticator(alg: Alg) { | |
| 37 | const pair = (await (alg === "ES256" | |
| 38 | ? crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]) | |
| 39 | : crypto.subtle.generateKey({ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: Uint8Array.of(1, 0, 1), hash: "SHA-256" }, true, ["sign"]))) as CryptoKeyPair; | |
| 40 | const jwk = await crypto.subtle.exportKey("jwk", pair.publicKey); | |
| 41 | const unb = (s: string) => new Uint8Array(Buffer.from(s, "base64url")); | |
| 42 | const cose: [number, V][] = alg === "ES256" | |
| 43 | ? [[1, 2], [3, -7], [-1, 1], [-2, unb(jwk.x!)], [-3, unb(jwk.y!)]] | |
| 44 | : [[1, 3], [3, -257], [-1, unb(jwk.n!)], [-2, unb(jwk.e!)]]; | |
| 45 | const id = crypto.getRandomValues(new Uint8Array(32)); | |
| 46 | const cd = (type: string, challenge: string, origin = ORIGIN, extra: object = {}) => te.encode(JSON.stringify({ type, challenge, origin, ...extra })); | |
| 47 | const counter = (n: number) => Uint8Array.of(n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255); | |
| 48 | return { | |
| 49 | id, | |
| 50 | async create(challenge: string, o: { origin?: string; rp?: string; fmt?: string; type?: string; flags?: number; extra?: object } = {}) { | |
| 51 | const authData = cat(await sha(te.encode(o.rp ?? RP)), Uint8Array.of(o.flags ?? 0x45), counter(0), new Uint8Array(16), Uint8Array.of(0, id.length), id, enc(cose as V)); | |
| 52 | return { | |
| 53 | clientDataJSON: b64u(cd(o.type ?? "webauthn.create", challenge, o.origin, o.extra)), | |
| 54 | attestationObject: b64u(enc([["fmt", o.fmt ?? "none"], ["attStmt", []], ["authData", authData]])), | |
| 55 | }; | |
| 56 | }, | |
| 57 | async get(challenge: string, signCount: number, o: { origin?: string; rp?: string; flags?: number; type?: string; extra?: object } = {}) { | |
| 58 | const clientData = cd(o.type ?? "webauthn.get", challenge, o.origin, o.extra); | |
| 59 | const authData = cat(await sha(te.encode(o.rp ?? RP)), Uint8Array.of(o.flags ?? 0x05), counter(signCount)); | |
| 60 | const signed = cat(authData, await sha(clientData)); | |
| 61 | let sig: Uint8Array<ArrayBuffer> = new Uint8Array(await crypto.subtle.sign(alg === "ES256" ? { name: "ECDSA", hash: "SHA-256" } : "RSASSA-PKCS1-v1_5", pair.privateKey, signed)); | |
| 62 | if (alg === "ES256") sig = rawToDer(sig); | |
| 63 | return { clientDataJSON: b64u(clientData), authenticatorData: b64u(authData), signature: b64u(sig) }; | |
| 64 | }, | |
| 65 | }; | |
| 66 | } | |
| 67 | ||
| 68 | const want = (challenge: string) => ({ challenge, origin: ORIGIN, rpId: RP }); | |
| 69 | ||
| 70 | for (const alg of ["ES256", "RS256"] as const) { | |
| 71 | describe(alg, () => { | |
| 72 | test("registers, then asserts with a rising signCount", async () => { | |
| 73 | const a = await authenticator(alg); | |
| 74 | const reg = await verifyRegistration(await a.create("c1"), want("c1")); | |
| 75 | if (!reg.ok) throw new Error(reg.error); | |
| 76 | expect(reg.alg).toBe(alg); | |
| 77 | expect(reg.id).toBe(b64u(a.id)); | |
| 78 | expect(reg.signCount).toBe(0); | |
| 79 | const stored: Credential = { id: reg.id, alg: reg.alg, key: reg.key, signCount: reg.signCount }; | |
| 80 | const one = await verifyAssertion(await a.get("c2", 1), want("c2"), stored); | |
| 81 | expect(one).toEqual({ ok: true, signCount: 1, uv: true }); | |
| 82 | stored.signCount = 5; | |
| 83 | const back = await verifyAssertion(await a.get("c3", 5), want("c3"), stored); | |
| 84 | expect(back).toEqual({ ok: false, error: expect.stringContaining("signCount") }); | |
| 85 | const lower = await verifyAssertion(await a.get("c3", 2), want("c3"), stored); | |
| 86 | expect(lower.ok).toBe(false); | |
| 87 | expect((await verifyAssertion(await a.get("c3", 6), want("c3"), stored)).ok).toBe(true); | |
| 88 | }); | |
| 89 | ||
| 90 | test("refuses a wrong origin, rpId and challenge", async () => { | |
| 91 | const a = await authenticator(alg); | |
| 92 | expect(await verifyRegistration(await a.create("c", { origin: "https://evil.example" }), want("c"))).toEqual({ ok: false, error: "origin does not match" }); | |
| 93 | expect(await verifyRegistration(await a.create("c", { rp: "evil.example" }), want("c"))).toEqual({ ok: false, error: "rpId does not match" }); | |
| 94 | expect(await verifyRegistration(await a.create("c"), want("other"))).toEqual({ ok: false, error: "challenge does not match" }); | |
| 95 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 96 | if (!reg.ok) throw new Error(reg.error); | |
| 97 | const stored: Credential = { id: reg.id, alg, key: reg.key, signCount: 0 }; | |
| 98 | expect(await verifyAssertion(await a.get("c", 1, { origin: "https://evil.example" }), want("c"), stored)).toEqual({ ok: false, error: "origin does not match" }); | |
| 99 | expect(await verifyAssertion(await a.get("c", 1, { rp: "evil.example" }), want("c"), stored)).toEqual({ ok: false, error: "rpId does not match" }); | |
| 100 | }); | |
| 101 | ||
| 102 | test("refuses a signature from another key or a tampered one", async () => { | |
| 103 | const a = await authenticator(alg); | |
| 104 | const b = await authenticator(alg); | |
| 105 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 106 | if (!reg.ok) throw new Error(reg.error); | |
| 107 | const stored: Credential = { id: reg.id, alg, key: reg.key, signCount: 0 }; | |
| 108 | expect((await verifyAssertion(await b.get("c", 1), want("c"), stored)).ok).toBe(false); | |
| 109 | const good = await a.get("c", 1); | |
| 110 | const sig = Buffer.from(good.signature, "base64url"); | |
| 111 | sig[sig.length - 1]! ^= 1; | |
| 112 | expect((await verifyAssertion({ ...good, signature: sig.toString("base64url") }, want("c"), stored)).ok).toBe(false); | |
| 113 | }); | |
| 114 | }); | |
| 115 | } | |
| 116 | ||
| 117 | test("a first assertion with signCount 0 is fine when the authenticator keeps no counter", async () => { | |
| 118 | const a = await authenticator("ES256"); | |
| 119 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 120 | if (!reg.ok) throw new Error(reg.error); | |
| 121 | const stored: Credential = { id: reg.id, alg: "ES256", key: reg.key, signCount: 0 }; | |
| 122 | expect((await verifyAssertion(await a.get("c", 0), want("c"), stored)).ok).toBe(true); | |
| 123 | }); | |
| 124 | ||
| 125 | test("user presence and user verification are required, in both ceremonies", async () => { | |
| 126 | const a = await authenticator("ES256"); | |
| 127 | expect(await verifyRegistration(await a.create("c", { flags: 0x44 }), want("c"))).toEqual({ ok: false, error: "user presence missing" }); | |
| 128 | expect(await verifyRegistration(await a.create("c", { flags: 0x41 }), want("c"))).toEqual({ ok: false, error: "user verification missing" }); | |
| 129 | const soft = await verifyRegistration(await a.create("c", { flags: 0x41 }), { ...want("c"), userVerification: "preferred" }); | |
| 130 | if (!soft.ok) throw new Error(soft.error); | |
| 131 | expect(soft.uv).toBe(false); | |
| 132 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 133 | if (!reg.ok) throw new Error(reg.error); | |
| 134 | expect(reg.uv).toBe(true); | |
| 135 | const stored: Credential = { id: reg.id, alg: "ES256", key: reg.key, signCount: 0 }; | |
| 136 | expect(await verifyAssertion(await a.get("c", 1, { flags: 0x04 }), want("c"), stored)).toEqual({ ok: false, error: "user presence missing" }); | |
| 137 | expect(await verifyAssertion(await a.get("c", 1, { flags: 0x01 }), want("c"), stored)).toEqual({ ok: false, error: "user verification missing" }); | |
| 138 | expect(await verifyAssertion(await a.get("c", 1, { flags: 0x01 }), { ...want("c"), userVerification: "preferred" }, stored)).toEqual({ ok: true, signCount: 1, uv: false }); | |
| 139 | }); | |
| 140 | ||
| 141 | test("the ceremony type and cross-origin are checked", async () => { | |
| 142 | const a = await authenticator("ES256"); | |
| 143 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 144 | if (!reg.ok) throw new Error(reg.error); | |
| 145 | const stored: Credential = { id: reg.id, alg: "ES256", key: reg.key, signCount: 0 }; | |
| 146 | expect(await verifyAssertion(await a.get("c", 1, { type: "webauthn.create" }), want("c"), stored)).toEqual({ ok: false, error: "wrong ceremony type" }); | |
| 147 | expect(await verifyRegistration(await a.create("c", { type: "webauthn.get" }), want("c"))).toEqual({ ok: false, error: "wrong ceremony type" }); | |
| 148 | expect(await verifyAssertion(await a.get("c", 1, { extra: { crossOrigin: true } }), want("c"), stored)).toEqual({ ok: false, error: "cross-origin ceremony refused" }); | |
| 149 | expect(await verifyRegistration(await a.create("c", { extra: { crossOrigin: true } }), want("c"))).toEqual({ ok: false, error: "cross-origin ceremony refused" }); | |
| 150 | }); | |
| 151 | ||
| 152 | test("garbage given to verifyAssertion is refused, never thrown", async () => { | |
| 153 | const a = await authenticator("ES256"); | |
| 154 | const reg = await verifyRegistration(await a.create("c"), want("c")); | |
| 155 | if (!reg.ok) throw new Error(reg.error); | |
| 156 | const stored: Credential = { id: reg.id, alg: "ES256", key: reg.key, signCount: 0 }; | |
| 157 | const good = await a.get("c", 1); | |
| 158 | for (const bad of [undefined, null, {}, { ...good, clientDataJSON: "!!" }, { ...good, authenticatorData: "AA" }, { ...good, signature: "" }, { ...good, signature: "AAAA" }, { ...good, signature: 7 }]) | |
| 159 | expect((await verifyAssertion(bad as any, want("c"), stored)).ok).toBe(false); | |
| 160 | }); | |
| 161 | ||
| 162 | test("attestation other than none is refused", async () => { | |
| 163 | const a = await authenticator("ES256"); | |
| 164 | expect(await verifyRegistration(await a.create("c", { fmt: "packed" }), want("c"))).toEqual({ ok: false, error: "only none attestation is accepted" }); | |
| 165 | }); | |
| 166 | ||
| 167 | test("garbage is refused, never thrown", async () => { | |
| 168 | for (const bad of [undefined, null, {}, { clientDataJSON: "!!", attestationObject: "x" }, { clientDataJSON: b64u(te.encode("{}")), attestationObject: "AA" }]) | |
| 169 | expect((await verifyRegistration(bad as any, want("c"))).ok).toBe(false); | |
| 170 | expect(derToRaw(Uint8Array.of(1, 2, 3))).toBeNull(); | |
| 171 | }); | |
| 172 | ||
| 173 | describe("challenges", () => { | |
| 174 | test("a challenge answers once: a replay is refused", () => { | |
| 175 | const c = challenges(); | |
| 176 | const x = c.issue(); | |
| 177 | expect(c.consume(x)).toBe(true); | |
| 178 | expect(c.consume(x)).toBe(false); | |
| 179 | expect(c.consume("never-issued")).toBe(false); | |
| 180 | expect(c.consume(undefined)).toBe(false); | |
| 181 | }); | |
| 182 | test("a challenge expires", () => { | |
| 183 | let t = 0; | |
| 184 | const c = challenges(1000, () => t); | |
| 185 | const x = c.issue(); | |
| 186 | t = 1000; | |
| 187 | expect(c.consume(x)).toBe(false); | |
| 188 | }); | |
| 189 | }); |
Low: with FIFO eviction, a flood of
issue()calls pushes out real users' challenges. Once this has a route, rate-limitissue()on the socket address or the identity, or key the challenges by session.