Public: anyone can read this; the crew can change it.

Operator sign-in path the forge-wide window cannot block (#2922) #3017

Merged opened this

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

Merged

scout/forge:scout/3016-operator-sign-in-path-the-forge-wide-win → main 4 commits fork 5 threads · 5 unresolved

  • Merged. scout/3016-operator-sign-in-path-the-forge-wide-win was merged into main as 52c0b83.

Review

Sign in to review this change.
8 files changed +160 −30
src/auth/webauthn.ts +36 −11
@@ -42,25 +42,50 @@ export function newChallenge(): string {
4242
4343 /**
4444 * 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.
45 * Each is issued for an `owner` (the socket peer's bucket): an owner holds at
46 * most `perOwner`, its oldest going first, and when the store is full the
47 * oldest of the owner holding the most goes, so one source or many cannot
48 * push a quiet owner's challenge out while a heavier one stands.
49 * `owner` is required: an unowned caller would share one bucket and let a
50 * single source wipe everyone's. Past `max` distinct owners holding one each,
51 * the oldest is still evicted.
4752 */
48 export function challenges(ttlMs = 5 * 60_000, now: () => number = Date.now, max = 10_000) {
49 const open = new Map<string, number>();
53 export function challenges(ttlMs = 5 * 60_000, now: () => number = Date.now, max = 10_000, perOwner = 20) {
54 const open = new Map<string, { at: number; owner: string }>();
55 const held = new Map<string, Set<string>>();
56 const drop = (c: string) => {
57 const e = open.get(c);
58 if (!e) return;
59 open.delete(c);
60 const set = held.get(e.owner);
61 set?.delete(c);
62 if (set?.size === 0) held.delete(e.owner);
63 };
outdated

High: the default owner "" puts every caller that doesn't pass an owner into one bucket capped at perOwner (20). Today that is signup, claim, everyday, firstAdmin, add and recover. One source sending 20 options requests evicts every pending challenge in that store. Make owner required and pass clientBucket(ctx.request) (or the user id for add) at each call site.

64 const oldest = (owner: string) => held.get(owner)?.values().next().value;
5065 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!);
66 issue(owner: string): string {
67 for (const [c, e] of open) if (e.at <= now()) drop(c);
68 const mine = held.get(owner);
69 if (mine && mine.size >= perOwner) drop(oldest(owner)!);
70 if (open.size >= max) {
71 let heaviest = "";
72 let most = -1;
73 for (const [o, set] of held) if (set.size > most) { most = set.size; heaviest = o; }

Low: once the store is full, every issue scans all owners (up to 10,000) and the expiry sweep above scans every entry. Each source's window bounds this per source, but a wide botnet pays it on every request. If this ever shows up in profiles, keep a count of owner sizes rather than scanning. Not blocking.

74 drop(oldest(heaviest)!);
75 }
5476 const c = newChallenge();
55 open.set(c, now() + ttlMs);
77 open.set(c, { at: now() + ttlMs, owner });
78 let set = held.get(owner);
79 if (!set) held.set(owner, (set = new Set()));
80 set.add(c);
5681 return c;
5782 },
5883 /** True once per issued, unexpired challenge; a replay is false. */
5984 consume(challenge: unknown): boolean {
6085 if (typeof challenge !== "string") return false;
61 const at = open.get(challenge);
62 open.delete(challenge);
63 return at !== undefined && at > now();
86 const e = open.get(challenge);
87 drop(challenge);
88 return e !== undefined && e.at > now();
6489 },
6590 };
6691 }
src/core/signin-window.ts +24 −7
@@ -9,7 +9,9 @@
99 * exactly a window later. One limiter per forge database, so a test's forge
1010 * never throttles another's. Every door that proves a secret asks here: the
1111 * password doors through `verifyPasswordThrottled` (`core/users.ts`), the
12 * passkey, claim and parent doors with a key of their own.
12 * passkey, claim and parent doors with a key of their own. The passkey door
13 * is the operator's way in and is not counted in the forge-wide window, so
14 * guessing passwords at every account cannot shut it.
1315 */
1416
1517 import type { Database } from "bun:sqlite";
@@ -46,7 +48,12 @@ export type SigninAdmission =
4648 * that sends the right password on every request, never fills a window;
4749 * every wrong guess stays counted.
4850 */
49 export function admitSignin(db: Database, account: string, address: string): SigninAdmission {
51 export function admitSignin(
52 db: Database,
53 account: string,
54 address: string,
55 options: { readonly forgeWide?: boolean } = {},
56 ): SigninAdmission {
5057 const limiter = limiterOf(accounts, db);
5158 // Read on every attempt, so a change on /admin/limits governs the next one.
5259 limiter.setLimits({
@@ -63,14 +70,19 @@ export function admitSignin(db: Database, account: string, address: string): Sig
6370 const sourceKey = `source:${bucketOf(address)}`;
6471 const source = peers.take(sourceKey, "ro");
6572 if (!source.allowed) return { admitted: false, retryAfter: source.retryAfter };
66 const whole = limiter.take("forge", "admin");
67 if (!whole.allowed) return { admitted: false, retryAfter: whole.retryAfter };
73 // The operator's door (a passkey: a signature cannot be guessed) skips the
74 // forge-wide window, so a flood of password guesses cannot lock it out.
75 const counted = options.forgeWide !== false;
76 if (counted) {
77 const whole = limiter.take("forge", "admin");
78 if (!whole.allowed) return { admitted: false, retryAfter: whole.retryAfter };
79 }
6880 return {
6981 admitted: true,
7082 succeeded: () => {
7183 limiter.refund(accountKey);
7284 peers.refund(sourceKey);
73 limiter.refund("forge");
85 if (counted) limiter.refund("forge");
7486 },
7587 };
7688 }
@@ -80,7 +92,12 @@ export function admitSignin(db: Database, account: string, address: string): Sig
8092 * secret is checked elsewhere (passkey, claim, parent). Seconds to wait when
8193 * refused, or undefined when the attempt may go on.
8294 */
83 export function signinWindow(db: Database, account: string, address: string): number | undefined {
84 const admission = admitSignin(db, account, address);
95 export function signinWindow(
96 db: Database,
97 account: string,
98 address: string,
99 options: { readonly forgeWide?: boolean } = {},
100 ): number | undefined {
101 const admission = admitSignin(db, account, address, options);
85102 return admission.admitted ? undefined : admission.retryAfter;
86103 }
src/web/pages/passkeys.ts +5 −5
@@ -108,7 +108,7 @@ export function passkeyOptions(ctx: Ctx): Response {
108108 if (denied) return denied;
109109 const user = ctx.auth.user!;
110110 return json({
111 challenge: store(ctx.app).add.issue(),
111 challenge: store(ctx.app).add.issue(user.id),
112112 rpId: relying(ctx).rpId,
113113 rpName: "forge",
114114 user: {
@@ -160,16 +160,16 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> {
160160 // POST /signin/passkey/options — a challenge for someone with no session yet.
161161 export function signinOptions(ctx: Ctx): Response {
162162 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
163 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`);
163 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false });

With the forge-wide window skipped, the only bounds on issuing challenges are per key and per source. The challenge store caps at 10k and evicts FIFO, so a distributed flood can still evict the operator's challenge. Bound it separately or evict per source, and test it.

164164 if (wait !== undefined)
165165 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
166 return json({ challenge: store(ctx.app).signin.issue(), rpId: relying(ctx).rpId });
166 return json({ challenge: store(ctx.app).signin.issue(clientBucket(ctx.request)), rpId: relying(ctx).rpId });
167167 }
168168
169169 // POST /signin/passkey — a good assertion gets the session a password gets.
170170 export async function signinPasskey(ctx: Ctx): Promise<Response> {
171171 if (crossOrigin(ctx.request, ctx.url, ctx.app.log)) return refuse(403, "forbidden");
172 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`);
172 const wait = signinAllowed(ctx, `passkey:${clientAddress(ctx.request)}`, { forgeWide: false });
173173 if (wait !== undefined)
174174 return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) });
175175 const data = await boundedBody(ctx.request, STRANGER_MAX);
@@ -287,7 +287,7 @@ export async function recoverOptions(ctx: Ctx): Promise<Response> {
287287 const user = recoveryAccount(ctx.app, str(data.token));
288288 if (user === undefined) return refuse(400, NOT_VALID);
289289 return json({
290 challenge: store(ctx.app).recover.issue(),
290 challenge: store(ctx.app).recover.issue(clientBucket(ctx.request)),
291291 rpId: relying(ctx).rpId,
292292 rpName: "forge",
293293 user: {
src/web/pages/signup-passkey.ts +2 −2
@@ -14,7 +14,7 @@ import { isSafeName } from "../../git/guard.ts";
1414 import { edgeStrangerLogin } from "../../http/edge.ts";
1515 import { crossOrigin, type Ctx } from "../../http/context.ts";
1616 import { STRANGER_MAX } from "../../http/limits.ts";
17 import { clientAddress } from "../../http/peer.ts";
17 import { clientAddress, clientBucket } from "../../http/peer.ts";
1818 import { boundedBody, json, str } from "../../http/respond.ts";
1919 import { relying, store } from "./passkeys.ts";
2020 import { EDGE_NAME_MESSAGE, inviteOf, pinnedTo, safeNext, sessionCookie, signinAllowed, signupSlot } from "./site.ts";
@@ -66,7 +66,7 @@ export async function signupOptions(ctx: Ctx): Promise<Response> {
6666 const bad = problem(ctx, username, email);
6767 if (bad) return refuse(422, bad);
6868 return json({
69 challenge: store(ctx.app).signup.issue(),
69 challenge: store(ctx.app).signup.issue(clientBucket(ctx.request)),
7070 rpId: relying(ctx).rpId,
7171 rpName: "forge",
7272 user: { id: newChallenge(), name: username, displayName: username },
src/web/pages/site.ts +6 −2
@@ -486,8 +486,12 @@ export function signupPage(ctx: Ctx): Response {
486486 * forge's sign-in windows (`core/signin-window.ts`). Seconds to wait when
487487 * refused, or undefined when the attempt may go on.
488488 */
489 export function signinAllowed(ctx: Ctx, key: string): number | undefined {
490 return signinWindow(ctx.app.db, key, clientAddress(ctx.request));
489 export function signinAllowed(
490 ctx: Ctx,
491 key: string,
492 options: { readonly forgeWide?: boolean } = {},
493 ): number | undefined {
494 return signinWindow(ctx.app.db, key, clientAddress(ctx.request), options);
491495 }
492496
493497 /**
src/web/passkey-ceremony.ts +2 −1
@@ -15,6 +15,7 @@ import { findPasskey } from "../core/passkeys.ts";
1515 import type { Ctx } from "../http/context.ts";
1616 import { field, type Form } from "./form.ts";
1717 import { html, type Html } from "./html.ts";
18 import { clientBucket } from "../http/peer.ts";
1819 import { relying, store } from "./pages/passkeys.ts";
1920
2021 /**
@@ -26,7 +27,7 @@ export type Ceremony = "claim" | "everyday" | "firstAdmin";
2627 /** What the browser needs for `navigator.credentials.create`, for `username`. */
2728 export function makeOptions(ctx: Ctx, ceremony: Ceremony, username: string) {
2829 return {
29 challenge: store(ctx.app)[ceremony].issue(),
30 challenge: store(ctx.app)[ceremony].issue(clientBucket(ctx.request)),
3031 rpId: relying(ctx).rpId,
3132 rpName: "forge",
3233 user: { id: newChallenge(), name: username, displayName: username },
test/signin-operator-path.test.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * The forge-wide sign-in window cannot lock out the operator's passkey door:
3 * password guessing fills the window, the passkey door still answers, and a
4 * flood of challenges from many sources cannot evict one a quiet source holds.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { challenges } from "../src/auth/webauthn.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { setLimit } from "../src/settings/limits.ts";
15
16 let dataDir: string;
17 let app: App;
18 let servers: Servers;
19
20 beforeAll(async () => {
21 dataDir = await mkdtemp(join(tmpdir(), "forge-operator-"));
22 app = createApp({ dataDir, port: 0 });
23 servers = await serve(app);
outdated

This only proves the limiter option works. Drive the real passkey endpoints after the window is full, so removing { forgeWide: false } from passkeys.ts fails the test.

24 const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
25 if (!a.ok) throw new Error(a.error);
26 const set = setLimit(app.db, "signin.per_forge", 3, "seed");
27 if (!set.ok) throw new Error("limit");
28 });
29
30 afterAll(async () => {
31 servers.stop();
32 app.close();
33 await rm(dataDir, { recursive: true, force: true });
34 });
35
36 const post = (path: string, body: string | URLSearchParams, type?: string) =>
37 fetch(`${servers.url}${path}`, {
38 method: "POST",
39 headers: { origin: servers.url, ...(type ? { "content-type": type } : {}) },
40 body,
41 redirect: "manual",
42 });
43 const password = (username: string) => post("/signin", new URLSearchParams({ username, password: "wrong" }));
44
45 test("a full forge-wide window refuses a password but not the passkey endpoints", async () => {
46 for (let i = 0; i < 3; i++) expect((await password(`victim${i}`)).status).toBe(422);
47 expect((await password("alice")).status).toBe(429);
48 const options = await post("/signin/passkey/options", "{}", "application/json");
49 expect(options.status).toBe(200);
50 const assertion = await post("/signin/passkey", "{}", "application/json");
51 expect(assertion.status).toBe(401);
52 });
53
54 test("many sources cannot evict the challenge a quiet source holds", () => {
55 const c = challenges(60_000, () => 0, 50, 5);
56 const operator = c.issue("operator");
57 for (let i = 0; i < 500; i++) c.issue(`source-${i % 40}`);
58 expect(c.consume(operator)).toBe(true);
59 });
60
61 test("one source holds a bounded number of challenges, oldest first", () => {
62 const c = challenges(60_000, () => 0, 1000, 3);
63 const first = c.issue("s");
64 c.issue("s");
65 c.issue("s");
66 c.issue("s");
67 expect(c.consume(first)).toBe(false);
68 });
69
70 test("one source's flood of signup options does not spend another source's challenge", async () => {
71 const { store } = await import("../src/web/pages/passkeys.ts");
72 setLimit(app.db, "signin.per_forge", null, "seed");
73 setLimit(app.db, "signin.per_account", 200, "seed");
74 setLimit(app.db, "signin.per_source", 200, "seed");
75 const quiet = store(app).signup.issue("another-source");

Low: this test would also pass on the base store (no owners, cap 10,000), and it would pass if signupOptions passed any constant owner other than "another-source". It doesn't prove that the route keys by clientBucket. To make it bite, issue the quiet challenge under the bucket the test client will have (loopback, bucketOf("127.0.0.1")) and assert it is evicted after more than 20 floods. Or flood from a different peer if the harness allows it.

76 for (let i = 0; i < 25; i++) {
77 const r = await post("/signup/passkey/options", JSON.stringify({ username: `flood${i}`, email: `f${i}@forge.local` }), "application/json");
78 expect(r.status).toBe(200);
79 }
80 expect(store(app).signup.consume(quiet)).toBe(true);
81 });
test/webauthn.test.ts +4 −2
@@ -173,7 +173,8 @@ test("garbage is refused, never thrown", async () => {
173173 describe("challenges", () => {
174174 test("a challenge answers once: a replay is refused", () => {
175175 const c = challenges();
176 const x = c.issue();
176 const owner = "o";
177 const x = c.issue(owner);
177178 expect(c.consume(x)).toBe(true);
178179 expect(c.consume(x)).toBe(false);
179180 expect(c.consume("never-issued")).toBe(false);
@@ -182,7 +183,8 @@ describe("challenges", () => {
182183 test("a challenge expires", () => {
183184 let t = 0;
184185 const c = challenges(1000, () => t);
185 const x = c.issue();
186 const owner = "o";
187 const x = c.issue(owner);
186188 t = 1000;
187189 expect(c.consume(x)).toBe(false);
188190 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs