@cosmicdrift/kumiko-framework 0.120.0 → 0.121.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.120.0",
3
+ "version": "0.121.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>",
@@ -185,7 +185,7 @@
185
185
  "zod": "^4.4.3"
186
186
  },
187
187
  "devDependencies": {
188
- "@cosmicdrift/kumiko-dispatcher-live": "0.120.0",
188
+ "@cosmicdrift/kumiko-dispatcher-live": "0.121.0",
189
189
  "bun-types": "^1.3.13",
190
190
  "pino-pretty": "^13.1.3"
191
191
  },
@@ -0,0 +1,180 @@
1
+ // KEK rotation (#818 step 7): mid-rotation an adapter with the NEW active
2
+ // KEK + previousKeks reads old-generation rows; rewrapSubjectKeys migrates
3
+ // the estate to the new generation; afterwards previousKeks can be dropped.
4
+ // Erased tombstones stay untouched, unknown generations fail loud as a
5
+ // CONFIG error (not a shredded subject).
6
+
7
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { randomUUID } from "node:crypto";
9
+ import postgres from "postgres";
10
+ import { createTestDb } from "../../stack/db";
11
+ import type { KmsContext, SubjectId } from "../kms-adapter";
12
+ import { createPgKmsAdapter, rewrapSubjectKeys } from "../pg-kms-adapter";
13
+
14
+ const baseUrl = process.env["TEST_DATABASE_URL"];
15
+ if (!baseUrl) throw new Error("Missing required env var: TEST_DATABASE_URL");
16
+
17
+ const testDb = await createTestDb();
18
+ const DB_URL = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
19
+
20
+ const KEK_V1 = Buffer.alloc(32, 1).toString("base64");
21
+ const KEK_V2 = Buffer.alloc(32, 2).toString("base64");
22
+ const ctx: KmsContext = { requestId: "kek-rotation-test" };
23
+ const freshUser = (): SubjectId => ({ kind: "user", userId: randomUUID() });
24
+
25
+ const raw = postgres(DB_URL, { max: 1, onnotice: () => {} });
26
+
27
+ afterAll(async () => {
28
+ await raw.end();
29
+ await testDb.cleanup();
30
+ });
31
+
32
+ beforeEach(async () => {
33
+ const boot = createPgKmsAdapter({ databaseUrl: DB_URL, platformKek: KEK_V1 });
34
+ await boot.health();
35
+ await boot.close();
36
+ await raw`TRUNCATE kumiko_subject_keys`;
37
+ });
38
+
39
+ async function seedV1Keys(count: number): Promise<SubjectId[]> {
40
+ const v1 = createPgKmsAdapter({ databaseUrl: DB_URL, platformKek: KEK_V1 });
41
+ const subjects: SubjectId[] = [];
42
+ for (let i = 0; i < count; i++) {
43
+ const subject = freshUser();
44
+ await v1.createKey(subject, ctx);
45
+ subjects.push(subject);
46
+ }
47
+ await v1.close();
48
+ return subjects;
49
+ }
50
+
51
+ describe("KEK rotation", () => {
52
+ test("mid-rotation adapter (new active + previousKeks) reads v1 rows; new writes carry v2", async () => {
53
+ const [oldSubject] = await seedV1Keys(1);
54
+ if (!oldSubject) throw new Error("seed failed");
55
+
56
+ const rotating = createPgKmsAdapter({
57
+ databaseUrl: DB_URL,
58
+ platformKek: KEK_V2,
59
+ kekVersion: 2,
60
+ previousKeks: { 1: KEK_V1 },
61
+ });
62
+ const dek = await rotating.getKey(oldSubject, ctx);
63
+ expect(dek.length).toBe(32);
64
+
65
+ const newSubject = freshUser();
66
+ await rotating.createKey(newSubject, ctx);
67
+ const versions = await raw<Array<{ kek_version: number }>>`
68
+ SELECT kek_version FROM kumiko_subject_keys ORDER BY kek_version`;
69
+ expect(versions.map((r) => r.kek_version)).toEqual([1, 2]);
70
+ await rotating.close();
71
+ });
72
+
73
+ test("unknown generation without previousKeks is a loud config error", async () => {
74
+ const [oldSubject] = await seedV1Keys(1);
75
+ if (!oldSubject) throw new Error("seed failed");
76
+
77
+ const misconfigured = createPgKmsAdapter({
78
+ databaseUrl: DB_URL,
79
+ platformKek: KEK_V2,
80
+ kekVersion: 2,
81
+ });
82
+ expect(misconfigured.getKey(oldSubject, ctx)).rejects.toThrow(/pass previousKeks/);
83
+ await misconfigured.close();
84
+ });
85
+
86
+ test("previousKeks newer than the active version is rejected at construction", () => {
87
+ expect(() =>
88
+ createPgKmsAdapter({
89
+ databaseUrl: DB_URL,
90
+ platformKek: KEK_V1,
91
+ kekVersion: 1,
92
+ previousKeks: { 2: KEK_V2 },
93
+ }),
94
+ ).toThrow(/must be older/);
95
+ });
96
+
97
+ test("rewrapSubjectKeys migrates the estate; DEKs stay identical; idempotent", async () => {
98
+ const subjects = await seedV1Keys(3);
99
+ const v1 = createPgKmsAdapter({ databaseUrl: DB_URL, platformKek: KEK_V1 });
100
+ const before = await Promise.all(subjects.map((s) => v1.getKey(s, ctx)));
101
+ await v1.close();
102
+
103
+ const result = await rewrapSubjectKeys({
104
+ databaseUrl: DB_URL,
105
+ fromKeks: { 1: KEK_V1 },
106
+ toKek: KEK_V2,
107
+ toKekVersion: 2,
108
+ batchSize: 2,
109
+ });
110
+ expect(result.failures).toEqual([]);
111
+ expect(result.rewrapped).toBe(3);
112
+
113
+ // The rotated estate is fully readable with ONLY the new KEK — and the
114
+ // unwrapped DEKs are byte-identical (stored ciphertext stays decryptable).
115
+ const v2 = createPgKmsAdapter({ databaseUrl: DB_URL, platformKek: KEK_V2, kekVersion: 2 });
116
+ const after = await Promise.all(subjects.map((s) => v2.getKey(s, ctx)));
117
+ for (const [i, dek] of after.entries()) {
118
+ expect(dek.equals(before[i] as Buffer)).toBe(true);
119
+ }
120
+ await v2.close();
121
+
122
+ const second = await rewrapSubjectKeys({
123
+ databaseUrl: DB_URL,
124
+ fromKeks: { 1: KEK_V1 },
125
+ toKek: KEK_V2,
126
+ toKekVersion: 2,
127
+ });
128
+ expect(second.scanned).toBe(0);
129
+ expect(second.rewrapped).toBe(0);
130
+ });
131
+
132
+ test("erased tombstones are skipped and stay erased", async () => {
133
+ const [gone] = await seedV1Keys(1);
134
+ if (!gone) throw new Error("seed failed");
135
+ const v1 = createPgKmsAdapter({ databaseUrl: DB_URL, platformKek: KEK_V1 });
136
+ await v1.eraseKey(gone, ctx);
137
+ await v1.close();
138
+
139
+ const result = await rewrapSubjectKeys({
140
+ databaseUrl: DB_URL,
141
+ fromKeks: { 1: KEK_V1 },
142
+ toKek: KEK_V2,
143
+ toKekVersion: 2,
144
+ });
145
+ expect(result.skippedErased).toBe(1);
146
+ expect(result.rewrapped).toBe(0);
147
+
148
+ const rows = await raw<Array<{ cipher_key: Uint8Array | null }>>`
149
+ SELECT cipher_key FROM kumiko_subject_keys`;
150
+ expect(rows[0]?.cipher_key).toBeNull();
151
+ });
152
+
153
+ test("dryRun counts but writes nothing", async () => {
154
+ await seedV1Keys(2);
155
+ const dry = await rewrapSubjectKeys({
156
+ databaseUrl: DB_URL,
157
+ fromKeks: { 1: KEK_V1 },
158
+ toKek: KEK_V2,
159
+ toKekVersion: 2,
160
+ dryRun: true,
161
+ });
162
+ expect(dry.rewrapped).toBe(2);
163
+ const versions = await raw<Array<{ kek_version: number }>>`
164
+ SELECT DISTINCT kek_version FROM kumiko_subject_keys`;
165
+ expect(versions.map((r) => r.kek_version)).toEqual([1]);
166
+ });
167
+
168
+ test("missing fromKek for a generation lands in failures, not a crash", async () => {
169
+ await seedV1Keys(1);
170
+ const result = await rewrapSubjectKeys({
171
+ databaseUrl: DB_URL,
172
+ fromKeks: {},
173
+ toKek: KEK_V2,
174
+ toKekVersion: 2,
175
+ });
176
+ expect(result.rewrapped).toBe(0);
177
+ expect(result.failures).toHaveLength(1);
178
+ expect(result.failures[0]?.reason).toContain("no fromKek configured");
179
+ });
180
+ });
@@ -38,6 +38,9 @@ export {
38
38
  createPgKmsAdapter,
39
39
  PgKmsAdapter,
40
40
  type PgKmsAdapterOptions,
41
+ type RewrapOptions,
42
+ type RewrapResult,
43
+ rewrapSubjectKeys,
41
44
  } from "./pg-kms-adapter";
