@openparachute/hub 0.7.4-rc.15 → 0.7.4-rc.16

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.15",
3
+ "version": "0.7.4-rc.16",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -312,6 +312,78 @@ describe("/api/account/2fa start + confirm", () => {
312
312
  const body = (await res.json()) as { error: string };
313
313
  expect(body.error).toBe("setup_expired");
314
314
  });
315
+
316
+ test("confirm is rate-limited after 10 attempts → 429 (lenient, #712)", async () => {
317
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
318
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
319
+ const { secret } = (await startRes.json()) as { secret: string };
320
+ // 10 honest mistypes are admitted (each 400 invalid_code) — the lenient
321
+ // bucket doesn't punish a fumbling enroller.
322
+ for (let i = 0; i < 10; i++) {
323
+ const r = await post("/2fa/confirm", cookie, {
324
+ __csrf: TEST_CSRF,
325
+ secret,
326
+ code: "000000",
327
+ });
328
+ expect(r.status).toBe(400);
329
+ }
330
+ // 11th is denied by the limiter BEFORE the code is checked.
331
+ const denied = await post("/2fa/confirm", cookie, {
332
+ __csrf: TEST_CSRF,
333
+ secret,
334
+ code: "000000",
335
+ });
336
+ expect(denied.status).toBe(429);
337
+ const body = (await denied.json()) as { error: string };
338
+ expect(body.error).toBe("too_many_attempts");
339
+ expect(denied.headers.get("retry-after")).toBeTruthy();
340
+ // The grind never touched enrollment.
341
+ expect(isTotpEnrolled(harness.db, userId)).toBe(false);
342
+ });
343
+
344
+ test("malformed-secret POSTs don't burn the confirm budget (#712)", async () => {
345
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
346
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
347
+ const { secret } = (await startRes.json()) as { secret: string };
348
+ // 10 junk POSTs are rejected by the format guard BEFORE the limiter runs.
349
+ for (let i = 0; i < 10; i++) {
350
+ const r = await post("/2fa/confirm", cookie, {
351
+ __csrf: TEST_CSRF,
352
+ secret: "not-base32!!",
353
+ code: "000000",
354
+ });
355
+ expect(r.status).toBe(400);
356
+ }
357
+ // Budget untouched — the legit live code still enrolls on the next attempt.
358
+ const ok = await post("/2fa/confirm", cookie, {
359
+ __csrf: TEST_CSRF,
360
+ secret,
361
+ code: liveCode(secret),
362
+ });
363
+ expect(ok.status).toBe(200);
364
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
365
+ });
366
+
367
+ test("a few mistypes then the live code within budget still enrolls (lenient)", async () => {
368
+ const { userId, cookie } = await userWithSession(harness.db, "owner", "owner-password-123");
369
+ const startRes = await post("/2fa/start", cookie, { __csrf: TEST_CSRF });
370
+ const { secret } = (await startRes.json()) as { secret: string };
371
+ for (let i = 0; i < 3; i++) {
372
+ const r = await post("/2fa/confirm", cookie, {
373
+ __csrf: TEST_CSRF,
374
+ secret,
375
+ code: "000000",
376
+ });
377
+ expect(r.status).toBe(400);
378
+ }
379
+ const ok = await post("/2fa/confirm", cookie, {
380
+ __csrf: TEST_CSRF,
381
+ secret,
382
+ code: liveCode(secret),
383
+ });
384
+ expect(ok.status).toBe(200);
385
+ expect(isTotpEnrolled(harness.db, userId)).toBe(true);
386
+ });
315
387
  });
316
388
 
