@cosmicdrift/kumiko-bundled-features 0.149.0 → 0.149.1
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 +6 -6
- package/src/auth-mfa/__tests__/pii-subject-encryption.integration.test.ts +137 -0
- package/src/auth-mfa/db/queries.ts +15 -4
- package/src/auth-mfa/handlers/enable-confirm.write.ts +3 -1
- package/src/auth-mfa/handlers/reencrypt.job.ts +54 -14
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +1 -1
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/schema/user-mfa.ts +10 -3
- package/src/auth-mfa/web/mfa-enable-screen.tsx +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.149.
|
|
3
|
+
"version": "0.149.1",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
115
115
|
},
|
|
116
116
|
"dependencies": {
|
|
117
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.149.
|
|
118
|
-
"@cosmicdrift/kumiko-framework": "0.149.
|
|
119
|
-
"@cosmicdrift/kumiko-headless": "0.149.
|
|
120
|
-
"@cosmicdrift/kumiko-renderer": "0.149.
|
|
121
|
-
"@cosmicdrift/kumiko-renderer-web": "0.149.
|
|
117
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.149.1",
|
|
118
|
+
"@cosmicdrift/kumiko-framework": "0.149.1",
|
|
119
|
+
"@cosmicdrift/kumiko-headless": "0.149.1",
|
|
120
|
+
"@cosmicdrift/kumiko-renderer": "0.149.1",
|
|
121
|
+
"@cosmicdrift/kumiko-renderer-web": "0.149.1",
|
|
122
122
|
"@mollie/api-client": "^4.5.0",
|
|
123
123
|
"imapflow": "^1.3.3",
|
|
124
124
|
"mailparser": "^3.9.8",
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Regression test for a real crash found via manual browser testing in a
|
|
2
|
+
// consumer app (kumiko-studio) that mounts the full GDPR/user-data-rights
|
|
3
|
+
// stack: totpSecret and recoveryCodes are both `encrypted: true` +
|
|
4
|
+
// `userOwned` (schema/user-mfa.ts) — the SECOND layer (per-subject
|
|
5
|
+
// crypto-shredding, entity-field-encryption.ts's sibling pipeline) only
|
|
6
|
+
// activates when `configurePiiSubjectKms` has been called, which none of
|
|
7
|
+
// the other auth-mfa integration tests do. That gap let a real bug ship:
|
|
8
|
+
// recoveryCodes used to be a `createJsonbField` (an object, not a string),
|
|
9
|
+
// and `encryptPiiFieldValues` throws "PII field must be a string" for any
|
|
10
|
+
// non-string userOwned field. enable-confirm failed for EVERY real deployment
|
|
11
|
+
// with crypto-shredding configured, while every test here stayed green.
|
|
12
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import {
|
|
14
|
+
configurePiiSubjectKms,
|
|
15
|
+
InMemoryKmsAdapter,
|
|
16
|
+
resetPiiSubjectKmsForTests,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
18
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import {
|
|
20
|
+
createTestUser,
|
|
21
|
+
setupTestStack,
|
|
22
|
+
type TestStack,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
|
+
import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
|
+
import { createConfigFeature } from "../../config";
|
|
28
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
29
|
+
import { configValuesTable } from "../../config/table";
|
|
30
|
+
import { createUserFeature } from "../../user/feature";
|
|
31
|
+
import { userEntity } from "../../user/schema/user";
|
|
32
|
+
import { base32Decode } from "../base32";
|
|
33
|
+
import { AuthMfaHandlers } from "../constants";
|
|
34
|
+
import { createAuthMfaFeature } from "../feature";
|
|
35
|
+
import { userMfaEntity } from "../schema/user-mfa";
|
|
36
|
+
import { currentTotpCode } from "../totp";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
const SETUP_TOKEN_SECRET = "test-setup-token-secret-do-not-use-in-prod";
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
const encryption = createTestEnvelopeCipher();
|
|
44
|
+
configureEntityFieldEncryption(encryption);
|
|
45
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
46
|
+
stack = await setupTestStack({
|
|
47
|
+
features: [
|
|
48
|
+
createConfigFeature(),
|
|
49
|
+
createUserFeature(),
|
|
50
|
+
createAuthMfaFeature({
|
|
51
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
52
|
+
issuer: "Kumiko Test",
|
|
53
|
+
challengeTokenSecret: "test-mfa-challenge-secret-at-least-32-bytes!!",
|
|
54
|
+
}),
|
|
55
|
+
],
|
|
56
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
57
|
+
});
|
|
58
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
59
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
60
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterAll(async () => {
|
|
64
|
+
await stack.cleanup();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The second, independent encryption layer — same activation recipe as
|
|
68
|
+
// crypto-shredding/__tests__/forget-subject.integration.test.ts.
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => {
|
|
74
|
+
resetPiiSubjectKmsForTests();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("with per-subject PII crypto-shredding active", () => {
|
|
78
|
+
test("enable-confirm succeeds and persists both totpSecret and recoveryCodes", async () => {
|
|
79
|
+
const user = createTestUser({ id: "pii-enable-1", roles: ["User"] });
|
|
80
|
+
|
|
81
|
+
const start = await stack.http.writeOk<{
|
|
82
|
+
setupToken: string;
|
|
83
|
+
otpauthUri: string;
|
|
84
|
+
recoveryCodes: string[];
|
|
85
|
+
}>(AuthMfaHandlers.enableStart, { accountLabel: "pii-enable@example.com" }, user);
|
|
86
|
+
|
|
87
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
88
|
+
const secret = base32Decode(secretParam);
|
|
89
|
+
|
|
90
|
+
const confirmed = await stack.http.writeOk<{ enabled: boolean }>(
|
|
91
|
+
AuthMfaHandlers.enableConfirm,
|
|
92
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
93
|
+
user,
|
|
94
|
+
);
|
|
95
|
+
expect(confirmed.enabled).toBe(true);
|
|
96
|
+
|
|
97
|
+
// recoveryCodes round-trips through both encryption layers correctly —
|
|
98
|
+
// a recovery code (not just the TOTP code) still authenticates.
|
|
99
|
+
const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
|
|
100
|
+
AuthMfaHandlers.disable,
|
|
101
|
+
{ code: start.recoveryCodes[0] },
|
|
102
|
+
user,
|
|
103
|
+
);
|
|
104
|
+
expect(disableRes.disabled).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("regenerate-recovery re-encrypts the new codes without crashing", async () => {
|
|
108
|
+
const user = createTestUser({ id: "pii-regen-1", roles: ["User"] });
|
|
109
|
+
|
|
110
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
111
|
+
AuthMfaHandlers.enableStart,
|
|
112
|
+
{ accountLabel: "pii-regen@example.com" },
|
|
113
|
+
user,
|
|
114
|
+
);
|
|
115
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
116
|
+
const secret = base32Decode(secretParam);
|
|
117
|
+
await stack.http.writeOk(
|
|
118
|
+
AuthMfaHandlers.enableConfirm,
|
|
119
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
120
|
+
user,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const regenerated = await stack.http.writeOk<{ recoveryCodes: string[] }>(
|
|
124
|
+
AuthMfaHandlers.regenerateRecovery,
|
|
125
|
+
{ code: currentTotpCode(secret) },
|
|
126
|
+
user,
|
|
127
|
+
);
|
|
128
|
+
expect(regenerated.recoveryCodes).toHaveLength(8);
|
|
129
|
+
|
|
130
|
+
const disableRes = await stack.http.writeOk<{ disabled: boolean }>(
|
|
131
|
+
AuthMfaHandlers.disable,
|
|
132
|
+
{ code: regenerated.recoveryCodes[0] },
|
|
133
|
+
user,
|
|
134
|
+
);
|
|
135
|
+
expect(disableRes.disabled).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
2
|
import { createEventStoreExecutor, type TenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
3
3
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
4
5
|
import { userMfaEntity, userMfaTable } from "../schema/user-mfa";
|
|
5
6
|
|
|
6
7
|
export type UserMfaRow = {
|
|
@@ -17,10 +18,14 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
17
18
|
entityName: "user-mfa",
|
|
18
19
|
});
|
|
19
20
|
|
|
20
|
-
// `totpSecret`
|
|
21
|
-
// not
|
|
21
|
+
// `totpSecret`/`recoveryCodes` are `encrypted: true` — a raw fetchOne would
|
|
22
|
+
// return ciphertext, not plaintext. fetchOne only locates the row's id (no
|
|
22
23
|
// decryption needed for that); executor.detail() does the real, decrypting
|
|
23
|
-
// read the same way every other entity handler does.
|
|
24
|
+
// read the same way every other entity handler does. `recoveryCodes` is
|
|
25
|
+
// stored as a JSON string (schema/user-mfa.ts — both encryption layers need
|
|
26
|
+
// a string value) — parsed back into `{ hashes }` here, once, for every
|
|
27
|
+
// caller instead of each handler re-parsing it. A parse failure here means
|
|
28
|
+
// stored data is corrupt (post-decrypt, not user input) — fail loud.
|
|
24
29
|
export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<UserMfaRow | null> {
|
|
25
30
|
const idLookup = await fetchOne<{ id: string }>(db, userMfaTable, {
|
|
26
31
|
userId: user.id,
|
|
@@ -28,7 +33,13 @@ export async function findUserMfaRow(db: TenantDb, user: SessionUser): Promise<U
|
|
|
28
33
|
});
|
|
29
34
|
if (!idLookup) return null;
|
|
30
35
|
const detail = await executor.detail({ id: idLookup.id }, user, db);
|
|
36
|
+
if (!detail) return null;
|
|
31
37
|
// @cast-boundary engine-payload — executor.detail returns a decrypted,
|
|
32
38
|
// untyped Record; the userMfa entity's own field shape narrows it.
|
|
33
|
-
|
|
39
|
+
const row = detail as Omit<UserMfaRow, "recoveryCodes"> & { recoveryCodes: string };
|
|
40
|
+
const recoveryCodes = parseJsonOrThrow<{ hashes: readonly string[] }>(
|
|
41
|
+
row.recoveryCodes,
|
|
42
|
+
"user-mfa.recoveryCodes",
|
|
43
|
+
);
|
|
44
|
+
return { ...row, recoveryCodes };
|
|
34
45
|
}
|
|
@@ -46,7 +46,9 @@ export function createEnableConfirmHandler(opts: EnableConfirmOptions) {
|
|
|
46
46
|
{
|
|
47
47
|
userId: event.user.id,
|
|
48
48
|
totpSecret: verify.payload.totpSecretBase32,
|
|
49
|
-
|
|
49
|
+
// Stored as a JSON string — see schema/user-mfa.ts (recoveryCodes
|
|
50
|
+
// is an encrypted+userOwned text field, both layers need a string).
|
|
51
|
+
recoveryCodes: JSON.stringify({ hashes: verify.payload.recoveryCodeHashes }),
|
|
50
52
|
enabledAt: Temporal.Now.instant(),
|
|
51
53
|
lastUsedAt: null,
|
|
52
54
|
},
|
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// KEK-rotation job for userMfa.totpSecret (entity-field
|
|
2
|
-
// MasterKeyProvider as secrets/config — see
|
|
3
|
-
// directly on config/handlers/reencrypt.job.ts:
|
|
4
|
-
// stores a StoredEnvelope JSON string per value (no
|
|
5
|
-
// column, unlike secrets' DEK-wrap model), so rotation
|
|
6
|
-
// the OLD key, then let the executor's own write-path
|
|
7
|
-
// provider.currentVersion() — the same auto-encrypt every
|
|
8
|
-
// write already goes through (see enable-confirm.write.ts: it
|
|
9
|
-
// PLAINTEXT to executor.create, never a manually-built envelope).
|
|
1
|
+
// KEK-rotation job for userMfa.totpSecret + recoveryCodes (entity-field
|
|
2
|
+
// encryption, same MasterKeyProvider as secrets/config — see
|
|
3
|
+
// schema/user-mfa.ts). Modeled directly on config/handlers/reencrypt.job.ts:
|
|
4
|
+
// entity-field encryption stores a StoredEnvelope JSON string per value (no
|
|
5
|
+
// separate kekVersion column, unlike secrets' DEK-wrap model), so rotation
|
|
6
|
+
// means decrypt under the OLD key, then let the executor's own write-path
|
|
7
|
+
// re-encrypt under provider.currentVersion() — the same auto-encrypt every
|
|
8
|
+
// enable-confirm write already goes through (see enable-confirm.write.ts: it
|
|
9
|
+
// passes PLAINTEXT to executor.create, never a manually-built envelope).
|
|
10
|
+
//
|
|
11
|
+
// Both fields also carry `userOwned` — the executor's own encryptForStorage
|
|
12
|
+
// wraps them PII(envelope(plaintext)) (see event-store-executor-context.ts).
|
|
13
|
+
// This job reads raw rows via selectMany (bypassing the executor's
|
|
14
|
+
// decryptForRead), so it must peel that PII layer itself before the envelope
|
|
15
|
+
// cipher ever sees the value, mirroring decryptForRead's PII-then-envelope
|
|
16
|
+
// order — an envelope decrypt on a still-PII-wrapped string throws
|
|
17
|
+
// "legacy single-key format" instead of rotating anything.
|
|
10
18
|
//
|
|
11
19
|
// Idempotent: re-running skips rows already on the current kekVersion.
|
|
12
20
|
// Every write is a normal executor.update — after a full run even a
|
|
@@ -16,6 +24,11 @@
|
|
|
16
24
|
// projection instead of appending a real `.updated` event).
|
|
17
25
|
|
|
18
26
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
27
|
+
import {
|
|
28
|
+
collectPiiSubjectFields,
|
|
29
|
+
configuredPiiSubjectKms,
|
|
30
|
+
decryptPiiFieldValues,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
19
32
|
import {
|
|
20
33
|
configuredEntityFieldEncryption,
|
|
21
34
|
createEventStoreExecutor,
|
|
@@ -37,6 +50,8 @@ const executor = createEventStoreExecutor(userMfaTable, userMfaEntity, {
|
|
|
37
50
|
entityName: "user-mfa",
|
|
38
51
|
});
|
|
39
52
|
|
|
53
|
+
const piiFieldNames = collectPiiSubjectFields(userMfaEntity);
|
|
54
|
+
|
|
40
55
|
export type MfaReencryptJobPayload = {
|
|
41
56
|
readonly batchSize?: number;
|
|
42
57
|
readonly maxDurationMs?: number;
|
|
@@ -91,6 +106,9 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
91
106
|
});
|
|
92
107
|
}
|
|
93
108
|
const db = ctx.db as DbConnection; // @cast-boundary db-operator
|
|
109
|
+
// Undefined = per-subject crypto-shredding isn't configured for this app —
|
|
110
|
+
// both fields are then plain envelope strings, no PII layer to peel.
|
|
111
|
+
const piiKms = configuredPiiSubjectKms();
|
|
94
112
|
|
|
95
113
|
const batchSize = payload.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
96
114
|
const maxFailures = payload.maxFailures ?? DEFAULT_MAX_FAILURES;
|
|
@@ -113,6 +131,7 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
113
131
|
tenantId: string;
|
|
114
132
|
version: number;
|
|
115
133
|
totpSecret: string;
|
|
134
|
+
recoveryCodes: string;
|
|
116
135
|
};
|
|
117
136
|
|
|
118
137
|
let alreadyCurrent = 0;
|
|
@@ -131,22 +150,43 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
134
|
-
|
|
153
|
+
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
154
|
+
|
|
155
|
+
// Peel the PII layer first (if active) so needsReencrypt/cipher.decrypt
|
|
156
|
+
// below only ever see envelope strings, same order as decryptForRead.
|
|
157
|
+
let envelopeValues: { totpSecret: string; recoveryCodes: string } = row;
|
|
158
|
+
if (piiKms) {
|
|
159
|
+
const unwrapped = await decryptPiiFieldValues(row, piiFieldNames, piiKms, {
|
|
160
|
+
requestId: "auth-mfa-reencrypt-job",
|
|
161
|
+
tenantId,
|
|
162
|
+
});
|
|
163
|
+
envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const totpNeedsRotation = needsReencrypt(envelopeValues.totpSecret, targetVersion);
|
|
167
|
+
const recoveryNeedsRotation = needsReencrypt(envelopeValues.recoveryCodes, targetVersion);
|
|
168
|
+
if (!totpNeedsRotation && !recoveryNeedsRotation) {
|
|
135
169
|
alreadyCurrent++;
|
|
136
170
|
return "skipped";
|
|
137
171
|
}
|
|
138
172
|
|
|
139
|
-
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
140
173
|
// decrypt failure (missing legacy key, unknown kekVersion, tamper)
|
|
141
174
|
// throws → counted as failed via onRowError; the row stays untouched.
|
|
142
|
-
const
|
|
175
|
+
const changes: Record<string, string> = {};
|
|
176
|
+
if (totpNeedsRotation) {
|
|
177
|
+
changes["totpSecret"] = await cipher.decrypt(envelopeValues.totpSecret, { tenantId });
|
|
178
|
+
}
|
|
179
|
+
if (recoveryNeedsRotation) {
|
|
180
|
+
changes["recoveryCodes"] = await cipher.decrypt(envelopeValues.recoveryCodes, { tenantId });
|
|
181
|
+
}
|
|
143
182
|
|
|
144
183
|
const actor: SessionUser = { id: "system", tenantId, roles: SYSTEM_ROLES };
|
|
145
184
|
// PLAINTEXT here, not a manually-built envelope — the executor's own
|
|
146
185
|
// write-path (encryptForStorage) re-encrypts under the CURRENT
|
|
147
|
-
// injected cipher/KEK
|
|
186
|
+
// injected cipher/KEK (and re-wraps the PII layer under the same,
|
|
187
|
+
// unrotated subject DEK), exactly like every other write to this field.
|
|
148
188
|
const result = await executor.update(
|
|
149
|
-
{ id: row.id, version: row.version, changes
|
|
189
|
+
{ id: row.id, version: row.version, changes },
|
|
150
190
|
actor,
|
|
151
191
|
tdbFor(tenantId),
|
|
152
192
|
);
|
|
@@ -44,7 +44,7 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
|
|
|
44
44
|
{
|
|
45
45
|
id: row.id,
|
|
46
46
|
version: row.version,
|
|
47
|
-
changes: { recoveryCodes: { hashes: newHashes } },
|
|
47
|
+
changes: { recoveryCodes: JSON.stringify({ hashes: newHashes }) },
|
|
48
48
|
},
|
|
49
49
|
event.user,
|
|
50
50
|
ctx.db,
|
|
@@ -94,7 +94,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
94
94
|
{
|
|
95
95
|
id: row.id,
|
|
96
96
|
version: row.version,
|
|
97
|
-
changes: { recoveryCodes: { hashes: verify.remainingHashes } },
|
|
97
|
+
changes: { recoveryCodes: JSON.stringify({ hashes: verify.remainingHashes }) },
|
|
98
98
|
},
|
|
99
99
|
scopedUser,
|
|
100
100
|
scopedDb,
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import {
|
|
3
3
|
createEntity,
|
|
4
|
-
createJsonbField,
|
|
5
4
|
createTextField,
|
|
6
5
|
createTimestampField,
|
|
7
6
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -27,10 +26,18 @@ export const userMfaEntity = createEntity({
|
|
|
27
26
|
encrypted: true,
|
|
28
27
|
userOwned: { ownerField: "userId" },
|
|
29
28
|
}),
|
|
30
|
-
// { hashes: string[] }
|
|
29
|
+
// JSON-stringified `{ hashes: string[] }` (argon2id hashes of unredeemed
|
|
30
|
+
// recovery codes) — both encryption layers (entity-field envelope +
|
|
31
|
+
// per-subject crypto-shred, same as totpSecret above) require a string
|
|
32
|
+
// value, so callers JSON.stringify on write and JSON.parse on read
|
|
33
|
+
// (see db/queries.ts's findUserMfaRow) rather than storing jsonb here.
|
|
31
34
|
// A redeemed code's hash is removed from the array (see disable/regen
|
|
32
35
|
// handlers), so array length also IS the remaining-codes count.
|
|
33
|
-
recoveryCodes:
|
|
36
|
+
recoveryCodes: createTextField({
|
|
37
|
+
required: true,
|
|
38
|
+
encrypted: true,
|
|
39
|
+
userOwned: { ownerField: "userId" },
|
|
40
|
+
}),
|
|
34
41
|
enabledAt: createTimestampField({ required: true }),
|
|
35
42
|
lastUsedAt: createTimestampField(),
|
|
36
43
|
},
|
|
@@ -36,9 +36,20 @@ function extractSecret(otpauthUri: string): string {
|
|
|
36
36
|
return new URLSearchParams(query).get("secret") ?? "";
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export type MfaEnableScreenProps = {
|
|
39
|
+
export type MfaEnableScreenProps = {
|
|
40
|
+
readonly embedded?: boolean;
|
|
41
|
+
// Fired once enable-confirm succeeds — MfaEnableScreen has no query of its
|
|
42
|
+
// own to invalidate, so a host screen composing it alongside other MFA
|
|
43
|
+
// state (e.g. a status query gating which section renders) needs this to
|
|
44
|
+
// know when to refetch/swap views instead of leaving the success banner
|
|
45
|
+
// as a dead end.
|
|
46
|
+
readonly onEnabled?: () => void;
|
|
47
|
+
};
|
|
40
48
|
|
|
41
|
-
export function MfaEnableScreen({
|
|
49
|
+
export function MfaEnableScreen({
|
|
50
|
+
embedded = false,
|
|
51
|
+
onEnabled,
|
|
52
|
+
}: MfaEnableScreenProps = {}): ReactNode {
|
|
42
53
|
const t = useTranslation();
|
|
43
54
|
const { Button, Banner, Field, Input, Section, Heading } = usePrimitives();
|
|
44
55
|
const dispatcher = useDispatcher();
|
|
@@ -89,6 +100,7 @@ export function MfaEnableScreen({ embedded = false }: MfaEnableScreenProps = {})
|
|
|
89
100
|
}
|
|
90
101
|
setEnabled(true);
|
|
91
102
|
setSetup(null);
|
|
103
|
+
onEnabled?.();
|
|
92
104
|
};
|
|
93
105
|
|
|
94
106
|
const content = (
|