@cosmicdrift/kumiko-framework 0.119.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.119.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.119.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,161 @@
1
+ // Event-PII catalog (#799): defineEvent({ piiFields }) → createRegistry
2
+ // publishes the catalog → encryptEventPayloadPii encrypts under the owning
3
+ // user's DEK. append() applies this on every write path; the pure pieces
4
+ // are testable without a database.
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import { z } from "zod";
8
+ import { createRegistry, defineFeature } from "../../engine";
9
+ import {
10
+ configuredEventPiiCatalog,
11
+ configureEventPiiCatalog,
12
+ encryptEventPayloadPii,
13
+ resetEventPiiCatalogForTests,
14
+ } from "../event-pii";
15
+ import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
16
+ import {
17
+ configurePiiSubjectKms,
18
+ decryptPiiFieldValues,
19
+ isPiiCiphertext,
20
+ PII_ERASED_SENTINEL,
21
+ resetPiiSubjectKmsForTests,
22
+ } from "../pii-field-encryption";
23
+
24
+ const attemptSchema = z.object({
25
+ recipientId: z.string().nullable(),
26
+ recipientAddress: z.string().nullable(),
27
+ status: z.string(),
28
+ });
29
+
30
+ const EVENT_TYPE = "mailer:event:attempt";
31
+
32
+ function catalogWithAttempt(): void {
33
+ configureEventPiiCatalog(
34
+ new Map([[EVENT_TYPE, { recipientAddress: { subjectField: "recipientId" } }]]),
35
+ );
36
+ }
37
+
38
+ afterEach(() => {
39
+ resetEventPiiCatalogForTests();
40
+ resetPiiSubjectKmsForTests();
41
+ });
42
+
43
+ describe("defineEvent piiFields validation", () => {
44
+ test("valid piiFields land on the EventDef and in the registry catalog", () => {
45
+ const feature = defineFeature("mailer", (r) => {
46
+ r.defineEvent("attempt", attemptSchema, {
47
+ piiFields: { recipientAddress: { subjectField: "recipientId" } },
48
+ });
49
+ });
50
+ createRegistry([feature]);
51
+ expect(configuredEventPiiCatalog().get(EVENT_TYPE)).toEqual({
52
+ recipientAddress: { subjectField: "recipientId" },
53
+ });
54
+ });
55
+
56
+ test("pii field not on the payload schema throws at definition time", () => {
57
+ expect(() =>
58
+ defineFeature("mailer", (r) => {
59
+ r.defineEvent("attempt", attemptSchema, {
60
+ piiFields: { nope: { subjectField: "recipientId" } },
61
+ });
62
+ }),
63
+ ).toThrow(/piiFields references "nope"/);
64
+ });
65
+
66
+ test("unknown subjectField throws at definition time", () => {
67
+ expect(() =>
68
+ defineFeature("mailer", (r) => {
69
+ r.defineEvent("attempt", attemptSchema, {
70
+ piiFields: { recipientAddress: { subjectField: "ownerId" } },
71
+ });
72
+ }),
73
+ ).toThrow(/piiFields references "ownerId"/);
74
+ });
75
+
76
+ test("field cannot be its own subjectField", () => {
77
+ expect(() =>
78
+ defineFeature("mailer", (r) => {
79
+ r.defineEvent("attempt", attemptSchema, {
80
+ piiFields: { recipientAddress: { subjectField: "recipientAddress" } },
81
+ });
82
+ }),
83
+ ).toThrow(/cannot use itself as subjectField/);
84
+ });
85
+
86
+ test("events without piiFields do not enter the catalog", () => {
87
+ const feature = defineFeature("mailer", (r) => {
88
+ r.defineEvent("attempt", attemptSchema);
89
+ });
90
+ createRegistry([feature]);
91
+ expect(configuredEventPiiCatalog().size).toBe(0);
92
+ });
93
+ });
94
+
95
+ describe("encryptEventPayloadPii", () => {
96
+ const payload = { recipientId: "u-1", recipientAddress: "u1@example.com", status: "sent" };
97
+
98
+ test("uncatalogued event type returns the payload untouched (same reference)", async () => {
99
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
100
+ expect(await encryptEventPayloadPii("other:event:x", payload)).toBe(payload);
101
+ });
102
+
103
+ test("no KMS configured → plaintext passthrough (rollout mode)", async () => {
104
+ catalogWithAttempt();
105
+ expect(await encryptEventPayloadPii(EVENT_TYPE, payload)).toBe(payload);
106
+ });
107
+
108
+ test("encrypts under the subject's DEK; subject fk stays plaintext", async () => {
109
+ catalogWithAttempt();
110
+ const kms = new InMemoryKmsAdapter();
111
+ configurePiiSubjectKms(kms);
112
+
113
+ const out = await encryptEventPayloadPii(EVENT_TYPE, payload);
114
+ expect(isPiiCiphertext(out["recipientAddress"])).toBe(true);
115
+ expect(String(out["recipientAddress"])).toContain("user:u-1");
116
+ expect(out["recipientId"]).toBe("u-1");
117
+ expect(out["status"]).toBe("sent");
118
+
119
+ const back = await decryptPiiFieldValues(out, ["recipientAddress"], kms, {
120
+ requestId: "test",
121
+ });
122
+ expect(back["recipientAddress"]).toBe("u1@example.com");
123
+ });
124
+
125
+ test("null subject field → value stays plaintext (no user key to shred)", async () => {
126
+ catalogWithAttempt();
127
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
128
+ const systemPayload = {
129
+ recipientId: null,
130
+ recipientAddress: "ops@example.com",
131
+ status: "sent",
132
+ };
133
+ expect(await encryptEventPayloadPii(EVENT_TYPE, systemPayload)).toBe(systemPayload);
134
+ });
135
+
136
+ test("null pii value passes through", async () => {
137
+ catalogWithAttempt();
138
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
139
+ const skipped = { recipientId: "u-1", recipientAddress: null, status: "skipped" };
140
+ expect(await encryptEventPayloadPii(EVENT_TYPE, skipped)).toBe(skipped);
141
+ });
142
+
143
+ test("idempotent: ciphertext and erased sentinel stay as-is", async () => {
144
+ catalogWithAttempt();
145
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
146
+ const once = await encryptEventPayloadPii(EVENT_TYPE, payload);
147
+ const twice = await encryptEventPayloadPii(EVENT_TYPE, once);
148
+ expect(twice["recipientAddress"]).toBe(once["recipientAddress"]);
149
+
150
+ const erased = { ...payload, recipientAddress: PII_ERASED_SENTINEL };
151
+ const out = await encryptEventPayloadPii(EVENT_TYPE, erased);
152
+ expect(out["recipientAddress"]).toBe(PII_ERASED_SENTINEL);
153
+ });
154
+
155
+ test("non-string pii value is a loud error, not a silent skip", async () => {
156
+ catalogWithAttempt();
157
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
158
+ const broken = { recipientId: "u-1", recipientAddress: 42, status: "sent" };
159
+ expect(encryptEventPayloadPii(EVENT_TYPE, broken)).rejects.toThrow(/must be a string/);
160
+ });
161
+ });
@@ -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
+ });
@@ -0,0 +1,69 @@
1
+ // PII on custom-event payloads (#799). Entity CRUD events get their PII
2
+ // encrypted by the executor; events written via ctx.appendEvent / MSP-apply /
3
+ // low-level append() (delivery attempt-log, jobs run-logger) had no encrypt
4
+ // path at all. `r.defineEvent(name, schema, { piiFields })` declares which
5
+ // payload fields are PII and which payload field names the owning user;
6
+ // createRegistry publishes the catalog and append() — the single write funnel
7
+ // into kumiko_events — encrypts every catalogued field. No caller can forget.
8
+
9
+ import { requestContext } from "../api/request-context";
10
+ import type { EventPiiFields } from "../engine/types/handlers";
11
+ import { configuredPiiSubjectKms, encryptPiiValueForSubject } from "./pii-field-encryption";
12
+
13
+ export type EventPiiCatalog = ReadonlyMap<string, EventPiiFields>;
14
+
15
+ // Boot-injected like configurePiiSubjectKms — createRegistry calls this with
16
+ // the catalog collected from all defineEvent registrations.
17
+ let catalog: EventPiiCatalog = new Map();
18
+
19
+ export function configureEventPiiCatalog(next: EventPiiCatalog): void {
20
+ catalog = next;
21
+ }
22
+
23
+ export function configuredEventPiiCatalog(): EventPiiCatalog {
24
+ return catalog;
25
+ }
26
+
27
+ /** @internal test-only */
28
+ export function resetEventPiiCatalogForTests(): void {
29
+ catalog = new Map();
30
+ }
31
+
32
+ // Encrypts catalogued payload fields under the owning user's DEK. No-op when
33
+ // the event type is uncatalogued or no subject KMS is configured (plaintext
34
+ // rollout mode — the hard boot gate governs whether that is acceptable).
35
+ // A null/absent subject field (system cron runs, recipient-less skip
36
+ // attempts) leaves the value plaintext: there is no user key to shred.
37
+ export async function encryptEventPayloadPii(
38
+ eventType: string,
39
+ payload: Record<string, unknown>,
40
+ ): Promise<Record<string, unknown>> {
41
+ const piiFields = catalog.get(eventType);
42
+ if (!piiFields) return payload;
43
+ const kms = configuredPiiSubjectKms();
44
+ if (!kms) return payload;
45
+
46
+ let out: Record<string, unknown> | undefined;
47
+ for (const [field, spec] of Object.entries(piiFields)) {
48
+ const value = payload[field];
49
+ if (value === null || value === undefined) continue;
50
+ if (typeof value !== "string") {
51
+ throw new Error(
52
+ `Event "${eventType}" piiFields."${field}" must be a string payload field, got ${typeof value}`,
53
+ );
54
+ }
55
+ const subjectId = payload[spec.subjectField];
56
+ if (typeof subjectId !== "string" || subjectId.length === 0) continue;
57
+ const encrypted = await encryptPiiValueForSubject(
58
+ kms,
59
+ { kind: "user", userId: subjectId },
60
+ value,
61
+ { requestId: requestContext.get()?.requestId ?? "append-event" },
62
+ );
63
+ if (encrypted !== value) {
64
+ out ??= { ...payload };
65
+ out[field] = encrypted;
66
+ }
67
+ }
68
+ return out ?? payload;
69
+ }
@@ -8,6 +8,13 @@ export {
8
8
  decodeBlindIndexKey,
9
9
  resetBlindIndexKeyForTests,
10
10
  } from "./blind-index";
