@openparachute/hub 0.7.3-rc.4 → 0.7.3-rc.6

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.
@@ -21,7 +21,13 @@ import {
21
21
  getPendingLogin,
22
22
  parsePendingLoginCookie,
23
23
  } from "./pending-login.ts";
24
- import { checkAndRecord, clientIpFromRequest, totpRateLimiter } from "./rate-limit.ts";
24
+ import {
25
+ authIpCeilingRateLimiter,
26
+ clientIpFromRequest,
27
+ compositeKey,
28
+ loginRateLimiter,
29
+ totpRateLimiter,
30
+ } from "./rate-limit.ts";
25
31
  import { isHttpsRequest } from "./request-protocol.ts";
26
32
  import {
27
33
  SESSION_TTL_MS,
@@ -167,24 +173,42 @@ export async function handleAdminLoginPost(
167
173
  );
168
174
  }
169
175
  // Rate-limit gate fires *after* CSRF (so a junk cross-site POST doesn't
170
- // burn a bucket slot for the victim's IP) but *before* credential check.
176
+ // burn a bucket slot for the victim) but *before* credential check.
171
177
  // Every legitimate login attempt — wrong password, missing user, eventually
172
- // failed-2FA (#186) — counts toward the same bucket so an attacker can't
173
- // partition the cooldown across stages.
178
+ // failed-2FA — counts toward the same bucket so an attacker can't partition
179
+ // the cooldown across stages.
180
+ //
181
+ // Two tiers (the shared-egress-IP fix): a coarse per-IP CEILING (60/15min)
182
+ // backstops username-rotation, then a per-(ip,username) FLOOR (5/15min) so
183
+ // each account behind a shared NAT / Cloudflare egress IP gets its own
184
+ // bucket instead of the whole room pooling into one 5-slot per-IP bucket.
185
+ // `username` is hoisted above the gate because the floor keys on it. The same
186
+ // `loginRateLimiter` + `compositeKey(ip, username)` scheme backs the
187
+ // `/oauth/authorize` password door, so both doors share ONE per-account
188
+ // bucket.
189
+ const username = String(form.get("username") ?? "");
174
190
  const clientIp = clientIpFromRequest(req);
175
191
  const now = deps.now ? deps.now() : new Date();