317
389
  describe("/api/account/2fa/disable", () => {
@@ -40,7 +40,7 @@ import type { Database } from "bun:sqlite";
40
40
  import { hash as argonHash } from "@node-rs/argon2";
41
41
  import QRCode from "qrcode";
42
42
  import { verifyCsrfToken } from "./csrf.ts";
43
- import { changePasswordRateLimiter } from "./rate-limit.ts";
43
+ import { changePasswordRateLimiter, totpEnrollConfirmRateLimiter } from "./rate-limit.ts";
44
44
  import { findActiveSession } from "./sessions.ts";
45
45
  import { generateTotpSecret, otpauthUrlFor, verifyTotpCode } from "./totp.ts";
46
46
  import {
@@ -220,6 +220,28 @@ async function handleConfirm(
220
220
  if (isTotpEnrolled(deps.db, user.id)) {
221
221
  return jsonError(409, "already_enrolled", "Two-factor is already enabled.");
222
222
  }
223
+ // Bound a hijacked session grinding the in-flight (client-held) secret. Keyed
224
+ // by user.id, lenient (10/15min) so honest enroll mistypes aren't punished —
225
+ // defense-in-depth (#712). Fires AFTER the format + already-enrolled guards so
226
+ // junk/no-op POSTs don't burn the legit enroller's budget, and BEFORE the
227
+ // code verify so the grind window is actually bounded. A SUCCESSFUL confirm
228
+ // also consumes one slot (checkAndRecord counts every attempt) — harmless,
229
+ // since an enrolled account 409s on any further confirm anyway.
230
+ const confirmLimited = totpEnrollConfirmRateLimiter.checkAndRecord(
231
+ user.id,
232
+ deps.now ? deps.now() : new Date(),
233
+ );
234
+ if (!confirmLimited.allowed) {
235
+ const retryAfter = confirmLimited.retryAfterSeconds ?? 1;
236
+ return json(
237
+ 429,
238
+ {
239
+ error: "too_many_attempts",
240
+ error_description: `Too many attempts. Try again in ${retryAfter} seconds.`,
241
+ },
242
+ { "retry-after": String(retryAfter) },
243
+ );
244
+ }
223
245
  if (!verifyTotpCode(secret, code)) {
224
246
  return jsonError(
225
247
  400,
package/src/rate-limit.ts CHANGED
@@ -87,6 +87,21 @@ export const CHANGE_PASSWORD_WINDOW_MS = 5 * 60 * 1000;
87
87
  * cookie attacker shouldn't get a 5-shot grind window.
88
88
  */
89
89
  export const CHANGE_PASSWORD_MAX_ATTEMPTS = 3;
90
+ /**
91
+ * `/api/account/2fa/confirm` (TOTP enrollment seal) window: 15 minutes. This is
92
+ * the SELF-only, already-session-authenticated enrollment step — the operator is
93
+ * typing the first live code off their own authenticator while it drifts into
94
+ * sync, so legitimate mistypes are common and must not be punished. The threat
95
+ * is only a hijacked session grinding the (client-held, not-yet-persisted)
96
+ * in-flight secret, which the 10^6 code space + replay cache already make
97
+ * effectively non-exploitable — so this is defense-in-depth, deliberately MORE
98
+ * generous than the 3/5-min change-password bucket. NOT the `/login/2fa` bucket:
99
+ * that one is the strict, pre-auth brute-force door (5/15-min); enrollment is a
100
+ * different, lower-risk surface and gets its own lenient bucket.
101
+ */
102
+ export const TOTP_ENROLL_CONFIRM_WINDOW_MS = 15 * 60 * 1000;
103
+ /** `/api/account/2fa/confirm` attempts allowed per window. 11th is denied. */
104
+ export const TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS = 10;
90
105
  /**
91
106
  * `/login/2fa` window length: 15 minutes — same as `/login`. The second-
92
107
  * factor step (hub#473) sits behind a verified password + a short-lived
@@ -289,6 +304,18 @@ export const changePasswordRateLimiter = new RateLimiter(
289
304
  */
290
305
  export const totpRateLimiter = new RateLimiter(TOTP_MAX_ATTEMPTS, TOTP_WINDOW_MS);
291
306
 
307
+ /**
308
+ * `/api/account/2fa/confirm` enrollment-seal bucket. Lenient (10 / 15 min),
309
+ * keyed by `user.id` (the session already establishes identity). Separate from
310
+ * `totpRateLimiter` so an enrollment mistype and a `/login/2fa` failure never
311
+ * share a window — different surfaces, different threat models (see the const
312
+ * docs above).
313
+ */
314
+ export const totpEnrollConfirmRateLimiter = new RateLimiter(
315
+ TOTP_ENROLL_CONFIRM_MAX_ATTEMPTS,
316
+ TOTP_ENROLL_CONFIRM_WINDOW_MS,
317
+ );
318
+
292
319
  /**
293
320
  * Coarse per-IP CEILING rate limiter — 60 attempts / 15 min, keyed by client
294
321
  * IP ONLY. Shared by all interactive auth doors (`/login`, the
@@ -342,6 +369,7 @@ export function __resetForTests(): void {
342
369
  loginRateLimiter.reset();
343
370
  changePasswordRateLimiter.reset();
344
371
  totpRateLimiter.reset();
372
+ totpEnrollConfirmRateLimiter.reset();
345
373
  vaultTokenMintRateLimiter.reset();
346
374
  signupRateLimiter.reset();
347
375
  authIpCeilingRateLimiter.reset();