okengine 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,762 @@
1
+ /**
2
+ * Exploit-proof security audit for the seven Gate auth method plugins.
3
+ *
4
+ * Real HTTP against a booted app — rate limits, gate posture, single-use tokens,
5
+ * anonymous non-escalation, channel delivery gap, TOTP constant-time compare,
6
+ * and WebAuthn signature + origin verification.
7
+ */
8
+
9
+ import { afterEach, describe, expect, test } from "bun:test";
10
+ import { constantTimeEqual } from "../auth/constant-time.ts";
11
+ import { oke } from "../kernel/app.ts";
12
+ import { resetFlowSeq } from "../kernel/flow.ts";
13
+ import { resetBindings } from "../kernel/on.ts";
14
+ import { anonymous } from "./anonymous.ts";
15
+ import { emailOtp } from "./email-otp.ts";
16
+ import { magicLink } from "./magic-link.ts";
17
+ import { passkey } from "./passkey.ts";
18
+ import { b64urlEncode, buildAuthenticatorData, signWebAuthnAssertion } from "./passkey-webauthn.ts";
19
+ import { phoneNumber } from "./phone-number.ts";
20
+ import { twoFactor, verifyTotp, createTwoFactorStore } from "./two-factor.ts";
21
+ import { username } from "./username.ts";
22
+
23
+ afterEach(() => {
24
+ resetBindings();
25
+ resetFlowSeq();
26
+ });
27
+
28
+ const SECRET = "test-secret-at-least-16";
29
+
30
+ function jsonPost(path: string, body: unknown, headers: Record<string, string> = {}): Request {
31
+ return new Request(`http://localhost${path}`, {
32
+ method: "POST",
33
+ headers: { "content-type": "application/json", ...headers },
34
+ body: JSON.stringify(body),
35
+ });
36
+ }
37
+
38
+ function fullAuthApp() {
39
+ return oke({
40
+ name: `auth-sec-${crypto.randomUUID()}`,
41
+ env: "test",
42
+ registry: "ignore",
43
+ gate: {
44
+ auth: {
45
+ secret: SECRET,
46
+ emailAndPassword: { enabled: true },
47
+ },
48
+ unguardedHttp: "deny",
49
+ },
50
+ })
51
+ .plug(username())
52
+ .plug(anonymous())
53
+ .plug(magicLink({ exposeDevToken: true }))
54
+ .plug(emailOtp({ exposeDevOtp: true }))
55
+ .plug(phoneNumber({ exposeDevOtp: true }))
56
+ .plug(twoFactor())
57
+ .plug(passkey({ origins: ["http://localhost"] }));
58
+ }
59
+
60
+ async function readError(res: Response): Promise<{ code?: string; reason?: string }> {
61
+ const body = (await res.json()) as {
62
+ error?: { code?: string; data?: { reason?: string } };
63
+ };
64
+ return { code: body.error?.code, reason: body.error?.data?.reason };
65
+ }
66
+
67
+ async function mintUsernameSession(
68
+ app: ReturnType<typeof fullAuthApp>,
69
+ usernameValue: string,
70
+ ): Promise<{ accessToken: string; userId: string }> {
71
+ const res = await app.fetch(
72
+ jsonPost("/auth/sign-up/username", {
73
+ username: usernameValue,
74
+ password: "CorrectHorse1",
75
+ }),
76
+ );
77
+ expect(res.status).toBe(200);
78
+ const body = (await res.json()) as { data: { accessToken: string; userId: string } };
79
+ return body.data;
80
+ }
81
+
82
+ async function generatePasskeyKeypair(): Promise<{
83
+ privateKey: CryptoKey;
84
+ publicKeyB64: string;
85
+ }> {
86
+ const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
87
+ "sign",
88
+ "verify",
89
+ ]);
90
+ const spki = new Uint8Array(await crypto.subtle.exportKey("spki", pair.publicKey));
91
+ return { privateKey: pair.privateKey, publicKeyB64: b64urlEncode(spki) };
92
+ }
93
+
94
+ async function buildCeremony(opts: {
95
+ type: "webauthn.create" | "webauthn.get";
96
+ challenge: string;
97
+ origin: string;
98
+ rpId: string;
99
+ privateKey: CryptoKey;
100
+ signCount: number;
101
+ }): Promise<{
102
+ clientDataJSON: string;
103
+ authenticatorData: string;
104
+ signature: string;
105
+ }> {
106
+ const clientData = new TextEncoder().encode(
107
+ JSON.stringify({
108
+ type: opts.type,
109
+ challenge: opts.challenge,
110
+ origin: opts.origin,
111
+ }),
112
+ );
113
+ const authData = await buildAuthenticatorData(opts.rpId, opts.signCount);
114
+ const sig = await signWebAuthnAssertion(opts.privateKey, authData, clientData);
115
+ return {
116
+ clientDataJSON: b64urlEncode(clientData),
117
+ authenticatorData: b64urlEncode(authData),
118
+ signature: b64urlEncode(sig),
119
+ };
120
+ }
121
+
122
+ describe("auth methods — gate posture (zero-trust)", () => {
123
+ test("every plugin HTTP binding declares gate posture; boot succeeds with deny", async () => {
124
+ const app = fullAuthApp();
125
+ const httpBindings = app.bindings.filter((b) => b.trigger.kind === "http");
126
+ expect(httpBindings.length).toBeGreaterThan(10);
127
+ for (const b of httpBindings) {
128
+ const gates = (b.trigger as { gates: readonly unknown[] }).gates;
129
+ expect(gates.length).toBeGreaterThan(0);
130
+ }
131
+ await app.boot({ env: "test" });
132
+ await app.stop();
133
+ });
134
+ });
135
+
136
+ describe("auth methods — rate limiting / enumeration", () => {
137
+ test("OTP / magic-link / phone / passkey challenge endpoints rate-limit at 5/1m per IP", async () => {
138
+ const app = fullAuthApp();
139
+ await app.boot({ env: "test" });
140
+ const ip = { "x-forwarded-for": "203.0.113.10" };
141
+
142
+ const paths: Array<{ path: string; body: unknown }> = [
143
+ { path: "/auth/magic-link/request", body: { email: "r@example.com" } },
144
+ { path: "/auth/email-otp/request", body: { email: "r@example.com" } },
145
+ { path: "/auth/phone/request", body: { phone: "+15551234567" } },
146
+ { path: "/auth/passkey/authenticate/options", body: {} },
147
+ ];
148
+
149
+ for (const { path, body } of paths) {
150
+ // Fresh IP per path so buckets do not share exhaustion across surfaces.
151
+ const hdr = { ...ip, "x-forwarded-for": `203.0.113.${path.length}` };
152
+ for (let i = 0; i < 5; i++) {
153
+ const res = await app.fetch(jsonPost(path, body, hdr));
154
+ expect(res.status).toBe(200);
155
+ }
156
+ const limited = await app.fetch(jsonPost(path, body, hdr));
157
+ expect(limited.status).toBe(429);
158
+ const err = await readError(limited);
159
+ expect(err.code).toBe("RateLimited");
160
+ }
161
+
162
+ await app.stop();
163
+ });
164
+
165
+ test("username sign-in: unknown user and wrong password both invalid_credentials", async () => {
166
+ const app = fullAuthApp();
167
+ await app.boot({ env: "test" });
168
+ await mintUsernameSession(app, "alice_enum");
169
+
170
+ const missing = await app.fetch(
171
+ jsonPost("/auth/sign-in/username", {
172
+ username: "no_such_user",
173
+ password: "CorrectHorse1",
174
+ }),
175
+ );
176
+ const wrong = await app.fetch(
177
+ jsonPost("/auth/sign-in/username", {
178
+ username: "alice_enum",
179
+ password: "wrong-password",
180
+ }),
181
+ );
182
+ const a = await readError(missing);
183
+ const b = await readError(wrong);
184
+ expect(a.reason).toBe("invalid_credentials");
185
+ expect(b.reason).toBe("invalid_credentials");
186
+ expect(a.code).toBe(b.code);
187
+
188
+ await app.stop();
189
+ });
190
+
191
+ test("magic-link / email-otp request always ok (no email enumeration)", async () => {
192
+ const app = fullAuthApp();
193
+ await app.boot({ env: "test" });
194
+
195
+ const a = await app.fetch(jsonPost("/auth/magic-link/request", { email: "a@example.com" }));
196
+ const b = await app.fetch(jsonPost("/auth/magic-link/request", { email: "b@example.com" }));
197
+ expect(a.status).toBe(200);
198
+ expect(b.status).toBe(200);
199
+ const aBody = (await a.json()) as { data: { ok: true } };
200
+ const bBody = (await b.json()) as { data: { ok: true } };
201
+ expect(aBody.data.ok).toBe(true);
202
+ expect(bBody.data.ok).toBe(true);
203
+
204
+ await app.stop();
205
+ });
206
+ });
207
+
208
+ describe("auth methods — single-use OTP / token / backup codes", () => {
209
+ test("magic-link token cannot be reused after verify", async () => {
210
+ const app = fullAuthApp();
211
+ await app.boot({ env: "test" });
212
+
213
+ const req = await app.fetch(jsonPost("/auth/magic-link/request", { email: "ml@example.com" }));
214
+ const { data } = (await req.json()) as { data: { devToken: string } };
215
+ const first = await app.fetch(jsonPost("/auth/magic-link/verify", { token: data.devToken }));
216
+ expect(first.status).toBe(200);
217
+ const reuse = await app.fetch(jsonPost("/auth/magic-link/verify", { token: data.devToken }));
218
+ expect(reuse.status).toBeGreaterThanOrEqual(400);
219
+ expect((await readError(reuse)).reason).toBe("invalid_credentials");
220
+
221
+ await app.stop();
222
+ });
223
+
224
+ test("email OTP cannot be reused after verify", async () => {
225
+ const app = fullAuthApp();
226
+ await app.boot({ env: "test" });
227
+
228
+ const req = await app.fetch(jsonPost("/auth/email-otp/request", { email: "otp@example.com" }));
229
+ const { data } = (await req.json()) as { data: { devOtp: string } };
230
+ const first = await app.fetch(
231
+ jsonPost("/auth/email-otp/verify", { email: "otp@example.com", otp: data.devOtp }),
232
+ );
233
+ expect(first.status).toBe(200);
234
+ const reuse = await app.fetch(
235
+ jsonPost("/auth/email-otp/verify", { email: "otp@example.com", otp: data.devOtp }),
236
+ );
237
+ expect((await readError(reuse)).reason).toBe("invalid_credentials");
238
+
239
+ await app.stop();
240
+ });
241
+
242
+ test("phone OTP cannot be reused after verify", async () => {
243
+ const app = fullAuthApp();
244
+ await app.boot({ env: "test" });
245
+
246
+ const req = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15559876543" }));
247
+ const { data } = (await req.json()) as { data: { devOtp: string } };
248
+ const first = await app.fetch(
249
+ jsonPost("/auth/phone/verify", { phone: "+15559876543", otp: data.devOtp }),
250
+ );
251
+ expect(first.status).toBe(200);
252
+ const reuse = await app.fetch(
253
+ jsonPost("/auth/phone/verify", { phone: "+15559876543", otp: data.devOtp }),
254
+ );
255
+ expect((await readError(reuse)).reason).toBe("invalid_credentials");
256
+
257
+ await app.stop();
258
+ });
259
+
260
+ test("two-factor recovery code is single-use", async () => {
261
+ const factors = createTwoFactorStore();
262
+ const app = oke({
263
+ name: `2fa-${crypto.randomUUID()}`,
264
+ env: "test",
265
+ registry: "ignore",
266
+ gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
267
+ })
268
+ .plug(username())
269
+ .plug(twoFactor({ factors }));
270
+ await app.boot({ env: "test" });
271
+
272
+ const session = await mintUsernameSession(app, "twofa_user");
273
+ const enable = await app.fetch(
274
+ jsonPost("/auth/two-factor/enable", {}, { authorization: `Bearer ${session.accessToken}` }),
275
+ );
276
+ expect(enable.status).toBe(200);
277
+ const enabled = (await enable.json()) as { data: { recoveryCodes: string[] } };
278
+ const code = enabled.data.recoveryCodes[0]!;
279
+
280
+ const first = await app.fetch(
281
+ jsonPost("/auth/two-factor/verify", { userId: session.userId, code }),
282
+ );
283
+ expect(first.status).toBe(200);
284
+ const reuse = await app.fetch(
285
+ jsonPost("/auth/two-factor/verify", { userId: session.userId, code }),
286
+ );
287
+ expect((await readError(reuse)).reason).toBe("invalid_credentials");
288
+
289
+ await app.stop();
290
+ });
291
+
292
+ test("passkey challenge cannot be reused after authenticate", async () => {
293
+ const app = fullAuthApp();
294
+ await app.boot({ env: "test" });
295
+ const session = await mintUsernameSession(app, "pk_reuse");
296
+ const keys = await generatePasskeyKeypair();
297
+
298
+ const regOpts = await app.fetch(
299
+ jsonPost(
300
+ "/auth/passkey/register/options",
301
+ {},
302
+ { authorization: `Bearer ${session.accessToken}` },
303
+ ),
304
+ );
305
+ const reg = (await regOpts.json()) as {
306
+ data: { challenge: string; userId: string; rpId: string };
307
+ };
308
+ const regCeremony = await buildCeremony({
309
+ type: "webauthn.create",
310
+ challenge: reg.data.challenge,
311
+ origin: "http://localhost",
312
+ rpId: reg.data.rpId,
313
+ privateKey: keys.privateKey,
314
+ signCount: 0,
315
+ });
316
+ const credentialId = b64urlEncode(crypto.getRandomValues(new Uint8Array(16)));
317
+ const registered = await app.fetch(
318
+ jsonPost(
319
+ "/auth/passkey/register",
320
+ {
321
+ credentialId,
322
+ publicKey: keys.publicKeyB64,
323
+ userId: reg.data.userId,
324
+ challenge: reg.data.challenge,
325
+ ...regCeremony,
326
+ },
327
+ { authorization: `Bearer ${session.accessToken}` },
328
+ ),
329
+ );
330
+ expect(registered.status).toBe(200);
331
+
332
+ const authOpts = await app.fetch(jsonPost("/auth/passkey/authenticate/options", {}));
333
+ const auth = (await authOpts.json()) as { data: { challenge: string; rpId: string } };
334
+ const authCeremony = await buildCeremony({
335
+ type: "webauthn.get",
336
+ challenge: auth.data.challenge,
337
+ origin: "http://localhost",
338
+ rpId: auth.data.rpId,
339
+ privateKey: keys.privateKey,
340
+ signCount: 1,
341
+ });
342
+ const first = await app.fetch(
343
+ jsonPost("/auth/passkey/authenticate", {
344
+ credentialId,
345
+ challenge: auth.data.challenge,
346
+ ...authCeremony,
347
+ }),
348
+ );
349
+ expect(first.status).toBe(200);
350
+
351
+ const reuse = await app.fetch(
352
+ jsonPost("/auth/passkey/authenticate", {
353
+ credentialId,
354
+ challenge: auth.data.challenge,
355
+ ...authCeremony,
356
+ }),
357
+ );
358
+ expect((await readError(reuse)).reason).toBe("invalid_credentials");
359
+
360
+ await app.stop();
361
+ });
362
+ });
363
+
364
+ describe("auth methods — anonymous non-escalation", () => {
365
+ test("anonymous session cannot register a passkey for another userId", async () => {
366
+ const app = fullAuthApp();
367
+ await app.boot({ env: "test" });
368
+
369
+ const anon = await app.fetch(jsonPost("/auth/sign-in/anonymous", {}));
370
+ const anonBody = (await anon.json()) as { data: { accessToken: string; userId: string } };
371
+ const victim = await mintUsernameSession(app, "victim_user");
372
+
373
+ const opts = await app.fetch(
374
+ jsonPost(
375
+ "/auth/passkey/register/options",
376
+ {},
377
+ { authorization: `Bearer ${anonBody.data.accessToken}` },
378
+ ),
379
+ );
380
+ expect(opts.status).toBe(200);
381
+ const optBody = (await opts.json()) as { data: { challenge: string; userId: string } };
382
+ expect(optBody.data.userId).toBe(anonBody.data.userId);
383
+
384
+ const keys = await generatePasskeyKeypair();
385
+ const ceremony = await buildCeremony({
386
+ type: "webauthn.create",
387
+ challenge: optBody.data.challenge,
388
+ origin: "http://localhost",
389
+ rpId: "localhost",
390
+ privateKey: keys.privateKey,
391
+ signCount: 0,
392
+ });
393
+ const escalate = await app.fetch(
394
+ jsonPost(
395
+ "/auth/passkey/register",
396
+ {
397
+ credentialId: b64urlEncode(crypto.getRandomValues(new Uint8Array(8))),
398
+ publicKey: keys.publicKeyB64,
399
+ userId: victim.userId,
400
+ challenge: optBody.data.challenge,
401
+ ...ceremony,
402
+ },
403
+ { authorization: `Bearer ${anonBody.data.accessToken}` },
404
+ ),
405
+ );
406
+ expect(escalate.status).toBeGreaterThanOrEqual(400);
407
+ expect((await readError(escalate)).reason).toBe("unauthenticated");
408
+
409
+ // Username sign-up while holding anon Bearer creates a *new* principal — no silent link.
410
+ const linked = await app.fetch(
411
+ jsonPost("/auth/sign-up/username", {
412
+ username: "fresh_from_anon",
413
+ password: "CorrectHorse1",
414
+ }),
415
+ );
416
+ const linkedBody = (await linked.json()) as { data: { userId: string } };
417
+ expect(linkedBody.data.userId).not.toBe(anonBody.data.userId);
418
+
419
+ await app.stop();
420
+ });
421
+ });
422
+
423
+ describe("auth methods — channel delivery gap", () => {
424
+ test("magic / email-otp / phone generate codes but do not expose them without exposeDev*", async () => {
425
+ resetBindings();
426
+ resetFlowSeq();
427
+ const app = oke({
428
+ name: `delivery-${crypto.randomUUID()}`,
429
+ env: "test",
430
+ registry: "ignore",
431
+ gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
432
+ })
433
+ .plug(magicLink())
434
+ .plug(emailOtp())
435
+ .plug(phoneNumber());
436
+ await app.boot({ env: "test" });
437
+
438
+ const ml = await app.fetch(jsonPost("/auth/magic-link/request", { email: "x@example.com" }));
439
+ const mlBody = (await ml.json()) as { data: Record<string, unknown> };
440
+ expect(mlBody.data.ok).toBe(true);
441
+ expect(mlBody.data.devToken).toBeUndefined();
442
+
443
+ const otp = await app.fetch(jsonPost("/auth/email-otp/request", { email: "x@example.com" }));
444
+ const otpBody = (await otp.json()) as { data: Record<string, unknown> };
445
+ expect(otpBody.data.ok).toBe(true);
446
+ expect(otpBody.data.devOtp).toBeUndefined();
447
+
448
+ const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
449
+ const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
450
+ expect(phoneBody.data.ok).toBe(true);
451
+ expect(phoneBody.data.devOtp).toBeUndefined();
452
+
453
+ // No Channel import / send path in these plugins — codes exist only in the
454
+ // verification store (unreachable without exposeDev* or a future Channel wire).
455
+ const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
456
+ const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
457
+ const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
458
+ for (const src of [magicSrc, emailSrc, phoneSrc]) {
459
+ expect(src).not.toMatch(/fx\.channel|channel\./);
460
+ expect(src).not.toMatch(/from ["'].*channel/);
461
+ }
462
+
463
+ await app.stop();
464
+ });
465
+ });
466
+
467
+ describe("auth methods — TOTP constant-time compare (confirmed issue)", () => {
468
+ test("verifyTotp accepts valid code and rejects wrong codes (no === short-circuit path)", async () => {
469
+ // Source-level: the vulnerable `otp === code` pattern must be gone.
470
+ const src = await Bun.file(new URL("./two-factor.ts", import.meta.url)).text();
471
+ expect(src).not.toMatch(/otp\s*===\s*code/);
472
+ expect(src).toContain("constantTimeEqual");
473
+ expect(constantTimeEqual("123456", "123456")).toBe(true);
474
+ expect(constantTimeEqual("123456", "123457")).toBe(false);
475
+
476
+ const factors = createTwoFactorStore();
477
+ const app = oke({
478
+ name: `totp-${crypto.randomUUID()}`,
479
+ env: "test",
480
+ registry: "ignore",
481
+ gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
482
+ })
483
+ .plug(username())
484
+ .plug(twoFactor({ factors }));
485
+ await app.boot({ env: "test" });
486
+ const session = await mintUsernameSession(app, "totp_user");
487
+ const enable = await app.fetch(
488
+ jsonPost("/auth/two-factor/enable", {}, { authorization: `Bearer ${session.accessToken}` }),
489
+ );
490
+ const { data } = (await enable.json()) as { data: { secret: string } };
491
+
492
+ const t = Math.floor(Date.now() / 1000);
493
+ const code = await generateTotpForTest(data.secret, t);
494
+ expect(await verifyTotp(data.secret, code, t)).toBe(true);
495
+
496
+ const ok = await app.fetch(
497
+ jsonPost("/auth/two-factor/verify", { userId: session.userId, code }),
498
+ );
499
+ expect(ok.status).toBe(200);
500
+
501
+ // Wrong code of the same length — must fail (uses constant-time path).
502
+ const wrong = code === "000000" ? "000001" : "000000";
503
+ const bad = await app.fetch(
504
+ jsonPost("/auth/two-factor/verify", { userId: session.userId, code: wrong }),
505
+ );
506
+ expect((await readError(bad)).reason).toBe("invalid_credentials");
507
+
508
+ await app.stop();
509
+ });
510
+ });
511
+
512
+ /** Compute a valid 6-digit TOTP for tests (RFC 6238, SHA-1, 30s). */
513
+ async function generateTotpForTest(secretBase32: string, nowSec: number): Promise<string> {
514
+ const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
515
+ const cleaned = secretBase32.toUpperCase().replace(/[^A-Z2-7]/g, "");
516
+ let bits = 0;
517
+ let value = 0;
518
+ const key: number[] = [];
519
+ for (const ch of cleaned) {
520
+ const idx = B32.indexOf(ch);
521
+ value = (value << 5) | idx;
522
+ bits += 5;
523
+ if (bits >= 8) {
524
+ key.push((value >>> (bits - 8)) & 0xff);
525
+ bits -= 8;
526
+ }
527
+ }
528
+ const keyBytes = new Uint8Array(key);
529
+ const counter = Math.floor(nowSec / 30);
530
+ const msg = new Uint8Array(8);
531
+ let c = counter;
532
+ for (let i = 7; i >= 0; i--) {
533
+ msg[i] = c & 0xff;
534
+ c = Math.floor(c / 256);
535
+ }
536
+ const cryptoKey = await crypto.subtle.importKey(
537
+ "raw",
538
+ keyBytes.buffer.slice(
539
+ keyBytes.byteOffset,
540
+ keyBytes.byteOffset + keyBytes.byteLength,
541
+ ) as ArrayBuffer,
542
+ { name: "HMAC", hash: "SHA-1" },
543
+ false,
544
+ ["sign"],
545
+ );
546
+ const sig = new Uint8Array(await crypto.subtle.sign("HMAC", cryptoKey, msg));
547
+ const offset = sig[sig.length - 1]! & 0x0f;
548
+ const bin =
549
+ ((sig[offset]! & 0x7f) << 24) |
550
+ ((sig[offset + 1]! & 0xff) << 16) |
551
+ ((sig[offset + 2]! & 0xff) << 8) |
552
+ (sig[offset + 3]! & 0xff);
553
+ return (bin % 1_000_000).toString().padStart(6, "0");
554
+ }
555
+
556
+ describe("auth methods — passkey signature + origin (confirmed issue)", () => {
557
+ test("presence-only authenticate (credentialId alone) is rejected", async () => {
558
+ const app = fullAuthApp();
559
+ await app.boot({ env: "test" });
560
+ const session = await mintUsernameSession(app, "pk_presence");
561
+ const keys = await generatePasskeyKeypair();
562
+
563
+ const regOpts = await app.fetch(
564
+ jsonPost(
565
+ "/auth/passkey/register/options",
566
+ {},
567
+ { authorization: `Bearer ${session.accessToken}` },
568
+ ),
569
+ );
570
+ const reg = (await regOpts.json()) as {
571
+ data: { challenge: string; userId: string; rpId: string };
572
+ };
573
+ const regCeremony = await buildCeremony({
574
+ type: "webauthn.create",
575
+ challenge: reg.data.challenge,
576
+ origin: "http://localhost",
577
+ rpId: reg.data.rpId,
578
+ privateKey: keys.privateKey,
579
+ signCount: 0,
580
+ });
581
+ const credentialId = b64urlEncode(crypto.getRandomValues(new Uint8Array(16)));
582
+ expect(
583
+ (
584
+ await app.fetch(
585
+ jsonPost(
586
+ "/auth/passkey/register",
587
+ {
588
+ credentialId,
589
+ publicKey: keys.publicKeyB64,
590
+ userId: reg.data.userId,
591
+ challenge: reg.data.challenge,
592
+ ...regCeremony,
593
+ },
594
+ { authorization: `Bearer ${session.accessToken}` },
595
+ ),
596
+ )
597
+ ).status,
598
+ ).toBe(200);
599
+
600
+ // Pre-fix exploit shape: credentialId + userId, no signature / origin.
601
+ const exploit = await app.fetch(
602
+ jsonPost("/auth/passkey/authenticate", {
603
+ credentialId,
604
+ userId: session.userId,
605
+ }),
606
+ );
607
+ expect(exploit.status).toBeGreaterThanOrEqual(400);
608
+
609
+ await app.stop();
610
+ });
611
+
612
+ test("wrong origin is rejected; valid signature + origin issues a session", async () => {
613
+ const app = fullAuthApp();
614
+ await app.boot({ env: "test" });
615
+ const session = await mintUsernameSession(app, "pk_origin");
616
+ const keys = await generatePasskeyKeypair();
617
+
618
+ const regOpts = await app.fetch(
619
+ jsonPost(
620
+ "/auth/passkey/register/options",
621
+ {},
622
+ { authorization: `Bearer ${session.accessToken}` },
623
+ ),
624
+ );
625
+ const reg = (await regOpts.json()) as {
626
+ data: { challenge: string; userId: string; rpId: string };
627
+ };
628
+ const regCeremony = await buildCeremony({
629
+ type: "webauthn.create",
630
+ challenge: reg.data.challenge,
631
+ origin: "http://localhost",
632
+ rpId: reg.data.rpId,
633
+ privateKey: keys.privateKey,
634
+ signCount: 0,
635
+ });
636
+ const credentialId = b64urlEncode(crypto.getRandomValues(new Uint8Array(16)));
637
+ expect(
638
+ (
639
+ await app.fetch(
640
+ jsonPost(
641
+ "/auth/passkey/register",
642
+ {
643
+ credentialId,
644
+ publicKey: keys.publicKeyB64,
645
+ userId: reg.data.userId,
646
+ challenge: reg.data.challenge,
647
+ ...regCeremony,
648
+ },
649
+ { authorization: `Bearer ${session.accessToken}` },
650
+ ),
651
+ )
652
+ ).status,
653
+ ).toBe(200);
654
+
655
+ const authOpts = await app.fetch(jsonPost("/auth/passkey/authenticate/options", {}));
656
+ const auth = (await authOpts.json()) as { data: { challenge: string; rpId: string } };
657
+
658
+ const evil = await buildCeremony({
659
+ type: "webauthn.get",
660
+ challenge: auth.data.challenge,
661
+ origin: "https://evil.example",
662
+ rpId: auth.data.rpId,
663
+ privateKey: keys.privateKey,
664
+ signCount: 1,
665
+ });
666
+ const evilRes = await app.fetch(
667
+ jsonPost("/auth/passkey/authenticate", {
668
+ credentialId,
669
+ challenge: auth.data.challenge,
670
+ ...evil,
671
+ }),
672
+ );
673
+ expect((await readError(evilRes)).reason).toBe("invalid_origin");
674
+
675
+ // Fresh challenge after failed attempt (previous challenge was consumed).
676
+ const authOpts2 = await app.fetch(jsonPost("/auth/passkey/authenticate/options", {}));
677
+ const auth2 = (await authOpts2.json()) as { data: { challenge: string; rpId: string } };
678
+ const good = await buildCeremony({
679
+ type: "webauthn.get",
680
+ challenge: auth2.data.challenge,
681
+ origin: "http://localhost",
682
+ rpId: auth2.data.rpId,
683
+ privateKey: keys.privateKey,
684
+ signCount: 2,
685
+ });
686
+ const ok = await app.fetch(
687
+ jsonPost("/auth/passkey/authenticate", {
688
+ credentialId,
689
+ challenge: auth2.data.challenge,
690
+ ...good,
691
+ }),
692
+ );
693
+ expect(ok.status).toBe(200);
694
+ const okBody = (await ok.json()) as { data: { accessToken: string; userId: string } };
695
+ expect(okBody.data.accessToken).toBeTruthy();
696
+ expect(okBody.data.userId).toBe(session.userId);
697
+
698
+ await app.stop();
699
+ });
700
+
701
+ test("forged signature with wrong private key is rejected", async () => {
702
+ const app = fullAuthApp();
703
+ await app.boot({ env: "test" });
704
+ const session = await mintUsernameSession(app, "pk_forge");
705
+ const keys = await generatePasskeyKeypair();
706
+ const attacker = await generatePasskeyKeypair();
707
+
708
+ const regOpts = await app.fetch(
709
+ jsonPost(
710
+ "/auth/passkey/register/options",
711
+ {},
712
+ { authorization: `Bearer ${session.accessToken}` },
713
+ ),
714
+ );
715
+ const reg = (await regOpts.json()) as {
716
+ data: { challenge: string; userId: string; rpId: string };
717
+ };
718
+ const regCeremony = await buildCeremony({
719
+ type: "webauthn.create",
720
+ challenge: reg.data.challenge,
721
+ origin: "http://localhost",
722
+ rpId: reg.data.rpId,
723
+ privateKey: keys.privateKey,
724
+ signCount: 0,
725
+ });
726
+ const credentialId = b64urlEncode(crypto.getRandomValues(new Uint8Array(16)));
727
+ await app.fetch(
728
+ jsonPost(
729
+ "/auth/passkey/register",
730
+ {
731
+ credentialId,
732
+ publicKey: keys.publicKeyB64,
733
+ userId: reg.data.userId,
734
+ challenge: reg.data.challenge,
735
+ ...regCeremony,
736
+ },
737
+ { authorization: `Bearer ${session.accessToken}` },
738
+ ),
739
+ );
740
+
741
+ const authOpts = await app.fetch(jsonPost("/auth/passkey/authenticate/options", {}));
742
+ const auth = (await authOpts.json()) as { data: { challenge: string; rpId: string } };
743
+ const forged = await buildCeremony({
744
+ type: "webauthn.get",
745
+ challenge: auth.data.challenge,
746
+ origin: "http://localhost",
747
+ rpId: auth.data.rpId,
748
+ privateKey: attacker.privateKey,
749
+ signCount: 1,
750
+ });
751
+ const res = await app.fetch(
752
+ jsonPost("/auth/passkey/authenticate", {
753
+ credentialId,
754
+ challenge: auth.data.challenge,
755
+ ...forged,
756
+ }),
757
+ );
758
+ expect((await readError(res)).reason).toBe("invalid_credentials");
759
+
760
+ await app.stop();
761
+ });
762
+ });