176
- const gate = checkAndRecord(clientIp, now);
177
- if (!gate.allowed) {
192
+ // Both checks always run (no short-circuit): a denied `checkAndRecord` does
193
+ // NOT append a timestamp, so running the floor after a denied ceiling never
194
+ // double-counts. We need both recorded so each independently tracks its own
195
+ // bucket (the floor still gates per-account even when the ceiling is fine).
196
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
197
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
198
+ if (!ceiling.allowed || !floor.allowed) {
199
+ const retryAfterSeconds = Math.max(
200
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
201
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
202
+ );
178
203
  return htmlResponse(
179
204
  renderAdminError({
180
205
  title: "Too many login attempts",
181
- message: `Too many login attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
206
+ message: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
182
207
  }),
183
208
  429,
184
- { "retry-after": String(gate.retryAfterSeconds ?? 1) },
209
+ { "retry-after": String(retryAfterSeconds) },
185
210
  );
186
211
  }
187
- const username = String(form.get("username") ?? "");
188
212
  const password = String(form.get("password") ?? "");
189
213
  const next = safeNext(String(form.get("next") ?? ""));
190
214
  const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
@@ -314,23 +338,36 @@ export async function handleAdminLoginTotpPost(
314
338
  const next = safeNext(String(form.get("next") ?? ""));
315
339
  const code = String(form.get("code") ?? "");
316
340
 
317
- // Rate-limit BEFORE resolving the pending login or verifying the factor.
341
+ // Rate-limit BEFORE verifying the factor. Resolve the pending login FIRST so
342
+ // the per-account floor can key on the STABLE userId (NOT the pendingToken,
343
+ // which a fresh /login success rotates and would reset the bucket). Resolving
344
+ // here also lets a bad/expired pending token fall back to keying the floor by
345
+ // IP alone — never by the rotating pendingToken.
318
346
  const clientIp = clientIpFromRequest(req);
319
347
  const now = deps.now ? deps.now() : new Date();
320
- const gate = totpRateLimiter.checkAndRecord(clientIp, now);
321
- if (!gate.allowed) {
348
+ const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
349
+ const pending = getPendingLogin(pendingToken, () => now);
350
+ // Two tiers (the shared-egress-IP fix): coarse per-IP CEILING (60/15min) +
351
+ // per-(ip,userId) FLOOR (5/15min). When userId can't be resolved (bad/expired
352
+ // pending token), key the floor by IP alone — do NOT key by the pendingToken.
353
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
354
+ const floorKey = pending ? compositeKey(clientIp, pending.userId) : clientIp;
355
+ const floor = totpRateLimiter.checkAndRecord(floorKey, now);
356
+ if (!ceiling.allowed || !floor.allowed) {
357
+ const retryAfterSeconds = Math.max(
358
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
359
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
360
+ );
322
361
  return htmlResponse(
323
362
  renderAdminError({
324
363
  title: "Too many attempts",
325
- message: `Too many verification attempts from this IP. Try again in ${gate.retryAfterSeconds ?? 1} seconds.`,
364
+ message: `Too many verification attempts. Try again in ${retryAfterSeconds} seconds.`,
326
365
  }),
327
366
  429,
328
- { "retry-after": String(gate.retryAfterSeconds ?? 1) },
367
+ { "retry-after": String(retryAfterSeconds) },
329
368
  );
330
369
  }
331
370
 
332
- const pendingToken = parsePendingLoginCookie(req.headers.get("cookie"));
333
- const pending = getPendingLogin(pendingToken, () => now);
334
371
  if (!pending) {
335
372
  // No live pending login — expired, missing, or tampered. Send the user
336
373
  // back to the start; clear any stale pending cookie.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `/api/vault-caps*` — admin visibility + edit for per-vault storage caps
3
+ * (DEMO-PREP-2026-06-25 Workstream B5 / D-slice).
4
+ *
5
+ * PR #686 shipped the `vault_caps` table + `setVaultCap`/`getVaultCapBytes`
6
+ * (provision-time persistence) and a separate Phase-2 PR reads + ENFORCES the
7
+ * cap at upload time. This file is the OPERATOR-FACING seam in between: the
8
+ * admin SPA needs to SEE who has what cap and EDIT a cap live in the demo.
9
+ *
10
+ * Surfaces:
11
+ *
12
+ * GET /api/vault-caps list every vault (from services.json) joined
13
+ * with its persisted cap (host:admin)
14
+ * PUT /api/vault-caps/:name set/update a vault's cap to N bytes (host:admin)
15
+ *
16
+ * The list is the JOIN of "what vaults exist" (services.json, the canonical
17
+ * vault-name source) and "what caps are persisted" (`vault_caps`). A vault
18
+ * with no cap row appears with `cap_bytes: null` (uncapped) — the same
19
+ * "uncapped = no row" contract the Phase-2 enforcement reader relies on.
20
+ *
21
+ * Wire shape is snake_case (matches `/api/users`, `/api/invites`). Auth: same
22
+ * `parachute:host:admin` Bearer gate as every other `/api/*` admin surface;
23
+ * the SPA mints it from the session cookie via `/admin/host-admin-token`.
24
+ *
25
+ * Scope discipline: PUT only sets a cap on a vault that is REGISTERED in
26
+ * services.json (rejects a stale / typo name with 400 `vault_not_found`) so an
27
+ * operator can't seed a cap row for a vault that doesn't exist. Additive only —
28
+ * this never changes the provision-time cap-persistence from #686; it's a
29
+ * read + targeted-edit layer on the same table.
30
+ */
31
+ import type { Database } from "bun:sqlite";
32
+ import { type AdminAuthError, adminAuthErrorResponse, requireScope } from "./admin-auth.ts";
33
+ import { HOST_ADMIN_SCOPE } from "./admin-vaults.ts";
34
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
35
+ import { getVaultCap, setVaultCap } from "./vault-caps.ts";
36
+ import { listVaultNamesFromPath } from "./vault-names.ts";
37
+
38
+ export interface ApiVaultCapsDeps {
39
+ db: Database;
40
+ /** Hub origin — JWT `iss` validation. */
41
+ issuer: string;
42
+ /** Override services.json path. Defaults to `~/.parachute/services.json`. */
43
+ manifestPath?: string;
44
+ }
45
+
46
+ /**
47
+ * One row in the `GET /api/vault-caps` response: a vault name + its cap. A
48
+ * vault with no persisted cap carries `cap_bytes: null` (uncapped); `created_at`
49
+ * / `updated_at` are null too in that case.
50
+ */
51
+ export interface VaultCapWireShape {
52
+ vault_name: string;
53
+ cap_bytes: number | null;
54
+ created_at: string | null;
55
+ updated_at: string | null;
56
+ }
57
+
58
+ function jsonError(status: number, error: string, description: string): Response {
59
+ return new Response(JSON.stringify({ error, error_description: description }), {
60
+ status,
61
+ headers: { "content-type": "application/json" },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * GET /api/vault-caps — every vault registered in services.json, joined with
67
+ * its persisted cap (null = uncapped). Ordered by vault name.
68
+ */
69
+ export async function handleListVaultCaps(req: Request, deps: ApiVaultCapsDeps): Promise<Response> {
70
+ if (req.method !== "GET") {
71
+ return jsonError(405, "method_not_allowed", "use GET");
72
+ }
73
+ try {
74
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
75
+ } catch (err) {
76
+ return adminAuthErrorResponse(err as AdminAuthError);
77
+ }
78
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
79
+ const names = listVaultNamesFromPath(manifestPath);
80
+ const caps: VaultCapWireShape[] = names.map((vaultName) => {
81
+ const cap = getVaultCap(deps.db, vaultName);
82
+ return {
83
+ vault_name: vaultName,
84
+ cap_bytes: cap?.capBytes ?? null,
85
+ created_at: cap?.createdAt ?? null,
86
+ updated_at: cap?.updatedAt ?? null,
87
+ };
88
+ });
89
+ return new Response(JSON.stringify({ vault_caps: caps }), {
90
+ status: 200,
91
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
92
+ });
93
+ }
94
+
95
+ interface SetCapBody {
96
+ cap_bytes: number;
97
+ }
98
+
99
+ interface ParseErr {
100
+ ok: false;
101
+ status: number;
102
+ error: string;
103
+ description: string;
104
+ }
105
+
106
+ async function parseSetCapBody(req: Request): Promise<{ ok: true; body: SetCapBody } | ParseErr> {
107
+ const ctype = req.headers.get("content-type") ?? "";
108
+ if (!ctype.toLowerCase().includes("application/json")) {
109
+ return {
110
+ ok: false,
111
+ status: 400,
112
+ error: "invalid_request",
113
+ description: "Content-Type must be application/json",
114
+ };
115
+ }
116
+ let raw: unknown;
117
+ try {
118
+ raw = await req.json();
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err);
121
+ return {
122
+ ok: false,
123
+ status: 400,
124
+ error: "invalid_request",
125
+ description: `invalid JSON body: ${msg}`,
126
+ };
127
+ }
128
+ if (!raw || typeof raw !== "object") {
129
+ return {
130
+ ok: false,
131
+ status: 400,
132
+ error: "invalid_request",
133
+ description: "request body must be a JSON object",
134
+ };
135
+ }
136
+ const obj = raw as Record<string, unknown>;
137
+ const capBytes = obj.cap_bytes;
138
+ // Positive integer only — the table's CHECK (cap_bytes > 0) is the at-rest
139
+ // backstop; this is the edge validation so the operator gets a clean 400
140
+ // instead of a sqlite constraint error. A fractional byte count (which a byte
141
+ // count can never be) is rejected rather than silently floored downstream.
142
+ if (typeof capBytes !== "number" || !Number.isInteger(capBytes) || capBytes <= 0) {
143
+ return {
144
+ ok: false,
145
+ status: 400,
146
+ error: "invalid_request",
147
+ description: '"cap_bytes" must be a positive integer number of bytes',
148
+ };
149
+ }
150
+ return { ok: true, body: { cap_bytes: capBytes } };
151
+ }
152
+
153
+ /**
154
+ * PUT /api/vault-caps/:name — set or update a vault's storage cap.
155
+ *
156
+ * Order of checks (mirrors the /api/users handlers):
157
+ *
158
+ * 1. Method gate (405 on non-PUT).
159
+ * 2. Bearer carries `parachute:host:admin` (401 / 403 via `requireScope`).
160
+ * 3. Parse + validate body (400 on shape / non-positive cap).
161
+ * 4. Vault is registered in services.json (400 `vault_not_found`) — refuse
162
+ * to seed a cap for a vault that doesn't exist.
163
+ * 5. `setVaultCap` (upsert) — overwrites the cap, bumps `updated_at`,
164
+ * preserves the original `created_at`.
165
+ *
166
+ * Response on success: `200 { vault_cap: <wire shape> }`.
167
+ */
168
+ export async function handleSetVaultCap(
169
+ req: Request,
170
+ vaultName: string,
171
+ deps: ApiVaultCapsDeps,
172
+ ): Promise<Response> {
173
+ if (req.method !== "PUT") {
174
+ return jsonError(405, "method_not_allowed", "use PUT");
175
+ }
176
+ try {
177
+ await requireScope(deps.db, req, HOST_ADMIN_SCOPE, deps.issuer);
178
+ } catch (err) {
179
+ return adminAuthErrorResponse(err as AdminAuthError);
180
+ }
181
+ const parsed = await parseSetCapBody(req);
182
+ if (!parsed.ok) {
183
+ return jsonError(parsed.status, parsed.error, parsed.description);
184
+ }
185
+ const manifestPath = deps.manifestPath ?? SERVICES_MANIFEST_PATH;
186
+ const known = new Set(listVaultNamesFromPath(manifestPath));
187
+ if (!known.has(vaultName)) {
188
+ return jsonError(
189
+ 400,
190
+ "vault_not_found",
191
+ `vault "${vaultName}" is not registered in services.json`,
192
+ );
193
+ }
194
+ const cap = setVaultCap(deps.db, vaultName, parsed.body.cap_bytes);
195
+ console.log(`vault cap set: vault=${vaultName} cap_bytes=${cap.capBytes}`);
196
+ const wire: VaultCapWireShape = {
197
+ vault_name: cap.vaultName,
198
+ cap_bytes: cap.capBytes,
199
+ created_at: cap.createdAt,
200
+ updated_at: cap.updatedAt,
201
+ };
202
+ return new Response(JSON.stringify({ vault_cap: wire }), {
203
+ status: 200,
204
+ headers: { "content-type": "application/json", "cache-control": "no-store" },
205
+ });
206
+ }
package/src/hub-server.ts CHANGED
@@ -96,6 +96,8 @@
96
96
  * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
97
97
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
98
98
  * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
99
+ * /api/vault-caps (GET) → list vaults + persisted storage caps (host:admin)
100
+ * /api/vault-caps/<name> (PUT) → set/update a vault's storage cap (host:admin)
99
101
  * /login (GET + POST) → operator password login
100
102
  * /login/2fa (POST) → second-factor (TOTP/backup) step
101
103
  * (hub#473; reached after a correct
@@ -221,6 +223,7 @@ import {
221
223
  handleResetUserPassword,
222
224
  handleUpdateUserVaults,
223
225
  } from "./api-users.ts";
226
+ import { handleListVaultCaps, handleSetVaultCap } from "./api-vault-caps.ts";
224
227
  import { gateUiAudience, resolveUiMount } from "./audience-gate.ts";
225
228
  import {
226
229
  CHROME_OPT_OUT_PREFIXES,
@@ -3351,6 +3354,30 @@ export function hubFetch(
3351
3354
  });
3352
3355
  }
3353
3356
 
3357
+ // Per-vault storage caps (B5 admin visibility / D-slice). GET lists every
3358
+ // vault from services.json joined with its persisted cap; PUT /:name
3359
+ // sets/updates a cap. host:admin-gated, same gate flavor as /api/users.
3360
+ if (pathname === "/api/vault-caps") {
3361
+ if (!getDb) return dbNotConfigured();
3362
+ return handleListVaultCaps(req, {
3363
+ db: getDb(),
3364
+ issuer: oauthDeps(req).issuer,
3365
+ manifestPath,
3366
+ });
3367
+ }
3368
+ if (pathname.startsWith("/api/vault-caps/")) {
3369
+ if (!getDb) return dbNotConfigured();
3370
+ const name = decodeURIComponent(pathname.slice("/api/vault-caps/".length));
3371
+ if (!name || name.includes("/")) {
3372
+ return new Response("not found", { status: 404 });
3373
+ }
3374
+ return handleSetVaultCap(req, name, {
3375
+ db: getDb(),
3376
+ issuer: oauthDeps(req).issuer,
3377
+ manifestPath,
3378
+ });
3379
+ }
3380
+
3354
3381
  // Canonical login/logout. The handlers themselves are unchanged from
3355
3382
  // when they lived at /admin/login + /admin/logout; the rename surfaced
3356
3383
  // via #231-followup so the URL reflects the surface's actual scope
@@ -72,6 +72,12 @@ import {
72
72
  } from "./oauth-ui.ts";
73
73
  import { isSameOriginRequest } from "./origin-check.ts";
74
74
  import { buildPendingLoginCookie, createPendingLogin } from "./pending-login.ts";
75
+ import {
76
+ authIpCeilingRateLimiter,
77
+ clientIpFromRequest,
78
+ compositeKey,
79
+ loginRateLimiter,
80
+ } from "./rate-limit.ts";
75
81
  import { isHttpsRequest } from "./request-protocol.ts";
76
82
  import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
77
83
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
@@ -1386,12 +1392,40 @@ async function handleLoginSubmit(
1386
1392
  db: Database,
1387
1393
  req: Request,
1388
1394
  form: Awaited<ReturnType<Request["formData"]>>,
1389
- _deps: OAuthDeps,
1395
+ deps: OAuthDeps,
1390
1396
  csrfToken: string,
1391
1397
  ): Promise<Response> {
1392
1398
  const username = String(form.get("username") ?? "");
1393
1399
  const password = String(form.get("password") ?? "");
1394
1400
  const params = paramsFromForm(form);
1401
+ // Rate-limit gate — AFTER CSRF (verified by the `handleAuthorizePost` caller)
1402
+ // and BEFORE the credential check. This `/oauth/authorize` password door had
1403
+ // NO rate-limit before; without it an attacker got an unbounded brute-force
1404
+ // channel that bypassed `/login`'s floor entirely. Two tiers, reusing the
1405
+ // SAME `loginRateLimiter` instance + the SAME `compositeKey(ip, username)`
1406
+ // scheme as `/login`, so the two password doors share ONE per-account bucket
1407
+ // (an attacker can't get 5 tries at `/login` PLUS another 5 here). The coarse
1408
+ // per-IP CEILING backstops username-rotation; the per-(ip,username) FLOOR is
1409
+ // the brute-force floor.
1410
+ const clientIp = clientIpFromRequest(req);
1411
+ const now = deps.now ? deps.now() : new Date();
1412
+ const ceiling = authIpCeilingRateLimiter.checkAndRecord(clientIp, now);
1413
+ const floor = loginRateLimiter.checkAndRecord(compositeKey(clientIp, username), now);
1414
+ if (!ceiling.allowed || !floor.allowed) {
1415
+ const retryAfterSeconds = Math.max(
1416
+ ceiling.allowed ? 0 : (ceiling.retryAfterSeconds ?? 1),
1417
+ floor.allowed ? 0 : (floor.retryAfterSeconds ?? 1),
1418
+ );
1419
+ return htmlResponse(
1420
+ renderLogin({
1421
+ params,
1422
+ csrfToken,
1423
+ errorMessage: `Too many login attempts. Try again in ${retryAfterSeconds} seconds.`,
1424
+ }),
1425
+ 429,
1426
+ { "retry-after": String(retryAfterSeconds) },
1427
+ );
1428
+ }
1395
1429
  if (!username || !password) {
1396
1430
  return htmlResponse(
1397
1431
  renderLogin({ params, csrfToken, errorMessage: "Username and password are required." }),
package/src/rate-limit.ts CHANGED
@@ -8,15 +8,16 @@
8
8
  * because its endpoint is meant to be redeemed by a room of people sharing one
9
9
  * egress IP (see `SIGNUP_MAX_ATTEMPTS`).
10
10
  *
11
- * - `/login` (per-IP, hub#187 / hub#188): 5 attempts / 15 min.
12
- * Lands as a floor under brute-force after hub#187 collapsed the
13
- * public-reach matrix: with a cloudflare tunnel up, `/login` is now
14
- * reachable from the open internet, and 2FA (#186) is the next PR
15
- * rather than this one. A 5-attempts-per-15-minute bucket per IP is
16
- * the standard login-form floor; it's not the primary defense, just
17
- * the one that turns "infinite credential grinding" into "rotate IPs".
18
- * (Endpoint was `/admin/login` pre-rename; bucket logic is path-
19
- * agnostic so the rename was a comment-only change here.)
11
+ * - `/login` (per-account floor + per-IP ceiling): the FLOOR is keyed by
12
+ * `compositeKey(ip, username)` at 5 attempts / 15 min, shared with the
13
+ * `/oauth/authorize` password door so both doors feed ONE per-account
14
+ * bucket; the CEILING is `authIpCeilingRateLimiter` at 60 / 15 min keyed
15
+ * by IP alone. Re-keyed from pure per-IP so a room of users behind one
16
+ * NAT'd egress IP (shared wifi / cloudflare tunnel) no longer pool into a
17
+ * single 5-slot bucket the per-account floor still turns "infinite
18
+ * credential grinding" into "rotate IPs", while the IP ceiling backstops
19
+ * username-rotation. (Endpoint was `/admin/login` pre-rename; bucket
20
+ * logic is path-agnostic so the rename was a comment-only change here.)
20
21
  *
21
22
  * - `/account/change-password` (per-user, hub#282): 3 attempts / 5 min.
22
23
  * The endpoint is session-gated, so the threat model isn't open-
@@ -91,13 +92,27 @@ export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
91
92
  * factor step (hub#473) sits behind a verified password + a short-lived
92
93
  * pending-login token, so the threat model is "attacker who already has the
93
94
  * password grinding 6-digit codes / backup codes." A 5-attempt / 15-min
94
- * bucket per IP turns 10^6-space TOTP grinding into "rotate IPs," same floor
95
- * as `/login`. Keyed by IP (the pending-login token is short-lived and an
96
- * attacker could mint many, so IP is the stable actor key here).
95
+ * floor turns 10^6-space TOTP grinding into "rotate IPs," same floor as
96
+ * `/login`. Keyed by `compositeKey(ip, userId)` (the shared-egress-IP fix) so
97
+ * each 2FA-enrolled user behind one NAT'd / Cloudflare egress IP gets their own
98
+ * floor — the STABLE userId from the pending-login, NOT the rotating pending
99
+ * token. The coarse per-IP `authIpCeilingRateLimiter` backstops it.
97
100
  */
98
101
  export const TOTP_WINDOW_MS = 15 * 60 * 1000;
99
102
  /** `/login/2fa` attempts allowed per window. 6th within the window is denied. */
100
103
  export const TOTP_MAX_ATTEMPTS = 5;
104
+ /**
105
+ * Coarse per-IP CEILING shared by all interactive auth doors (`/login`, the
106
+ * `/oauth/authorize` password door, `/login/2fa`). 60 attempts / 15 min — the
107
+ * same generous cap as public signup (`SIGNUP_MAX_ATTEMPTS`), chosen so a
108
+ * ~20-person demo room behind ONE NAT'd / Cloudflare egress IP clears it
109
+ * comfortably. It exists ONLY as a backstop against username-rotation: the
110
+ * per-(ip,identity) FLOORs stay tight at 5/15min, but an attacker who rotates
111
+ * usernames against one IP would otherwise get a fresh 5-slot floor per name.
112
+ * This ceiling caps total per-IP auth volume regardless of identity. Reuses the
113
+ * 15-min `WINDOW_MS`.
114
+ */
115
+ export const AUTH_IP_CEILING_MAX_ATTEMPTS = 60;
101
116
  /**
102
117
  * `POST /account/vault-token/<name>` window length: 10 minutes. The endpoint
103
118
  * is session-gated and assignment-capped (a friend can only mint
@@ -235,9 +250,19 @@ export class RateLimiter {
235
250
  }
236
251
 
237
252
  /**
238
- * `/login` rate limiter — per-IP, 5 attempts / 15 min. Exported as a
239
- * singleton so all callers share one bucket map (rotating IPs across the
240
- * test suite or across a real attack must hit the same backing store).
253
+ * `/login` (+ the `/oauth/authorize` password door) rate limiter — per-
254
+ * account/per-half-login floor, keyed (ip,identity), 5 attempts / 15 min.
255
+ * RE-KEYED from per-IP to `compositeKey(ip, username)` (the shared-egress-IP
256
+ * fix): behind NAT / Cloudflare every user in a room presents one
257
+ * CF-Connecting-IP, so a per-IP-only key pooled the whole room into one 5-slot
258
+ * bucket and 429'd the 6th legitimate sign-in. Keyed by (ip,username) instead,
259
+ * each account gets its own floor while still pinning the floor to the IP so a
260
+ * single attacker can't grind one account from one box past 5 tries. BOTH
261
+ * password doors (`/login` and `/oauth/authorize` `__action=login`) share this
262
+ * ONE instance + the SAME key scheme, so an attacker can't get 5 tries per door
263
+ * against the same account. Exported as a singleton so all callers share one
264
+ * bucket map. The coarse per-IP `authIpCeilingRateLimiter` backstops
265
+ * username-rotation across this floor.
241
266
  */
242
267
  export const loginRateLimiter = new RateLimiter(MAX_ATTEMPTS, WINDOW_MS);
243
268
 
@@ -252,14 +277,30 @@ export const changePasswordRateLimiter = new RateLimiter(
252
277
  );
253
278
 
254
279
  /**
255
- * `/login/2fa` rate limiter — per-IP, 5 attempts / 15 min (hub#473). Bounds
256
- * second-factor grinding by an attacker who already has the password. Separate
280
+ * `/login/2fa` rate limiter — per-account/per-half-login floor, keyed
281
+ * (ip,identity), 5 attempts / 15 min (hub#473). Bounds second-factor grinding
282
+ * by an attacker who already has the password. RE-KEYED from per-IP to
283
+ * `compositeKey(ip, userId)` (the shared-egress-IP fix): keyed by the STABLE
284
+ * userId resolved from the pending-login (NOT the pendingToken, which rotates
285
+ * on every /login success and would reset the bucket). Behind NAT every 2FA-
286
+ * enrolled user in a room would otherwise pool into one per-IP bucket. Separate
257
287
  * bucket from `/login` so a password failure and a TOTP failure don't share a
258
- * window but both are per-IP so rotating egress IPs is the only escape, same
259
- * as the password floor.
288
+ * window. The coarse per-IP `authIpCeilingRateLimiter` backstops this floor.
260
289
  */
261
290
  export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
262
291
 
292
+ /**
293
+ * Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
294
+ * IP ONLY. Shared by all interactive auth doors (`/login`, the
295
+ * `/oauth/authorize` password door, `/login/2fa`) as a backstop against
296
+ * username-rotation: the per-(ip,identity) floors above stay tight, but an
297
+ * attacker rotating usernames against one IP would otherwise get a fresh floor
298
+ * per name. This ceiling caps total per-IP auth volume regardless of identity.
299
+ * Deliberately generous (matches the signup precedent) so a demo room sharing
300
+ * one egress IP clears it.
301
+ */
302
+ export const authIpCeilingRateLimiter = new RateLimiter(AUTH_IP_CEILING_MAX_ATTEMPTS, WINDOW_MS);
303
+
263
304
  /**
264
305
  * `POST /account/vault-token/<name>` rate limiter — per-user, 10 attempts /
265
306
  * 10 min (friend vault-token mint). Keyed by user-id (session-gated endpoint,
@@ -303,6 +344,19 @@ export function __resetForTests(): void {
303
344
  totpRateLimiter.reset();
304
345
  vaultTokenMintRateLimiter.reset();
305
346
  signupRateLimiter.reset();
347
+ authIpCeilingRateLimiter.reset();
348
+ }
349
+
350
+ /**
351
+ * Compose a per-(ip,identity) rate-limit key. Used by the per-account auth
352
+ * FLOORs (`/login` + `/oauth/authorize` keyed by username; `/login/2fa` keyed
353
+ * by userId) so each account behind a shared egress IP gets its own bucket
354
+ * while the floor still pins to the IP. The identity is trimmed + lowercased so
355
+ * `'Alice'` and `'alice'` share one bucket — otherwise an attacker could
356
+ * case-flip a username to mint a fresh 5-slot floor per casing.
357
+ */
358
+ export function compositeKey(ip: string, id: string): string {
359
+ return `${ip}|${id.trim().toLowerCase()}`;
306
360
  }
307
361
 
308
362
  /**