Public: anyone can read this; the crew can change it.
Operator sign-in path the forge-wide window cannot block (#2922) #3017
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/3016-operator-sign-in-path-the-forge-wide-win → main 4 commits fork 5 threads · 5 unresolved
src/auth/webauthn.ts +36 −11
| @@ -42,25 +42,50 @@ export function newChallenge(): string { | ||
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | 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. | |
| 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. | |
| 47 | 52 | */ |
| 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 | }; | |
| 64 | const oldest = (owner: string) => held.get(owner)?.values().next().value; | |
| 50 | 65 | 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 |
||
| 74 | drop(oldest(heaviest)!); | |
| 75 | } | |
| 54 | 76 | 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); | |
| 56 | 81 | return c; |
| 57 | 82 | }, |
| 58 | 83 | /** True once per issued, unexpired challenge; a replay is false. */ |
| 59 | 84 | consume(challenge: unknown): boolean { |
| 60 | 85 | 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(); | |
| 64 | 89 | }, |
| 65 | 90 | }; |
| 66 | 91 | } |
src/core/signin-window.ts +24 −7
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * exactly a window later. One limiter per forge database, so a test's forge |
| 10 | 10 | * never throttles another's. Every door that proves a secret asks here: the |
| 11 | 11 | * 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. | |
| 13 | 15 | */ |
| 14 | 16 | |
| 15 | 17 | import type { Database } from "bun:sqlite"; |
| @@ -46,7 +48,12 @@ export type SigninAdmission = | ||
| 46 | 48 | * that sends the right password on every request, never fills a window; |
| 47 | 49 | * every wrong guess stays counted. |
| 48 | 50 | */ |
| 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 { | |
| 50 | 57 | const limiter = limiterOf(accounts, db); |
| 51 | 58 | // Read on every attempt, so a change on /admin/limits governs the next one. |
| 52 | 59 | limiter.setLimits({ |
| @@ -63,14 +70,19 @@ export function admitSignin(db: Database, account: string, address: string): Sig | ||
| 63 | 70 | const sourceKey = `source:${bucketOf(address)}`; |
| 64 | 71 | const source = peers.take(sourceKey, "ro"); |
| 65 | 72 | 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 | } | |
| 68 | 80 | return { |
| 69 | 81 | admitted: true, |
| 70 | 82 | succeeded: () => { |
| 71 | 83 | limiter.refund(accountKey); |
| 72 | 84 | peers.refund(sourceKey); |
| 73 | limiter.refund("forge"); | |
| 85 | if (counted) limiter.refund("forge"); | |
| 74 | 86 | }, |
| 75 | 87 | }; |
| 76 | 88 | } |
| @@ -80,7 +92,12 @@ export function admitSignin(db: Database, account: string, address: string): Sig | ||
| 80 | 92 | * secret is checked elsewhere (passkey, claim, parent). Seconds to wait when |
| 81 | 93 | * refused, or undefined when the attempt may go on. |
| 82 | 94 | */ |
| 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); | |
| 85 | 102 | return admission.admitted ? undefined : admission.retryAfter; |
| 86 | 103 | } |
src/web/pages/passkeys.ts +5 −5
| @@ -108,7 +108,7 @@ export function passkeyOptions(ctx: Ctx): Response { | ||
| 108 | 108 | if (denied) return denied; |
| 109 | 109 | const user = ctx.auth.user!; |
| 110 | 110 | return json({ |
| 111 | challenge: store(ctx.app).add.issue(), | |
| 111 | challenge: store(ctx.app).add.issue(user.id), | |
| 112 | 112 | rpId: relying(ctx).rpId, |
| 113 | 113 | rpName: "forge", |
| 114 | 114 | user: { |
| @@ -160,16 +160,16 @@ export async function passkeyDelete(ctx: Ctx): Promise<Response> { | ||
| 160 | 160 | // POST /signin/passkey/options — a challenge for someone with no session yet. |
| 161 | 161 | export function signinOptions(ctx: Ctx): Response { |
| 162 | 162 | 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. |
||
| 164 | 164 | if (wait !== undefined) |
| 165 | 165 | 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 }); | |
| 167 | 167 | } |
| 168 | 168 | |
| 169 | 169 | // POST /signin/passkey — a good assertion gets the session a password gets. |
| 170 | 170 | export async function signinPasskey(ctx: Ctx): Promise<Response> { |
| 171 | 171 | 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 }); | |
| 173 | 173 | if (wait !== undefined) |
| 174 | 174 | return json({ error: "rate_limited", message: `Too many attempts. Try again in ${wait}s.` }, 429, { "retry-after": String(wait) }); |
| 175 | 175 | const data = await boundedBody(ctx.request, STRANGER_MAX); |
| @@ -287,7 +287,7 @@ export async function recoverOptions(ctx: Ctx): Promise<Response> { | ||
| 287 | 287 | const user = recoveryAccount(ctx.app, str(data.token)); |
| 288 | 288 | if (user === undefined) return refuse(400, NOT_VALID); |
| 289 | 289 | return json({ |
| 290 | challenge: store(ctx.app).recover.issue(), | |
| 290 | challenge: store(ctx.app).recover.issue(clientBucket(ctx.request)), | |
| 291 | 291 | rpId: relying(ctx).rpId, |
| 292 | 292 | rpName: "forge", |
| 293 | 293 | user: { |
src/web/pages/signup-passkey.ts +2 −2
| @@ -14,7 +14,7 @@ import { isSafeName } from "../../git/guard.ts"; | ||
| 14 | 14 | import { edgeStrangerLogin } from "../../http/edge.ts"; |
| 15 | 15 | import { crossOrigin, type Ctx } from "../../http/context.ts"; |
| 16 | 16 | import { STRANGER_MAX } from "../../http/limits.ts"; |
| 17 | import { clientAddress } from "../../http/peer.ts"; | |
| 17 | import { clientAddress, clientBucket } from "../../http/peer.ts"; | |
| 18 | 18 | import { boundedBody, json, str } from "../../http/respond.ts"; |
| 19 | 19 | import { relying, store } from "./passkeys.ts"; |
| 20 | 20 | 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> { | ||
| 66 | 66 | const bad = problem(ctx, username, email); |
| 67 | 67 | if (bad) return refuse(422, bad); |
| 68 | 68 | return json({ |
| 69 | challenge: store(ctx.app).signup.issue(), | |
| 69 | challenge: store(ctx.app).signup.issue(clientBucket(ctx.request)), | |
| 70 | 70 | rpId: relying(ctx).rpId, |
| 71 | 71 | rpName: "forge", |
| 72 | 72 | user: { id: newChallenge(), name: username, displayName: username }, |
src/web/pages/site.ts +6 −2
| @@ -486,8 +486,12 @@ export function signupPage(ctx: Ctx): Response { | ||
| 486 | 486 | * forge's sign-in windows (`core/signin-window.ts`). Seconds to wait when |
| 487 | 487 | * refused, or undefined when the attempt may go on. |
| 488 | 488 | */ |
| 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); | |
| 491 | 495 | } |
| 492 | 496 | |
| 493 | 497 | /** |
src/web/passkey-ceremony.ts +2 −1
| @@ -15,6 +15,7 @@ import { findPasskey } from "../core/passkeys.ts"; | ||
| 15 | 15 | import type { Ctx } from "../http/context.ts"; |
| 16 | 16 | import { field, type Form } from "./form.ts"; |
| 17 | 17 | import { html, type Html } from "./html.ts"; |
| 18 | import { clientBucket } from "../http/peer.ts"; | |
| 18 | 19 | import { relying, store } from "./pages/passkeys.ts"; |
| 19 | 20 | |
| 20 | 21 | /** |
| @@ -26,7 +27,7 @@ export type Ceremony = "claim" | "everyday" | "firstAdmin"; | ||
| 26 | 27 | /** What the browser needs for `navigator.credentials.create`, for `username`. */ |
| 27 | 28 | export function makeOptions(ctx: Ctx, ceremony: Ceremony, username: string) { |
| 28 | 29 | return { |
| 29 | challenge: store(ctx.app)[ceremony].issue(), | |
| 30 | challenge: store(ctx.app)[ceremony].issue(clientBucket(ctx.request)), | |
| 30 | 31 | rpId: relying(ctx).rpId, |
| 31 | 32 | rpName: "forge", |
| 32 | 33 | 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 |
||
| 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 |
||
| 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 () => { | ||
| 173 | 173 | describe("challenges", () => { |
| 174 | 174 | test("a challenge answers once: a replay is refused", () => { |
| 175 | 175 | const c = challenges(); |
| 176 | const x = c.issue(); | |
| 176 | const owner = "o"; | |
| 177 | const x = c.issue(owner); | |
| 177 | 178 | expect(c.consume(x)).toBe(true); |
| 178 | 179 | expect(c.consume(x)).toBe(false); |
| 179 | 180 | expect(c.consume("never-issued")).toBe(false); |
| @@ -182,7 +183,8 @@ describe("challenges", () => { | ||
| 182 | 183 | test("a challenge expires", () => { |
| 183 | 184 | let t = 0; |
| 184 | 185 | const c = challenges(1000, () => t); |
| 185 | const x = c.issue(); | |
| 186 | const owner = "o"; | |
| 187 | const x = c.issue(owner); | |
| 186 | 188 | t = 1000; |
| 187 | 189 | expect(c.consume(x)).toBe(false); |
| 188 | 190 | }); |
High: the default owner
""puts every caller that doesn't pass an owner into one bucket capped atperOwner(20). Today that is signup, claim, everyday, firstAdmin, add and recover. One source sending 20 options requests evicts every pending challenge in that store. Makeownerrequired and passclientBucket(ctx.request)(or the user id foradd) at each call site.