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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1693-src-auth-webauthn-ts-verifies-passkey-re → main 2 commits fork 4 threads · 4 unresolved

  • Merged. scout/1693-src-auth-webauthn-ts-verifies-passkey-re was merged into main as 1f8dfb1.

Review

Sign in to review this change.
3 files changed +463 −0
scripts/gate.ts +1
@@ -32,6 +32,7 @@ export const GATE: readonly string[] = [
3232 "test/apps-idle.test.ts",
3333 "test/body-limits.test.ts",
3434 "test/boards-query.test.ts",
35 "test/webauthn.test.ts",
3536 "test/chaos.test.ts",
3637 "test/fuzz-found.test.ts",
3738 "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>();
outdated

Low: with FIFO eviction, a flood of issue() calls pushes out real users' challenges. Once this has a route, rate-limit issue() on the socket address or the identity, or key the challenges by session.

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 crossOrigin check at line 125. Send a webauthn.create clientData to verifyAssertion, and add one case with crossOrigin: true.

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 uv here and from verifyRegistration. Add a test with flags 0x01 that would fail without the check.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs