@cosmicdrift/kumiko-bundled-features 0.155.1 → 0.156.0

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.
Files changed (50) hide show
  1. package/package.json +7 -6
  2. package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
  3. package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
  4. package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
  5. package/src/agent-tools/index.ts +9 -0
  6. package/src/agent-tools/tool-catalog.ts +130 -0
  7. package/src/agent-tools/tool-dispatch.ts +82 -0
  8. package/src/agent-tools/types.ts +38 -0
  9. package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
  10. package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
  11. package/src/auth-email-password/web/auth-gate.tsx +14 -4
  12. package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
  13. package/src/auth-mfa/__tests__/totp.test.ts +2 -2
  14. package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
  15. package/src/auth-mfa/constants.ts +4 -0
  16. package/src/auth-mfa/handlers/disable.write.ts +2 -1
  17. package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
  18. package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
  19. package/src/auth-mfa/handlers/verify.write.ts +22 -12
  20. package/src/auth-mfa/recovery-codes.ts +2 -2
  21. package/src/auth-mfa/schema/user-mfa.ts +10 -0
  22. package/src/auth-mfa/totp.ts +18 -6
  23. package/src/auth-mfa/verify-factor.ts +30 -2
  24. package/src/auth-mfa/web/i18n.ts +2 -0
  25. package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
  26. package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
  27. package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
  28. package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
  29. package/src/config/__tests__/write-helpers.test.ts +23 -0
  30. package/src/config/handlers/set.write.ts +47 -1
  31. package/src/config/resolver.ts +28 -0
  32. package/src/config/write-helpers.ts +22 -0
  33. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
  34. package/src/delivery/feature.ts +1 -1
  35. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  36. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
  37. package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
  38. package/src/inbound-mail-foundation/feature.ts +3 -3
  39. package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
  40. package/src/jobs/feature.ts +1 -1
  41. package/src/legal-pages/feature.ts +1 -1
  42. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  43. package/src/page-render/branding.ts +4 -3
  44. package/src/personal-access-tokens/feature.ts +1 -1
  45. package/src/sessions/__tests__/sessions.integration.test.ts +42 -0
  46. package/src/sessions/feature.ts +2 -2
  47. package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
  48. package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
  49. package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
  50. package/src/user-data-rights/lib/tenant-file-provider.ts +0 -62