11
+ export {
12
+ configuredEventPiiCatalog,
13
+ configureEventPiiCatalog,
14
+ type EventPiiCatalog,
15
+ encryptEventPayloadPii,
16
+ resetEventPiiCatalogForTests,
17
+ } from "./event-pii";
11
18
  export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
12
19
  export {
13
20
  isLocalKeyKmsAdapter,
@@ -31,6 +38,9 @@ export {
31
38
  createPgKmsAdapter,
32
39
  PgKmsAdapter,
33
40
  type PgKmsAdapterOptions,
41
+ type RewrapOptions,
42
+ type RewrapResult,
43
+ rewrapSubjectKeys,
34
44
  } from "./pg-kms-adapter";
35
45
  export {
36
46
  configuredPiiSubjectKms,
@@ -38,6 +48,7 @@ export {
38
48
  decryptPiiFieldValues,
39
49
  type EncryptPiiOptions,
40
50
  encryptPiiFieldValues,
51
+ encryptPiiValueForSubject,
41
52
  isPiiCiphertext,
42
53
  PII_CIPHERTEXT_PREFIX,
43
54
  PII_ERASED_SENTINEL,
@@ -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
+ }
@@ -85,6 +85,20 @@ async function getOrCreateDek(
85
85
  return kms.getKey(subject, ctx);
86
86
  }
87
87
 
88
+ // Single-value encrypt for callers that resolve the subject themselves
89
+ // (event-pii catalog, backfill). Ciphertext/sentinel inputs pass through —
90
+ // idempotent like the field-map variant.
91
+ export async function encryptPiiValueForSubject(
92
+ kms: LocalKeyKmsAdapter,
93
+ subject: SubjectId,
94
+ value: string,
95
+ kmsCtx: KmsContext,
96
+ ): Promise<string> {
97
+ if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) return value;
98
+ const dek = await getOrCreateDek(kms, subject, kmsCtx);
99
+ return encryptValue(subject, dek, value);
100
+ }
101
+
88
102
  export interface EncryptPiiOptions {
89
103
  readonly onlyKeys?: Iterable<string>;
90
104
  // Write-time tenant for tenantOwned fields on rows without a tenantId column.