@cosmicdrift/kumiko-framework 0.292.0 → 0.293.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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.293.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.293.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.293.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.293.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.293.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Resolve the blind-index key from a Key Manager ciphertext",
|
|
6
|
+
"detail": "resolvePlatformKeks walks an allowlist of slots (PLATFORM_KEK, PLATFORM_KEK_PREVIOUS, KUMIKO_BLIND_INDEX_KEY) instead of two hardcoded KEK slots, so each slot's <SLOT>_CIPHERTEXT is unwrapped at boot. The blind-index key can leave the pod env in the clear with no app-code change, because resolveKmsWiringAsync already resolves before its trio check. Ciphertexts outside the allowlist are ignored rather than resolved: a foreign *_CIPHERTEXT must never fail boot."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.292.0",
|
|
4
10
|
"type": "fix",
|
|
@@ -295,4 +295,65 @@ describe("resolvePlatformKeks", () => {
|
|
|
295
295
|
|
|
296
296
|
expect(lines).toEqual([]);
|
|
297
297
|
});
|
|
298
|
+
|
|
299
|
+
test("decrypts KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT into KUMIKO_BLIND_INDEX_KEY", async () => {
|
|
300
|
+
const { fetch, calls } = trackedFetch([jsonResponse(200, { plaintext: PLAINTEXT_A })]);
|
|
301
|
+
const env: KekSourceEnv = {
|
|
302
|
+
KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
|
|
303
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
304
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
308
|
+
|
|
309
|
+
expect(result.KUMIKO_BLIND_INDEX_KEY).toBe(PLAINTEXT_A);
|
|
310
|
+
expect(calls.length).toBe(1);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a plaintext KUMIKO_BLIND_INDEX_KEY wins over its ciphertext, no fetch", async () => {
|
|
314
|
+
const { fetch, calls } = trackedFetch([]);
|
|
315
|
+
const env: KekSourceEnv = {
|
|
316
|
+
KUMIKO_BLIND_INDEX_KEY: "blind-index-plaintext",
|
|
317
|
+
KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_A,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
321
|
+
|
|
322
|
+
expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
|
|
323
|
+
expect(calls.length).toBe(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("ignores a ciphertext outside the allowlist entirely", async () => {
|
|
327
|
+
const { fetch, calls } = trackedFetch([]);
|
|
328
|
+
const env: KekSourceEnv = {
|
|
329
|
+
FOO_CIPHERTEXT: CIPHERTEXT_A,
|
|
330
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
331
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
335
|
+
|
|
336
|
+
expect(calls.length).toBe(0);
|
|
337
|
+
expect(result["FOO"]).toBeUndefined();
|
|
338
|
+
expect(result).toBe(env);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("decrypts PLATFORM_KEK and KUMIKO_BLIND_INDEX_KEY ciphertexts together", async () => {
|
|
342
|
+
const { fetch, calls } = trackedFetch([
|
|
343
|
+
jsonResponse(200, { plaintext: "active-plaintext" }),
|
|
344
|
+
jsonResponse(200, { plaintext: "blind-index-plaintext" }),
|
|
345
|
+
]);
|
|
346
|
+
const env: KekSourceEnv = {
|
|
347
|
+
PLATFORM_KEK_CIPHERTEXT: CIPHERTEXT_A,
|
|
348
|
+
KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT: CIPHERTEXT_B,
|
|
349
|
+
PLATFORM_KEK_KMS_KEY_ID: "key-1",
|
|
350
|
+
PLATFORM_KEK_KMS_TOKEN: TOKEN,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const result = await resolvePlatformKeks(env, { fetch });
|
|
354
|
+
|
|
355
|
+
expect(result.PLATFORM_KEK).toBe("active-plaintext");
|
|
356
|
+
expect(result.KUMIKO_BLIND_INDEX_KEY).toBe("blind-index-plaintext");
|
|
357
|
+
expect(calls.length).toBe(2);
|
|
358
|
+
});
|
|
298
359
|
});
|
package/src/crypto/kek-source.ts
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// Resolves
|
|
2
|
-
// when no plaintext is set, so
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Resolves an allowlisted set of secrets (RESOLVABLE_SLOTS) from a Key
|
|
2
|
+
// Manager ciphertext when no plaintext is set, so they need not sit in the
|
|
3
|
+
// pod env in the clear. A slot's plaintext always wins over its ciphertext
|
|
4
|
+
// sibling, with no request made at all. Any other `*_CIPHERTEXT` in the env
|
|
5
|
+
// is outside the allowlist and is ignored — a foreign ciphertext must never
|
|
6
|
+
// fail boot.
|
|
5
7
|
|
|
6
8
|
const SCALEWAY_KEY_MANAGER_API_VERSION = "v1alpha1";
|
|
7
9
|
const DEFAULT_REGION = "fr-par";
|
|
8
10
|
const DECRYPT_TIMEOUT_MS = 5_000;
|
|
9
11
|
const RETRY_DELAYS_MS = [200, 800];
|
|
12
|
+
const RESOLVABLE_SLOTS = [
|
|
13
|
+
"PLATFORM_KEK",
|
|
14
|
+
"PLATFORM_KEK_PREVIOUS",
|
|
15
|
+
"KUMIKO_BLIND_INDEX_KEY",
|
|
16
|
+
] as const;
|
|
17
|
+
type ResolvableSlot = (typeof RESOLVABLE_SLOTS)[number];
|
|
10
18
|
|
|
11
19
|
export type KekSourceEnv = {
|
|
12
20
|
readonly PLATFORM_KEK?: string | undefined;
|
|
@@ -17,6 +25,8 @@ export type KekSourceEnv = {
|
|
|
17
25
|
readonly PLATFORM_KEK_KMS_KEY_ID?: string | undefined;
|
|
18
26
|
readonly PLATFORM_KEK_KMS_TOKEN?: string | undefined;
|
|
19
27
|
readonly PLATFORM_KEK_KMS_REGION?: string | undefined;
|
|
28
|
+
readonly KUMIKO_BLIND_INDEX_KEY?: string | undefined;
|
|
29
|
+
readonly KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT?: string | undefined;
|
|
20
30
|
readonly [key: string]: string | undefined;
|
|
21
31
|
};
|
|
22
32
|
|
|
@@ -97,13 +107,14 @@ async function decryptCiphertext(
|
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
async function resolveSlot(
|
|
100
|
-
|
|
101
|
-
ciphertext: string | undefined,
|
|
110
|
+
name: ResolvableSlot,
|
|
102
111
|
env: KekSourceEnv,
|
|
103
112
|
options: KekSourceOptions,
|
|
104
113
|
fetchImpl: typeof globalThis.fetch,
|
|
105
114
|
): Promise<string | undefined> {
|
|
115
|
+
const plaintext = env[name];
|
|
106
116
|
if (plaintext) return plaintext;
|
|
117
|
+
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
107
118
|
if (!ciphertext) return undefined;
|
|
108
119
|
|
|
109
120
|
const keyId = env.PLATFORM_KEK_KMS_KEY_ID;
|
|
@@ -111,7 +122,7 @@ async function resolveSlot(
|
|
|
111
122
|
if (!keyId || !token) {
|
|
112
123
|
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
113
124
|
throw new Error(
|
|
114
|
-
`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext — a partial set means the KMS wiring is broken.`,
|
|
125
|
+
`${prefix}PLATFORM_KEK_KMS_KEY_ID / PLATFORM_KEK_KMS_TOKEN are all-or-none with a KEK ciphertext (slot ${name}) — a partial set means the KMS wiring is broken.`,
|
|
115
126
|
);
|
|
116
127
|
}
|
|
117
128
|
const region = env.PLATFORM_KEK_KMS_REGION ?? DEFAULT_REGION;
|
|
@@ -121,12 +132,9 @@ async function resolveSlot(
|
|
|
121
132
|
// A leftover plaintext beside a ciphertext boots green while nothing was
|
|
122
133
|
// migrated, which is indistinguishable from a finished cutover unless the
|
|
123
134
|
// boot says which source won. Never carries a key value, only its origin.
|
|
124
|
-
function describeKekSource(
|
|
125
|
-
name
|
|
126
|
-
|
|
127
|
-
ciphertext: string | undefined,
|
|
128
|
-
env: KekSourceEnv,
|
|
129
|
-
): string | undefined {
|
|
135
|
+
function describeKekSource(name: ResolvableSlot, env: KekSourceEnv): string | undefined {
|
|
136
|
+
const plaintext = env[name];
|
|
137
|
+
const ciphertext = env[`${name}_CIPHERTEXT`];
|
|
130
138
|
if (plaintext) {
|
|
131
139
|
return ciphertext
|
|
132
140
|
? `${name} source=plaintext-env (ciphertext present and ignored)`
|
|
@@ -138,52 +146,35 @@ function describeKekSource(
|
|
|
138
146
|
}
|
|
139
147
|
|
|
140
148
|
// Each slot resolves independently so a rollback that clears one slot's
|
|
141
|
-
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
|
|
142
|
-
//
|
|
149
|
+
// plaintext (leaving its ciphertext/_VERSION behind or gone) never blocks
|
|
150
|
+
// another slot's fallback path — the trio check downstream still applies.
|
|
143
151
|
export async function resolvePlatformKeks(
|
|
144
152
|
env: KekSourceEnv,
|
|
145
153
|
options: KekSourceOptions = {},
|
|
146
154
|
): Promise<KekSourceEnv> {
|
|
147
155
|
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
148
156
|
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
env
|
|
152
|
-
|
|
153
|
-
options,
|
|
154
|
-
fetchImpl,
|
|
155
|
-
);
|
|
156
|
-
const previous = await resolveSlot(
|
|
157
|
-
env.PLATFORM_KEK_PREVIOUS,
|
|
158
|
-
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
159
|
-
env,
|
|
160
|
-
options,
|
|
161
|
-
fetchImpl,
|
|
162
|
-
);
|
|
157
|
+
const resolved: Partial<Record<ResolvableSlot, string | undefined>> = {};
|
|
158
|
+
for (const name of RESOLVABLE_SLOTS) {
|
|
159
|
+
resolved[name] = await resolveSlot(name, env, options, fetchImpl);
|
|
160
|
+
}
|
|
163
161
|
|
|
164
162
|
const prefix = options.logPrefix ? `${options.logPrefix} ` : "";
|
|
165
163
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
166
164
|
const log = options.log ?? console.info;
|
|
167
|
-
for (const
|
|
168
|
-
describeKekSource(
|
|
169
|
-
describeKekSource(
|
|
170
|
-
"PLATFORM_KEK_PREVIOUS",
|
|
171
|
-
env.PLATFORM_KEK_PREVIOUS,
|
|
172
|
-
env.PLATFORM_KEK_PREVIOUS_CIPHERTEXT,
|
|
173
|
-
env,
|
|
174
|
-
),
|
|
175
|
-
]) {
|
|
165
|
+
for (const name of RESOLVABLE_SLOTS) {
|
|
166
|
+
const line = describeKekSource(name, env);
|
|
176
167
|
if (line) log(`${prefix}${line}`);
|
|
177
168
|
}
|
|
178
169
|
|
|
179
|
-
if (
|
|
170
|
+
if (resolved.PLATFORM_KEK_PREVIOUS && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
|
|
180
171
|
throw new Error(
|
|
181
172
|
`${prefix}PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.`,
|
|
182
173
|
);
|
|
183
174
|
}
|
|
184
175
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
return { ...env,
|
|
176
|
+
const changed = RESOLVABLE_SLOTS.some((name) => resolved[name] !== env[name]);
|
|
177
|
+
if (!changed) return env;
|
|
178
|
+
|
|
179
|
+
return { ...env, ...resolved };
|
|
189
180
|
}
|