@@ -0,0 +1,38 @@
1
+ import type { EntityDefinition, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+
3
+ /** Mirrors `ToolDefinition` in `@cosmicdriftgamestudio/kumiko-ai-foundation` (providers/types.ts)
4
+ * field-for-field so a generated catalog needs no translation layer at the call site. Kept as
5
+ * a local, dependency-free type — agent-tools has no ai-foundation/enterprise dependency. */
6
+ export type ToolDefinition = {
7
+ readonly name: string;
8
+ readonly inputSchema: Readonly<Record<string, unknown>>;
9
+ readonly description: string;
10
+ };
11
+
12
+ /** Narrow, handler-first view of `Registry` — iterate query handlers (real, mounted, callable
13
+ * QNs) rather than entities, so the catalog never advertises a tool for an entity that has no
14
+ * `:list` handler mounted. Small enough that tests pass a plain object instead of a full
15
+ * 64-getter Registry. */
16
+ export type RegistrySearchView = {
17
+ getAllQueryHandlers(): ReadonlyMap<string, QueryHandlerDef>;
18
+ getHandlerEntity(qualifiedHandler: string): string | undefined;
19
+ getEntity(entityName: string): EntityDefinition | undefined;
20
+ getSearchableFields(entityName: string): readonly string[];
21
+ };
22
+
23
+ /** How to actually execute a generated tool — kept out of `ToolDefinition` (which mirrors the
24
+ * LLM-facing shape exactly) so dispatch never has to re-parse the tool name back into an entity
25
+ * + field. `buildToolCatalog` produces one of these per tool, keyed by tool name. */
26
+ export type ToolDispatchDescriptor =
27
+ | { readonly kind: "search"; readonly entityName: string; readonly qn: string }
28
+ | {
29
+ readonly kind: "findBy";
30
+ readonly entityName: string;
31
+ readonly fieldName: string;
32
+ readonly qn: string;
33
+ };
34
+
35
+ export type ToolCatalog = {
36
+ readonly tools: readonly ToolDefinition[];
37
+ readonly dispatchTable: ReadonlyMap<string, ToolDispatchDescriptor>;
38
+ };
@@ -3,6 +3,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { SignupCompleteScreen } from "../signup-complete-screen";
4
4
  import { renderWithProviders } from "./test-utils";
5
5
 
6
+ const realFetch = globalThis.fetch;
7
+
6
8
  beforeEach(() => {
7
9
  globalThis.fetch = mock(
8
10
  async () =>
@@ -15,7 +17,9 @@ beforeEach(() => {
15
17
  ),
16
18
  ) as unknown as typeof fetch;
17
19
  });
18
- afterEach(() => {});
20
+ afterEach(() => {
21
+ globalThis.fetch = realFetch;
22
+ });
19
23
 
20
24
  function fillPasswords(password: string, confirm: string): void {
21
25
  fireEvent.change(document.getElementById("signup-password") as HTMLInputElement, {
@@ -3,12 +3,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
3
  import { SignupScreen } from "../signup-screen";
4
4
  import { renderWithProviders } from "./test-utils";
5
5
 
6
+ const realFetch = globalThis.fetch;
7
+
6
8
  beforeEach(() => {
7
9
  globalThis.fetch = mock(
8
10
  async () => new Response(null, { status: 200 }),
9
11
  ) as unknown as typeof fetch;
10
12
  });
11
- afterEach(() => {});
13
+ afterEach(() => {
14
+ globalThis.fetch = realFetch;
15
+ });
12
16
 
13
17
  function fillEmail(value: string): void {
14
18
  fireEvent.change(document.getElementById("signup-email") as HTMLInputElement, {
@@ -41,8 +41,11 @@ export type LoginRouteOptions = {
41
41
  // apex pages stack providers, not gates — see auth-mount.tsx recipes).
42
42
  // Handing an app raw LoginScreen + telling it to hand-roll the challenge-
43
43
  // token swap itself is exactly how kumiko-framework#266's login-time MFA
44
- // step went missing in a real apex surface; this makes that mistake
45
- // structurally unavailable there is no lower-level piece left to misuse.
44
+ // step went missing in a real apex surface; this prevents the *most common*
45
+ // version of that mistake. MFA still only actually works once a consumer
46
+ // wires `mfaVerifyScreen` — omitting it is a silent no-MFA fallback, not
47
+ // something this route can catch on its own (auth-mfa is a separate,
48
+ // optional package).
46
49
  export function createLoginRoute(
47
50
  opts: LoginRouteOptions = {},
48
51
  ): ComponentType<Record<string, never>> {
@@ -51,13 +54,14 @@ export function createLoginRoute(
51
54
 
52
55
  function LoginRoute(): ReactNode {
53
56
  const { status } = useSession();
57
+ const { onAuthenticated } = opts;
54
58
  // Pending challenge-token from LoginScreen's onMfaChallenge. Lives here
55
59
  // (not in SessionState) because it's a UI-only transition — the server
56
60
  // never considers this session authenticated until verify succeeds.
57
61
  const [challengeToken, setChallengeToken] = useState<string | null>(null);
58
62
 
59
63
  useEffect(() => {
60
- if (status === "authenticated") opts.onAuthenticated?.();
64
+ if (status === "authenticated") onAuthenticated?.();
61
65
  }, [status]);
62
66
 
63
67
  if (status === "loading") {
@@ -65,7 +69,13 @@ export function createLoginRoute(
65
69
  <div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm" />
66
70
  );
67
71
  }
68
- if (status === "authenticated") return null;
72
+ // A standalone mount (no parent gate, no onAuthenticated wired — e.g. an
73
+ // apex/marketing surface that just places <LoginRoute /> directly) has no
74
+ // way to navigate away once authenticated. Blanking the page there is a
75
+ // regression against the old raw LoginScreen (which had no session check
76
+ // at all); fall through to the form instead. A route WITH onAuthenticated
77
+ // still returns null and trusts the effect above to redirect.
78
+ if (status === "authenticated" && onAuthenticated) return null;
69
79
  if (challengeToken !== null && MfaVerifyComponent) {
70
80
  return (
71
81
  <MfaVerifyComponent
@@ -230,4 +230,93 @@ describe("enable-confirm burns the setup token on first success", () => {
230
230
  );
231
231
  expectErrorIncludes(second, "invalid_setup_token");
232
232
  });
233
+
234
+ test("a fresh setup token confirmed while already enabled is rejected as mfa_already_enabled", async () => {
235
+ const user = createTestUser({ id: "already-enabled-fresh-token-1", roles: ["User"] });
236
+
237
+ const firstStart = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
238
+ AuthMfaHandlers.enableStart,
239
+ { accountLabel: "already-enabled@example.com" },
240
+ user,
241
+ );
242
+ // Requested before the first confirm — enable-start itself rejects with
243
+ // mfa_already_enabled once a row exists, so a fresh token can only be
244
+ // minted while the user is still unenrolled. Its expiresAtMs differs
245
+ // from firstStart's, so burnToken() (keyed on purpose+userId+expiresAtMs)
246
+ // treats it as fresh rather than a replay when confirmed below — the
247
+ // only way this confirm reaches findUserMfaRow's existing-row check
248
+ // (the sole path to mfaAlreadyEnabled()) instead of invalid_setup_token.
249
+ const secondStart = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
250
+ AuthMfaHandlers.enableStart,
251
+ { accountLabel: "already-enabled@example.com" },
252
+ user,
253
+ );
254
+ const firstSecret =
255
+ new URLSearchParams(firstStart.otpauthUri.split("?")[1]).get("secret") ?? "";
256
+ await stack.http.writeOk(
257
+ AuthMfaHandlers.enableConfirm,
258
+ { setupToken: firstStart.setupToken, code: currentTotpCode(base32Decode(firstSecret)) },
259
+ user,
260
+ );
261
+
262
+ const secondSecret =
263
+ new URLSearchParams(secondStart.otpauthUri.split("?")[1]).get("secret") ?? "";
264
+ const second = await stack.http.writeErr(
265
+ AuthMfaHandlers.enableConfirm,
266
+ { setupToken: secondStart.setupToken, code: currentTotpCode(base32Decode(secondSecret)) },
267
+ user,
268
+ );
269
+ expectErrorIncludes(second, "mfa_already_enabled");
270
+ });
271
+
272
+ // Not part of the mfa_already_enabled coverage above — this documents a
273
+ // separate, already-tracked gap: when both enable-confirms are truly
274
+ // concurrent, findUserMfaRow's existing-row check can't see the other
275
+ // request's uncommitted row, so the DB's userId+tenantId unique index
276
+ // rejects the loser with a raw `unique_violation` instead of the
277
+ // friendly mfaAlreadyEnabled() error. No duplicate row is ever created
278
+ // (the invariant this test asserts), but the error surfaced to the
279
+ // client leaks a DB-internal code. Same class of gap as pr-review
280
+ // kumiko-framework#952 idx2 (needs-framework-savepoint-primitive) —
281
+ // tracked there, not fixed here.
282
+ test("two concurrent enable-confirms for the same user never create two rows, 20x", async () => {
283
+ for (let i = 0; i < 20; i++) {
284
+ const user = createTestUser({ id: `concurrent-enable-${i}`, roles: ["User"] });
285
+
286
+ const [startA, startB] = await Promise.all([
287
+ stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
288
+ AuthMfaHandlers.enableStart,
289
+ { accountLabel: `concurrent-${i}@example.com` },
290
+ user,
291
+ ),
292
+ stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
293
+ AuthMfaHandlers.enableStart,
294
+ { accountLabel: `concurrent-${i}@example.com` },
295
+ user,
296
+ ),
297
+ ]);
298
+ const secretA = new URLSearchParams(startA.otpauthUri.split("?")[1]).get("secret") ?? "";
299
+ const secretB = new URLSearchParams(startB.otpauthUri.split("?")[1]).get("secret") ?? "";
300
+
301
+ const [resA, resB] = await Promise.all([
302
+ stack.http.write(
303
+ AuthMfaHandlers.enableConfirm,
304
+ { setupToken: startA.setupToken, code: currentTotpCode(base32Decode(secretA)) },
305
+ user,
306
+ ),
307
+ stack.http.write(
308
+ AuthMfaHandlers.enableConfirm,
309
+ { setupToken: startB.setupToken, code: currentTotpCode(base32Decode(secretB)) },
310
+ user,
311
+ ),
312
+ ]);
313
+ const bodies = (await Promise.all([resA.json(), resB.json()])) as Array<{
314
+ isSuccess?: boolean;
315
+ error?: { code?: string };
316
+ }>;
317
+
318
+ expect(bodies.filter((b) => b.isSuccess === true).length).toBe(1);
319
+ expect(bodies.filter((b) => b.isSuccess !== true).length).toBe(1);
320
+ }
321
+ });
233
322
  });
@@ -24,7 +24,7 @@ describe("TOTP — RFC 6238 vectors", () => {
24
24
  test("6-digit code at T=59 matches the last 6 digits of the RFC 8-digit vector", () => {
25
25
  const expected8 = rfcVectorCode8Digit(59);
26
26
  const expected6 = expected8.slice(-6);
27
- expect(verifyTotp(RFC_SECRET, expected6, 59_000)).toBe(true);
27
+ expect(verifyTotp(RFC_SECRET, expected6, 59_000)).not.toBe(false);
28
28
  });
29
29
 
30
30
  test("rejects a code from a different time window (>±1 step)", () => {
@@ -41,7 +41,7 @@ describe("TOTP — RFC 6238 vectors", () => {
41
41
  // the ±1 window, not same-step reuse. See the ±2-steps negative test below
42
42
  // for the window's upper bound.
43
43
  const codeAtNow = deriveCodeForTest(secret, now);
44
- expect(verifyTotp(secret, codeAtNow, now + 25_000)).toBe(true);
44
+ expect(verifyTotp(secret, codeAtNow, now + 25_000)).not.toBe(false);
45
45
  });
46
46
 
47
47
  test("rejects a code two steps in the future (outside the ±1 window)", () => {
@@ -157,7 +157,7 @@ describe("mfa verify — completes a two-step login", () => {
157
157
  expectErrorIncludes(err, "invalid_totp_code");
158
158
  });
159
159
 
160
- test("a challenge token cannot be replayed even with a still-valid TOTP code", async () => {
160
+ test("an exact-duplicate verify (same challenge token, same code) is rejected", async () => {
161
161
  const { user, secret } = await enableMfaFor(4);
162
162
  const challengeToken = challengeFor(user.id, user.tenantId);
163
163
 
@@ -167,14 +167,39 @@ describe("mfa verify — completes a two-step login", () => {
167
167
  GUEST,
168
168
  );
169
169
 
170
- // Same challenge token, same (still time-window-valid) code — must be
171
- // rejected because burnToken() marks it used on the first success.
170
+ // Same challenge token, same (still time-window-valid) code — rejected
171
+ // by the TOTP-counter burn inside verifyMfaFactor before the challenge-
172
+ // token burn is even reached (that burn now happens after success).
172
173
  const err = await stack.http.writeErr(
173
174
  AuthMfaHandlers.verify,
174
175
  { challengeToken, code: currentTotpCode(secret) },
175
176
  GUEST,
176
177
  );
177
- expectErrorIncludes(err, "invalid_challenge_token");
178
+ expectErrorIncludes(err, "invalid_totp_code");
179
+ });
180
+
181
+ test("a TOTP code cannot be replayed across a FRESH challenge token (RFC 6238 §5.2)", async () => {
182
+ const { user, secret } = await enableMfaFor(105);
183
+ const code = currentTotpCode(secret);
184
+
185
+ const challengeA = challengeFor(user.id, user.tenantId);
186
+ await stack.http.writeOk<{ session: SessionUser }>(
187
+ AuthMfaHandlers.verify,
188
+ { challengeToken: challengeA, code },
189
+ GUEST,
190
+ );
191
+
192
+ // A DIFFERENT challenge token (as a real re-login would mint) but the
193
+ // SAME still-time-window-valid code — this is the actual attack this
194
+ // finding is about: a phished/shoulder-surfed code used for a parallel
195
+ // login, not a naive replay of the whole prior request.
196
+ const challengeB = challengeFor(user.id, user.tenantId);
197
+ const err = await stack.http.writeErr(
198
+ AuthMfaHandlers.verify,
199
+ { challengeToken: challengeB, code },
200
+ GUEST,
201
+ );
202
+ expectErrorIncludes(err, "invalid_totp_code");
178
203
  });
179
204
 
180
205
  test("a malformed challenge token is rejected without leaking account state", async () => {
@@ -31,6 +31,10 @@ export const AuthMfaErrorCodes = {
31
31
  invalidRecoveryCode: "invalid_recovery_code",
32
32
  invalidChallengeToken: "invalid_challenge_token",
33
33
  tooManyAttempts: "too_many_attempts",
34
+ // Client-only: minted by mfa-enable-screen.tsx on a network/QR-render
35
+ // failure during enrollment, never by errors.ts server-side — kept here
36
+ // anyway so mfa-error-keys.ts's key-parity test covers it automatically.
37
+ setupFailed: "setup_failed",
34
38
  } as const;
35
39
 
36
40
  export const MFA_SETUP_TOKEN_TTL_MINUTES = 10;
@@ -30,7 +30,8 @@ export function createDisableHandler(opts: DisableOptions) {
30
30
  const row = await findUserMfaRow(ctx.db, event.user);
31
31
  if (!row) return mfaNotEnabled();
32
32
 
33
- const verify = await verifyMfaFactor(row, event.payload.code);
33
+ const replay = ctx.redis ? { redis: ctx.redis, userId: event.user.id } : undefined;
34
+ const verify = await verifyMfaFactor(row, event.payload.code, replay);
34
35
  if (!verify.ok) return invalidTotpCode();
35
36
 
36
37
  const result = await executor.delete({ id: row.id }, event.user, ctx.db);
@@ -7,7 +7,7 @@ import { base32Decode } from "../base32";
7
7
  import { findUserMfaRow } from "../db/queries";
8
8
  import { invalidSetupToken, invalidTotpCode, mfaAlreadyEnabled } from "../errors";
9
9
  import { verifyMfaSetupToken } from "../mfa-setup-token";
10
- import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
10
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
11
11
  import { verifyTotp } from "../totp";
12
12
 
13
13
  export type EnableConfirmOptions = {
@@ -42,12 +42,18 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
42
42
  if (verify.payload.userId !== event.user.id) return invalidSetupToken();
43
43
 
44
44
  const secret = base32Decode(verify.payload.totpSecretBase32);
45
- if (!verifyTotp(secret, event.payload.code)) return invalidTotpCode();
45
+ if (verifyTotp(secret, event.payload.code) === false) return invalidTotpCode();
46
46
 
47
47
  // Burn the setup token on the first successful confirm — without this
48
48
  // a `disable` doesn't retire it (re-enabling with the secret the user
49
49
  // thinks they discarded), and two parallel confirms both proceed to
50
50
  // executor.create instead of the second seeing mfa_already_enabled().
51
+ // ponytail: burned here, not unburned on a later executor.create
52
+ // failure (unlike login.write.ts's unburnToken pattern) — self-service
53
+ // enable-start always mints a fresh setup token on demand (no mailed
54
+ // link to go stale), so the realistic failure path (double-confirm
55
+ // race sans Redis) is meant to leave the burn standing. Add
56
+ // unburnToken here if create-failure retries turn out to matter.
51
57
  if (ctx.redis) {
52
58
  const burnResult = await burnToken(
53
59
  ctx.redis,
@@ -67,7 +73,7 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
67
73
  totpSecret: verify.payload.totpSecretBase32,
68
74
  // Stored as a JSON string — see schema/user-mfa.ts (recoveryCodes
69
75
  // is an encrypted+userOwned text field, both layers need a string).
70
- recoveryCodes: JSON.stringify({ hashes: verify.payload.recoveryCodeHashes }),
76
+ recoveryCodes: encodeRecoveryCodes(verify.payload.recoveryCodeHashes),
71
77
  enabledAt: Temporal.Now.instant(),
72
78
  lastUsedAt: null,
73
79
  },
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { findUserMfaRow } from "../db/queries";
5
5
  import { invalidTotpCode, mfaNotEnabled } from "../errors";
6
6
  import { generateRecoveryCodes, hashRecoveryCodes } from "../recovery-codes";
7
- import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
7
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
8
8
  import { verifyMfaFactor } from "../verify-factor";
9
9
 
10
10
  export type RegenerateRecoveryOptions = {
@@ -34,7 +34,8 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
34
34
  // (deliberately) one of the very codes about to be replaced both
35
35
  // count, since either way the presented code is consumed/irrelevant
36
36
  // afterward.
37
- const verify = await verifyMfaFactor(row, event.payload.code);
37
+ const replay = ctx.redis ? { redis: ctx.redis, userId: event.user.id } : undefined;
38
+ const verify = await verifyMfaFactor(row, event.payload.code, replay);
38
39
  if (!verify.ok) return invalidTotpCode();
39
40
 
40
41
  const newCodes = generateRecoveryCodes();
@@ -44,7 +45,7 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
44
45
  {
45
46
  id: row.id,
46
47
  version: row.version,
47
- changes: { recoveryCodes: JSON.stringify({ hashes: newHashes }) },
48
+ changes: { recoveryCodes: encodeRecoveryCodes(newHashes) },
48
49
  },
49
50
  event.user,
50
51
  ctx.db,
@@ -18,7 +18,7 @@ import {
18
18
  getMfaVerifyLockoutState,
19
19
  recordFailedMfaVerifyAttempt,
20
20
  } from "../mfa-verify-attempts";
21
- import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
21
+ import { encodeRecoveryCodes, userMfaEntity, userMfaTable } from "../schema/user-mfa";
22
22
  import { verifyMfaFactor } from "../verify-factor";
23
23
 
24
24
  export type MfaVerifyOptions = {
@@ -78,7 +78,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
78
78
  // bad code, no detail leak about account state.
79
79
  if (!row) return invalidChallengeToken();
80
80
 
81
- const verify = await verifyMfaFactor(row, event.payload.code);
81
+ const replay = ctx.redis ? { redis: ctx.redis, userId } : undefined;
82
+ const verify = await verifyMfaFactor(row, event.payload.code, replay);
82
83
  if (!verify.ok) {
83
84
  if (ctx.redis) {
84
85
  await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
@@ -86,6 +87,24 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
86
87
  return invalidTotpCode();
87
88
  }
88
89
 
90
+ // Burn the challenge token only after a successful code check — a
91
+ // still-failing attempt (wrong code) must not consume it, so a
92
+ // fat-fingered retry on the same token still works. The TOTP-counter
93
+ // burn inside verifyMfaFactor already rejects an exact-duplicate
94
+ // replay (same token + same code) with invalid_totp_code before this
95
+ // is reached, so this burn only needs to guard a DIFFERENT code
96
+ // (recovery, or a still-valid-but-not-yet-burned TOTP step) reused
97
+ // against the same challenge token.
98
+ if (ctx.redis) {
99
+ const burnResult = await burnToken(
100
+ ctx.redis,
101
+ "mfa-challenge",
102
+ userId,
103
+ verified.expiresAtMs,
104
+ );
105
+ if (burnResult === "already-used") return invalidChallengeToken();
106
+ }
107
+
89
108
  // Recovery-code use consumes the code — persist the reduced hash-list
90
109
  // immediately so it can't be replayed. Without this a recovery code
91
110
  // would work forever instead of being single-use.
@@ -94,7 +113,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
94
113
  {
95
114
  id: row.id,
96
115
  version: row.version,
97
- changes: { recoveryCodes: JSON.stringify({ hashes: verify.remainingHashes }) },
116
+ changes: { recoveryCodes: encodeRecoveryCodes(verify.remainingHashes) },
98
117
  },
99
118
  scopedUser,
100
119
  scopedDb,
@@ -103,15 +122,6 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
103
122
  }
104
123
 
105
124
  if (ctx.redis) {
106
- // Burn AFTER a successful verify — a still-failing attempt must not
107
- // consume the token, or a fat-fingered code would strand the user.
108
- const burnResult = await burnToken(
109
- ctx.redis,
110
- "mfa-challenge",
111
- userId,
112
- verified.expiresAtMs,
113
- );
114
- if (burnResult === "already-used") return invalidChallengeToken();
115
125
  await clearMfaVerifyAttempts(ctx.redis, userId);
116
126
  }
117
127
 
@@ -9,8 +9,8 @@ const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
9
9
 
10
10
  // Recovery codes get read aloud or copy-typed from a screen — a user retyping
11
11
  // "abcd1234" lowercase or without the dash must still match. Applied on both
12
- // sides (hash-time here is a no-op on freshly generated codes, but keeps
13
- // hash and verify deckungsgleich if generation ever changes) so a formatting
12
+ // sides (hash-time here strips the group separator before hashing, keeping
13
+ // hash and verify identical if generation ever changes) so a formatting
14
14
  // slip never surfaces as "wrong code" for an otherwise-correct code.
15
15
  function normalizeRecoveryCode(code: string): string {
16
16
  return code.toUpperCase().replace(/[^A-Z2-9]/g, "");
@@ -48,3 +48,13 @@ export const userMfaEntity = createEntity({
48
48
  });
49
49
 
50
50
  export const userMfaTable = buildEntityTable("user-mfa", userMfaEntity);
51
+
52
+ // Both encryption layers on `recoveryCodes` (entity-field envelope +
53
+ // per-subject crypto-shred) require a string value — every writer
54
+ // stringifies the same `{ hashes }` shape, so it lives here once instead
55
+ // of at each of the three write handlers. Matching read-side helper is
56
+ // `parseJsonOrThrow` in db/queries.ts (parses the full row, not just this
57
+ // field, so it isn't a symmetric pair of this one function).
58
+ export function encodeRecoveryCodes(hashes: readonly string[]): string {
59
+ return JSON.stringify({ hashes });
60
+ }
@@ -3,7 +3,7 @@
3
3
  // package for it would be the opposite of lazy.
4
4
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
5
5
 
6
- const STEP_SECONDS = 30;
6
+ export const STEP_SECONDS = 30;
7
7
  const DIGITS = 6;
8
8
  const WINDOW = 1; // accept ±1 step (±30s) of clock drift, per the spec's guidance
9
9
 
@@ -42,14 +42,26 @@ function totpAt(secret: Buffer, epochSeconds: number): string {
42
42
  // Timing-safe across the whole ±WINDOW check, not just the final compare —
43
43
  // a TOTP code is the same sensitivity class as a password, brute-forceable
44
44
  // over a public /auth/mfa/verify endpoint without it.
45
- export function verifyTotp(secret: Buffer, code: string, nowMs: number = Date.now()): boolean {
45
+ //
46
+ // Returns the matched HOTP counter (not just true) so callers can burn it —
47
+ // a code accepted once must not verify again within its ±WINDOW validity,
48
+ // per RFC 6238 §5.2. Returns `false` on no match, matching the old boolean
49
+ // contract for callers (like enable-confirm) that don't need replay-burn.
50
+ export function verifyTotp(
51
+ secret: Buffer,
52
+ code: string,
53
+ nowMs: number = Date.now(),
54
+ ): number | false {
46
55
  if (!/^\d{6}$/.test(code)) return false;
47
56
  const codeBuf = Buffer.from(code);
48
57
  const nowSeconds = Math.floor(nowMs / 1000);
49
- let matched = false;
58
+ let matchedCounter: number | false = false;
50
59
  for (let w = -WINDOW; w <= WINDOW; w++) {
51
- const candidate = Buffer.from(totpAt(secret, nowSeconds + w * STEP_SECONDS));
52
- if (timingSafeEqual(candidate, codeBuf)) matched = true;
60
+ const stepSeconds = nowSeconds + w * STEP_SECONDS;
61
+ const candidate = Buffer.from(totpAt(secret, stepSeconds));
62
+ if (timingSafeEqual(candidate, codeBuf)) {
63
+ matchedCounter = Math.floor(stepSeconds / STEP_SECONDS);
64
+ }
53
65
  }
54
- return matched;
66
+ return matchedCounter;
55
67
  }
@@ -1,7 +1,9 @@
1
+ import type Redis from "ioredis";
2
+ import { burnToken } from "../shared";
1
3
  import { base32Decode } from "./base32";
2
4
  import type { UserMfaRow } from "./db/queries";
3
5
  import { findMatchingRecoveryCodeIndex } from "./recovery-codes";
4
- import { verifyTotp } from "./totp";
6
+ import { STEP_SECONDS, verifyTotp } from "./totp";
5
7
 
6
8
  export type MfaFactorVerifyResult =
7
9
  | { readonly ok: true; readonly method: "totp" }
@@ -14,12 +16,38 @@ export type MfaFactorVerifyResult =
14
16
  // that get `method: "recovery"` must persist `remainingHashes` as the new
15
17
  // recoveryCodes.hashes — the matched code is single-use and already
16
18
  // excluded from the returned array.
19
+ //
20
+ // `replay` opts into TOTP-counter burn (RFC 6238 §5.2: an accepted code
21
+ // must not verify again within its ±1-step window — otherwise a code an
22
+ // attacker observed once, via phishing proxy or shoulder-surfing, stays
23
+ // usable for ~90s of parallel logins). Omitted where a caller has no
24
+ // ctx.redis available; the code-verify itself still works, just without
25
+ // replay protection for that call.
17
26
  export async function verifyMfaFactor(
18
27
  row: UserMfaRow,
19
28
  code: string,
29
+ replay?: { readonly redis: Redis; readonly userId: string },
20
30
  ): Promise<MfaFactorVerifyResult> {
21
31
  const secret = base32Decode(row.totpSecret);
22
- if (verifyTotp(secret, code)) return { ok: true, method: "totp" };
32
+ const counter = verifyTotp(secret, code);
33
+ if (counter !== false) {
34
+ if (replay) {
35
+ // Burn keyed by (userId, row.id, counter) — row.id, not just userId,
36
+ // because disable+re-enable creates a fresh aggregate with a new
37
+ // secret: two different secrets can hash to the same counter/step,
38
+ // and without row.id in the key a burn from the OLD secret would
39
+ // spuriously reject a fresh code under the NEW one. The counter's
40
+ // own step-expiry doubles as the burn marker's TTL, so it
41
+ // self-evicts once the code itself would have expired anyway — same
42
+ // mechanism as burnToken's HMAC-token callers, just with a synthetic
43
+ // expiresAtMs.
44
+ const expiresAtMs = (counter + 1) * STEP_SECONDS * 1000;
45
+ const burnUserId = `${replay.userId}:${row.id}`;
46
+ const burned = await burnToken(replay.redis, "mfa-totp", burnUserId, expiresAtMs);
47
+ if (burned === "already-used") return { ok: false };
48
+ }
49
+ return { ok: true, method: "totp" };
50
+ }
23
51
 
24
52
  const hashes = row.recoveryCodes.hashes;
25
53
  const matchIndex = await findMatchingRecoveryCodeIndex(code, hashes);
@@ -21,6 +21,7 @@ export const defaultTranslations: TranslationsByLocale = {
21
21
  "auth.mfa.errors.mfaAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiv.",
22
22
  "auth.mfa.errors.mfaNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiv.",
23
23
  "auth.mfa.errors.invalidSetupToken": "Die Einrichtung ist abgelaufen. Bitte erneut starten.",
24
+ "auth.mfa.errors.setupFailed": "Einrichtung fehlgeschlagen. Bitte erneut versuchen.",
24
25
  "auth.mfa.errors.invalidRecoveryCode": "Ungültiger Recovery-Code.",
25
26
  "auth.mfa.enable.title": "Zwei-Faktor-Authentifizierung",
26
27
  "auth.mfa.enable.intro":
@@ -71,6 +72,7 @@ export const defaultTranslations: TranslationsByLocale = {
71
72
  "auth.mfa.errors.mfaAlreadyEnabled": "Two-factor authentication is already enabled.",
72
73
  "auth.mfa.errors.mfaNotEnabled": "Two-factor authentication is not enabled.",
73
74
  "auth.mfa.errors.invalidSetupToken": "Setup expired. Please start again.",
75
+ "auth.mfa.errors.setupFailed": "Setup failed. Please try again.",
74
76
  "auth.mfa.errors.invalidRecoveryCode": "Invalid recovery code.",
75
77
  "auth.mfa.enable.title": "Two-factor authentication",
76
78
  "auth.mfa.enable.intro":
@@ -43,30 +43,6 @@ function extractSecret(otpauthUri: string): string {
43
43
  return new URLSearchParams(query).get("secret") ?? "";
44
44
  }
45
45
 
46
- // 24x24 viewBox icon paths (Material-style), solid fill. Real SVG paths
47
- // instead of emoji glyphs — emoji-as-<text> renders inconsistently across
48
- // font stacks and never centers cleanly (font-specific glyph padding).
49
- const QR_ICONS = [
50
- `<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" fill="#ef4444"/>`,
51
- `<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" fill="#facc15"/>`,
52
- `<circle cx="12" cy="12" r="11" fill="#facc15"/><circle cx="8.5" cy="10" r="1.4" fill="#1f2937"/><circle cx="15.5" cy="10" r="1.4" fill="#1f2937"/><path d="M7 14c1 2.2 3 3.4 5 3.4s4-1.2 5-3.4" stroke="#1f2937" stroke-width="1.6" fill="none" stroke-linecap="round"/>`,
53
- ];
54
-
55
- // Overlay a random icon in a circle badge over the QR center. cx/cy/r
56
- // percentages resolve against the SVG viewport regardless of viewBox size,
57
- // so this string-injection works without parsing qrcode's output dimensions.
58
- function withIconBadge(svg: string): string {
59
- const icon = QR_ICONS[Math.floor(Math.random() * QR_ICONS.length)];
60
- const size = Number(svg.match(/viewBox="0 0 (\d+(?:\.\d+)?) /)?.[1] ?? 100);
61
- const r = size * 0.16;
62
- const iconSize = r * 1.3;
63
- const scale = iconSize / 24;
64
- const offset = size / 2 - iconSize / 2;
65
- // html-ok: icon is one of the fixed QR_ICONS literals above, not user input
66
- const badge = `<circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="#fff" stroke="#e5e7eb" stroke-width="${size * 0.01}"/><g transform="translate(${offset} ${offset}) scale(${scale})">${icon}</g>`;
67
- return svg.replace("</svg>", `${badge}</svg>`);
68
- }
69
-
70
46
  export type MfaEnableScreenProps = {
71
47
  readonly embedded?: boolean;
72
48
  // Fired once enable-confirm succeeds — MfaEnableScreen has no query of its
@@ -104,11 +80,13 @@ export function MfaEnableScreen({
104
80
  setError(res.error.code);
105
81
  return;
106
82
  }
107
- // errorCorrectionLevel "H" (~30% redundancy) so the icon badge overlay
108
- // doesn't break scanning.
109
- const qrSvg = withIconBadge(
110
- await QRCode.toString(res.data.otpauthUri, { type: "svg", errorCorrectionLevel: "H" }),
111
- );
83
+ // errorCorrectionLevel "H" (~30% redundancy) more resilient to
84
+ // camera/lighting issues than the default, no downside for a code
85
+ // this short-lived.
86
+ const qrSvg = await QRCode.toString(res.data.otpauthUri, {
87
+ type: "svg",
88
+ errorCorrectionLevel: "H",
89
+ });
112
90
  setSetup({
113
91
  setupToken: res.data.setupToken,
114
92
  secretParam: extractSecret(res.data.otpauthUri),
@@ -21,6 +21,8 @@ export function mfaManageErrorKey(code: string): string {
21
21
  return "auth.mfa.errors.mfaNotEnabled";
22
22
  case AuthMfaErrorCodes.invalidSetupToken:
23
23
  return "auth.mfa.errors.invalidSetupToken";
24
+ case AuthMfaErrorCodes.setupFailed:
25
+ return "auth.mfa.errors.setupFailed";
24
26
  default:
25
27
  return "auth.mfa.errors.verifyFailed";
26
28
  }