@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.
- package/package.json +7 -6
- package/src/agent-tools/__tests__/tool-catalog.test.ts +179 -0
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +100 -0
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +126 -0
- package/src/agent-tools/index.ts +9 -0
- package/src/agent-tools/tool-catalog.ts +130 -0
- package/src/agent-tools/tool-dispatch.ts +82 -0
- package/src/agent-tools/types.ts +38 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-gate.tsx +14 -4
- package/src/auth-mfa/__tests__/enable-flow.integration.test.ts +89 -0
- package/src/auth-mfa/__tests__/totp.test.ts +2 -2
- package/src/auth-mfa/__tests__/verify.integration.test.ts +29 -4
- package/src/auth-mfa/constants.ts +4 -0
- package/src/auth-mfa/handlers/disable.write.ts +2 -1
- package/src/auth-mfa/handlers/enable-confirm.write.ts +9 -3
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +4 -3
- package/src/auth-mfa/handlers/verify.write.ts +22 -12
- package/src/auth-mfa/recovery-codes.ts +2 -2
- package/src/auth-mfa/schema/user-mfa.ts +10 -0
- package/src/auth-mfa/totp.ts +18 -6
- package/src/auth-mfa/verify-factor.ts +30 -2
- package/src/auth-mfa/web/i18n.ts +2 -0
- package/src/auth-mfa/web/mfa-enable-screen.tsx +7 -29
- package/src/auth-mfa/web/mfa-error-keys.ts +2 -0
- package/src/auth-mfa/web/qrcode-browser.d.ts +5 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +158 -0
- package/src/config/__tests__/write-helpers.test.ts +23 -0
- package/src/config/handlers/set.write.ts +47 -1
- package/src/config/resolver.ts +28 -0
- package/src/config/write-helpers.ts +22 -0
- package/src/custom-fields/__tests__/audit-integration.integration.test.ts +1 -1
- package/src/delivery/feature.ts +1 -1
- package/src/file-foundation/__tests__/feature.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +3 -1
- package/src/inbound-mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/inbound-mail-foundation/feature.ts +3 -3
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +7 -11
- package/src/jobs/feature.ts +1 -1
- package/src/legal-pages/feature.ts +1 -1
- package/src/mail-foundation/__tests__/feature.test.ts +1 -1
- package/src/page-render/branding.ts +4 -3
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +42 -0
- package/src/sessions/feature.ts +2 -2
- package/src/user-data-rights/handlers/download-by-job.query.ts +14 -7
- package/src/user-data-rights/handlers/download-by-token.query.ts +14 -7
- package/src/user-data-rights/lib/storage-provider-resolver.ts +6 -5
- 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
|
|
45
|
-
//
|
|
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")
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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("
|
|
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 —
|
|
171
|
-
//
|
|
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, "
|
|
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
|
|
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 (
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
13
|
-
// hash and verify
|
|
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
|
+
}
|
package/src/auth-mfa/totp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
58
|
+
let matchedCounter: number | false = false;
|
|
50
59
|
for (let w = -WINDOW; w <= WINDOW; w++) {
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -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)
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
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
|
}
|