@cosmicdrift/kumiko-bundled-features 0.156.1 → 0.156.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.156.
|
|
3
|
+
"version": "0.156.2",
|
|
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>",
|
|
@@ -116,11 +116,11 @@
|
|
|
116
116
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
117
117
|
},
|
|
118
118
|
"dependencies": {
|
|
119
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.156.
|
|
120
|
-
"@cosmicdrift/kumiko-framework": "0.156.
|
|
121
|
-
"@cosmicdrift/kumiko-headless": "0.156.
|
|
122
|
-
"@cosmicdrift/kumiko-renderer": "0.156.
|
|
123
|
-
"@cosmicdrift/kumiko-renderer-web": "0.156.
|
|
119
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.156.2",
|
|
120
|
+
"@cosmicdrift/kumiko-framework": "0.156.2",
|
|
121
|
+
"@cosmicdrift/kumiko-headless": "0.156.2",
|
|
122
|
+
"@cosmicdrift/kumiko-renderer": "0.156.2",
|
|
123
|
+
"@cosmicdrift/kumiko-renderer-web": "0.156.2",
|
|
124
124
|
"@mollie/api-client": "^4.5.0",
|
|
125
125
|
"imapflow": "^1.3.3",
|
|
126
126
|
"mailparser": "^3.9.8",
|
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
|
-
import { deleteMany,
|
|
8
|
+
import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
9
|
import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
10
10
|
import {
|
|
11
11
|
createEnvMasterKeyProvider,
|
|
12
|
-
|
|
12
|
+
decodeStoredEnvelope,
|
|
13
|
+
decryptValue,
|
|
13
14
|
type MasterKeyProvider,
|
|
14
15
|
} from "@cosmicdrift/kumiko-framework/secrets";
|
|
15
16
|
import {
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
import { createSecretsFeature } from "../feature";
|
|
22
23
|
import { rotateJob } from "../handlers/rotate.job";
|
|
23
24
|
import { createSecretsContext } from "../secrets-context";
|
|
24
|
-
import { tenantSecretsTable } from "../table";
|
|
25
|
+
import { type StoredEnvelope, tenantSecretsTable } from "../table";
|
|
25
26
|
|
|
26
27
|
const admin = createTestUser({
|
|
27
28
|
id: "00000000-0000-4000-8000-000000000010",
|
|
@@ -29,6 +30,15 @@ const admin = createTestUser({
|
|
|
29
30
|
roles: ["TenantAdmin"],
|
|
30
31
|
});
|
|
31
32
|
|
|
33
|
+
// Captured so the happy-path rotation test can build a provider whose V1
|
|
34
|
+
// key matches the seeded rows exactly — a fresh random V1 would fail the
|
|
35
|
+
// GCM auth tag on every unwrap.
|
|
36
|
+
const SEED_V1_KEY = randomBytes(32).toString("base64");
|
|
37
|
+
// Shared with the happy-path test's rotator AND its verifier — the verifier
|
|
38
|
+
// must decrypt with ONLY this key so a rewrap that bumps kek_version but
|
|
39
|
+
// leaves the envelope V1-wrapped fails loud instead of silently passing.
|
|
40
|
+
const ROTATED_V2_KEY = randomBytes(32).toString("base64");
|
|
41
|
+
|
|
32
42
|
// A provider that encrypts happily on wrapDek but always rejects
|
|
33
43
|
// unwrapDek. Simulates "KEK is unreachable / corrupt" — the failure mode
|
|
34
44
|
// the circuit-breaker exists to contain. The returned object carries a
|
|
@@ -64,7 +74,7 @@ beforeAll(async () => {
|
|
|
64
74
|
// Seeding the table uses a sane V1-only provider so writes land on V1.
|
|
65
75
|
const seedProvider = createEnvMasterKeyProvider({
|
|
66
76
|
env: {
|
|
67
|
-
KUMIKO_SECRETS_MASTER_KEY_V1:
|
|
77
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
|
|
68
78
|
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
69
79
|
},
|
|
70
80
|
});
|
|
@@ -80,21 +90,12 @@ beforeAll(async () => {
|
|
|
80
90
|
tenant_secrets: tenantSecretsTable,
|
|
81
91
|
});
|
|
82
92
|
|
|
83
|
-
// Seed 20 V1 rows
|
|
93
|
+
// Seed 20 V1 rows through the real write path (executor.create) instead
|
|
94
|
+
// of a raw insertOne — the rotate job's executor.update needs an actual
|
|
95
|
+
// event stream to update against, which a headless projection row lacks.
|
|
96
|
+
const seedSecrets = createSecretsContext({ db: stack.db, masterKeyProvider: seedProvider });
|
|
84
97
|
for (let i = 0; i < 20; i++) {
|
|
85
|
-
|
|
86
|
-
await insertOne(stack.db, tenantSecretsTable, {
|
|
87
|
-
tenantId: admin.tenantId,
|
|
88
|
-
key: `test:secret:k-${i}`,
|
|
89
|
-
envelope: {
|
|
90
|
-
ciphertext: envelope.ciphertext.toString("base64"),
|
|
91
|
-
iv: envelope.iv.toString("base64"),
|
|
92
|
-
authTag: envelope.authTag.toString("base64"),
|
|
93
|
-
encryptedDek: envelope.encryptedDek.toString("base64"),
|
|
94
|
-
kekVersion: envelope.kekVersion,
|
|
95
|
-
},
|
|
96
|
-
kekVersion: envelope.kekVersion,
|
|
97
|
-
});
|
|
98
|
+
await seedSecrets.set(admin.tenantId, `test:secret:k-${i}`, `secret-${i}`);
|
|
98
99
|
}
|
|
99
100
|
});
|
|
100
101
|
|
|
@@ -170,4 +171,47 @@ describe("rotate-job circuit-breaker", () => {
|
|
|
170
171
|
expect(await countV1Rows()).toBe(20);
|
|
171
172
|
expect(broken.calls()).toBe(25);
|
|
172
173
|
});
|
|
174
|
+
|
|
175
|
+
// Happy-path counterpart to the failure tests above: a working provider
|
|
176
|
+
// that actually rewraps. Run last — it's the only test that mutates the
|
|
177
|
+
// shared 20-row fixture, so the earlier "stays at 20" assertions must
|
|
178
|
+
// see it before this runs.
|
|
179
|
+
test("successful rotation rewraps the DEK, bumps kekVersion, and preserves plaintext", async () => {
|
|
180
|
+
const rotator = createEnvMasterKeyProvider({
|
|
181
|
+
env: {
|
|
182
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: SEED_V1_KEY,
|
|
183
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
|
|
184
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await rotateJob({ batchSize: 10, maxFailures: 5 }, jobCtx(rotator));
|
|
189
|
+
|
|
190
|
+
expect(await countV1Rows()).toBe(0);
|
|
191
|
+
|
|
192
|
+
// Decrypt with a V2-ONLY provider — no V1 key available. A rewrap
|
|
193
|
+
// that only bumped the kek_version column but left the envelope
|
|
194
|
+
// wrapped under V1 would throw "no KEK for version 1" here instead
|
|
195
|
+
// of silently passing, which a decrypt via `rotator` (V1+V2) can't
|
|
196
|
+
// catch since it still holds V1.
|
|
197
|
+
const verifier = createEnvMasterKeyProvider({
|
|
198
|
+
env: {
|
|
199
|
+
KUMIKO_SECRETS_MASTER_KEY_V2: ROTATED_V2_KEY,
|
|
200
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "2",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const rows = await selectMany<{ key: string; envelope: StoredEnvelope; kekVersion: number }>(
|
|
205
|
+
stack.db,
|
|
206
|
+
tenantSecretsTable,
|
|
207
|
+
{ tenantId: admin.tenantId },
|
|
208
|
+
);
|
|
209
|
+
expect(rows).toHaveLength(20);
|
|
210
|
+
for (const row of rows) {
|
|
211
|
+
expect(row.kekVersion).toBe(2);
|
|
212
|
+
const expectedIndex = row.key.split("-").at(-1);
|
|
213
|
+
const plaintext = await decryptValue(decodeStoredEnvelope(row.envelope), verifier);
|
|
214
|
+
expect(plaintext).toBe(`secret-${expectedIndex}`);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
173
217
|
});
|