42
45
  export {
43
46
  configuredPiiSubjectKms,
@@ -57,6 +57,7 @@ function isPgUniqueViolation(error: unknown): boolean {
57
57
 
58
58
  interface SubjectKeyRow {
59
59
  cipher_key: Uint8Array | null;
60
+ kek_version: number;
60
61
  erased: boolean;
61
62
  }
62
63
 
@@ -65,6 +66,13 @@ export interface PgKmsAdapterOptions {
65
66
  readonly databaseUrl: string;
66
67
  /** Base64-encoded 32-byte platform KEK (PLATFORM_KEK env var). */
67
68
  readonly platformKek: string;
69
+ /** Version of `platformKek` as stored in kumiko_subject_keys.kek_version.
70
+ * Bump on rotation; new wraps carry this version. Default 1. */
71
+ readonly kekVersion?: number;
72
+ /** Older KEKs by version, kept ONLY during a rotation window so rows not
73
+ * yet re-wrapped stay readable. Drop after rewrapSubjectKeys reports the
74
+ * estate fully on the active version (runbook: kek-rotation.md). */
75
+ readonly previousKeks?: Readonly<Record<number, string>>;
68
76
  readonly maxConnections?: number;
69
77
  }
70
78
 
@@ -76,10 +84,24 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
76
84
 
77
85
  private readonly sql: ReturnType<typeof postgres>;
78
86
  private readonly kek: Buffer;
87
+ private readonly kekVersion: number;
88
+ private readonly previousKeks: ReadonlyMap<number, Buffer>;
79
89
  private schemaReady: Promise<void> | undefined;
80
90
 
81
91
  constructor(options: PgKmsAdapterOptions) {
82
92
  this.kek = decodePlatformKek(options.platformKek);
93
+ this.kekVersion = options.kekVersion ?? 1;
94
+ const previous = new Map<number, Buffer>();
95
+ for (const [version, kek] of Object.entries(options.previousKeks ?? {})) {
96
+ const v = Number(version);
97
+ if (v >= this.kekVersion) {
98
+ throw new Error(
99
+ `PgKmsAdapter: previousKeks[${v}] must be older than the active kekVersion ${this.kekVersion}`,
100
+ );
101
+ }
102
+ previous.set(v, decodePlatformKek(kek));
103
+ }
104
+ this.previousKeks = previous;
83
105
  this.sql = postgres(options.databaseUrl, {
84
106
  max: options.maxConnections ?? 4,
85
107
  // The connection is exclusive to this adapter and its only DDL is
@@ -93,8 +115,8 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
93
115
  const wrapped = wrapDek(this.kek, randomBytes(32));
94
116
  try {
95
117
  await this.sql`
96
- INSERT INTO kumiko_subject_keys (subject_id, cipher_key, created_by)
97
- VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${ctx.userId ?? null})`;
118
+ INSERT INTO kumiko_subject_keys (subject_id, cipher_key, kek_version, created_by)
119
+ VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${this.kekVersion}, ${ctx.userId ?? null})`;
98
120
  } catch (error) {
99
121
  if (isPgUniqueViolation(error)) throw new KeyAlreadyExistsError(subject);
100
122
  throw error;
@@ -104,13 +126,25 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
104
126
  async getKey(subject: SubjectId, _ctx: KmsContext): Promise<SubjectDek> {
105
127
  await this.ensureSchema();
106
128
  const rows = await this.sql<SubjectKeyRow[]>`
107
- SELECT cipher_key, (erased_at IS NOT NULL) AS erased
129
+ SELECT cipher_key, kek_version, (erased_at IS NOT NULL) AS erased
108
130
  FROM kumiko_subject_keys
109
131
  WHERE subject_id = ${subjectIdToKey(subject)}`;
110
132
  const row = rows[0];
111
133
  if (!row) throw new KeyNotFoundError(subject);
112
134
  if (row.erased || row.cipher_key === null) throw new KeyErasedError(subject);
113
- return unwrapDek(this.kek, row.cipher_key);
135
+ return unwrapDek(this.kekFor(row.kek_version, subject), row.cipher_key);
136
+ }
137
+
138
+ // Rows keep working mid-rotation: not-yet-rewrapped DEKs name their KEK
139
+ // generation and unwrap with the matching previous key. A version with no
140
+ // configured key is a hard config error — NOT a shredded subject.
141
+ private kekFor(version: number, subject: SubjectId): Buffer {
142
+ if (version === this.kekVersion) return this.kek;
143
+ const previous = this.previousKeks.get(version);
144
+ if (previous) return previous;
145
+ throw new Error(
146
+ `PgKmsAdapter: subject ${subjectIdToKey(subject)} is wrapped with KEK version ${version} but only version ${this.kekVersion}${this.previousKeks.size > 0 ? ` (+previous ${[...this.previousKeks.keys()].join(",")})` : ""} is configured — pass previousKeks during rotation (runbook: kek-rotation.md).`,
147
+ );
114
148
  }
115
149
 
116
150
  async eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void> {
@@ -171,3 +205,91 @@ export class PgKmsAdapter implements LocalKeyKmsAdapter {
171
205
  export function createPgKmsAdapter(options: PgKmsAdapterOptions): PgKmsAdapter {
172
206
  return new PgKmsAdapter(options);
173
207
  }
208
+
209
+ export interface RewrapOptions {
210
+ readonly databaseUrl: string;
211
+ /** Older KEKs by version — every version still present in the table must be covered. */
212
+ readonly fromKeks: Readonly<Record<number, string>>;
213
+ /** The new active KEK and its version (previous active version + 1). */
214
+ readonly toKek: string;
215
+ readonly toKekVersion: number;
216
+ readonly batchSize?: number;
217
+ readonly dryRun?: boolean;
218
+ }
219
+
220
+ export interface RewrapResult {
221
+ readonly scanned: number;
222
+ readonly rewrapped: number;
223
+ readonly skippedErased: number;
224
+ readonly failures: ReadonlyArray<{ readonly subjectId: string; readonly reason: string }>;
225
+ }
226
+
227
+ // One-time KEK rotation (runbook: kek-rotation.md). Unwraps every live DEK
228
+ // wrapped with an older KEK generation and re-wraps it under the new KEK,
229
+ // bumping kek_version. Idempotent: rows already on toKekVersion are not
230
+ // selected; a second run reports 0. Erased tombstones (cipher_key NULL) are
231
+ // never touched. The UPDATE is guarded on the row's old kek_version, so a
232
+ // concurrent app writing fresh keys on the new version is never clobbered.
233
+ export async function rewrapSubjectKeys(options: RewrapOptions): Promise<RewrapResult> {
234
+ const toKek = decodePlatformKek(options.toKek);
235
+ const fromKeks = new Map<number, Buffer>();
236
+ for (const [version, kek] of Object.entries(options.fromKeks)) {
237
+ fromKeks.set(Number(version), decodePlatformKek(kek));
238
+ }
239
+ const batchSize = options.batchSize ?? 200;
240
+ const sql = postgres(options.databaseUrl, { max: 2, onnotice: () => {} });
241
+
242
+ const result = {
243
+ scanned: 0,
244
+ rewrapped: 0,
245
+ skippedErased: 0,
246
+ failures: [] as Array<{ subjectId: string; reason: string }>,
247
+ };
248
+ try {
249
+ let cursor = "";
250
+ for (;;) {
251
+ const rows = await sql<
252
+ Array<{ subject_id: string; cipher_key: Uint8Array | null; kek_version: number }>
253
+ >`
254
+ SELECT subject_id, cipher_key, kek_version
255
+ FROM kumiko_subject_keys
256
+ WHERE kek_version <> ${options.toKekVersion} AND subject_id > ${cursor}
257
+ ORDER BY subject_id ASC
258
+ LIMIT ${batchSize}`;
259
+ if (rows.length === 0) break;
260
+
261
+ for (const row of rows) {
262
+ result.scanned++;
263
+ if (row.cipher_key === null) {
264
+ result.skippedErased++;
265
+ continue;
266
+ }
267
+ try {
268
+ const fromKek = fromKeks.get(row.kek_version);
269
+ if (!fromKek) {
270
+ throw new Error(`no fromKek configured for kek_version ${row.kek_version}`);
271
+ }
272
+ const rewrapped = wrapDek(toKek, unwrapDek(fromKek, row.cipher_key));
273
+ if (!options.dryRun) {
274
+ await sql`
275
+ UPDATE kumiko_subject_keys
276
+ SET cipher_key = ${rewrapped}, kek_version = ${options.toKekVersion}
277
+ WHERE subject_id = ${row.subject_id} AND kek_version = ${row.kek_version}`;
278
+ }
279
+ result.rewrapped++;
280
+ } catch (error) {
281
+ result.failures.push({
282
+ subjectId: row.subject_id,
283
+ reason: error instanceof Error ? error.message : String(error),
284
+ });
285
+ }
286
+ }
287
+ const last = rows[rows.length - 1];
288
+ if (last === undefined) break;
289
+ cursor = last.subject_id;
290
+ }
291
+ } finally {
292
+ await sql.end();
293
+ }
294
+ return result;
295
+ }