@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.292.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.292.0",
202
- "@cosmicdrift/kumiko-types": "0.292.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.292.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
  });
@@ -1,12 +1,20 @@
1
- // Resolves PLATFORM_KEK / PLATFORM_KEK_PREVIOUS from a Key Manager ciphertext
2
- // when no plaintext is set, so the KEK need not sit in the pod env in the
3
- // clear. `PLATFORM_KEK` stays the source of truth: a plaintext value always
4
- // wins over its ciphertext sibling, with no request made at all.
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
- plaintext: string | undefined,
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: string,
126
- plaintext: string | undefined,
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 the
142
- // other slot's fallback path — the trio check downstream still applies.
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 active = await resolveSlot(
150
- env.PLATFORM_KEK,
151
- env.PLATFORM_KEK_CIPHERTEXT,
152
- env,
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 line of [
168
- describeKekSource("PLATFORM_KEK", env.PLATFORM_KEK, env.PLATFORM_KEK_CIPHERTEXT, env),
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 (previous && !env.PLATFORM_KEK_PREVIOUS_VERSION) {
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
- if (active === env.PLATFORM_KEK && previous === env.PLATFORM_KEK_PREVIOUS) {
186
- return env;
187
- }
188
- return { ...env, PLATFORM_KEK: active, PLATFORM_KEK_PREVIOUS: previous };
176
+ const changed = RESOLVABLE_SLOTS.some((name) => resolved[name] !== env[name]);
177
+ if (!changed) return env;
178
+
179
+ return { ...env, ...resolved };
189
180
  }