@cosmicdrift/kumiko-framework 0.165.3 → 0.166.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.165.3",
3
+ "version": "0.166.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>",
@@ -197,10 +197,10 @@
197
197
  "zod": "^4.4.3"
198
198
  },
199
199
  "peerDependencies": {
200
- "@cosmicdrift/kumiko-types": "^0.165.3"
200
+ "@cosmicdrift/kumiko-types": "^0.166.0"
201
201
  },
202
202
  "devDependencies": {
203
- "@cosmicdrift/kumiko-dispatcher-live": "0.165.3",
203
+ "@cosmicdrift/kumiko-dispatcher-live": "0.166.0",
204
204
  "bun-types": "^1.3.13",
205
205
  "pino-pretty": "^13.1.3"
206
206
  },
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildPgKmsOptions,
4
+ type KmsWiringEnv,
5
+ requireKmsWiring,
6
+ resolveKmsWiring,
7
+ } from "../kms-wiring";
8
+
9
+ // 32 raw bytes -> base64, the shape PgKmsAdapter's decodePlatformKek demands.
10
+ const KEK_A = Buffer.alloc(32, 1).toString("base64");
11
+ const KEK_B = Buffer.alloc(32, 2).toString("base64");
12
+
13
+ const fullTrio: KmsWiringEnv = {
14
+ PLATFORM_KEK: KEK_A,
15
+ SUBJECT_KEYS_DATABASE_URL: "postgres://localhost:5432/subject_keys",
16
+ KUMIKO_BLIND_INDEX_KEY: "blind-index-key",
17
+ };
18
+
19
+ const rotationEnv = {
20
+ PLATFORM_KEK: KEK_A,
21
+ SUBJECT_KEYS_DATABASE_URL: "postgres://localhost:5432/subject_keys",
22
+ };
23
+
24
+ describe("buildPgKmsOptions", () => {
25
+ test("defaults kekVersion to 1 and omits previousKeks entirely when no rotation is active", () => {
26
+ const options = buildPgKmsOptions(rotationEnv);
27
+
28
+ expect(options.kekVersion).toBe(1);
29
+ // Absent, not `undefined` — this is what distinguishes rotation-inactive
30
+ // (the normal state of every app) from a half-configured rotation.
31
+ expect("previousKeks" in options).toBe(false);
32
+ });
33
+
34
+ test("maps the previous KEK into the version slot named by the env var", () => {
35
+ const options = buildPgKmsOptions({
36
+ ...rotationEnv,
37
+ PLATFORM_KEK_VERSION: "3",
38
+ PLATFORM_KEK_PREVIOUS: KEK_B,
39
+ PLATFORM_KEK_PREVIOUS_VERSION: "2",
40
+ });
41
+
42
+ expect(options.kekVersion).toBe(3);
43
+ expect(options.previousKeks).toEqual({ 2: KEK_B });
44
+ });
45
+
46
+ // The bug this parser exists for: every one of these used to pass through
47
+ // Number() and produce a wrong-but-silent version, which makes the rotation
48
+ // slot unreachable and its rows unreadable (phronexsis#323).
49
+ test.each([
50
+ ["1e21", "scientific notation survives Number.isInteger"],
51
+ ["0x10", "hex coerces to 16"],
52
+ ["-1", "negative"],
53
+ ["0", "zero is not a valid version"],
54
+ [" 2 ", "whitespace is trimmed by Number"],
55
+ ["2.5", "fractional"],
56
+ ["abc", "not a number at all"],
57
+ ])("rejects PLATFORM_KEK_VERSION=%p (%s)", (raw) => {
58
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_VERSION: raw })).toThrow(
59
+ /PLATFORM_KEK_VERSION must be a positive integer/,
60
+ );
61
+ });
62
+
63
+ // An empty env var is "unset", not "invalid" — `FOO=` in a .env file and an
64
+ // absent FOO are the same thing to every shell and dotenv loader. Pinned
65
+ // because it is the one falsy input that must NOT hit the strict parser.
66
+ test("treats an empty PLATFORM_KEK_VERSION as unset", () => {
67
+ expect(buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_VERSION: "" }).kekVersion).toBe(1);
68
+ });
69
+
70
+ test("rejects a bad PLATFORM_KEK_PREVIOUS_VERSION with the same strictness", () => {
71
+ expect(() =>
72
+ buildPgKmsOptions({
73
+ ...rotationEnv,
74
+ PLATFORM_KEK_VERSION: "2",
75
+ PLATFORM_KEK_PREVIOUS: KEK_B,
76
+ PLATFORM_KEK_PREVIOUS_VERSION: "1e21",
77
+ }),
78
+ ).toThrow(/PLATFORM_KEK_PREVIOUS_VERSION must be a positive integer/);
79
+ });
80
+
81
+ test("rejects PLATFORM_KEK_PREVIOUS without its version", () => {
82
+ expect(() => buildPgKmsOptions({ ...rotationEnv, PLATFORM_KEK_PREVIOUS: KEK_B })).toThrow(
83
+ /PLATFORM_KEK_PREVIOUS_VERSION must be set/,
84
+ );
85
+ });
86
+ });
87
+
88
+ describe("resolveKmsWiring", () => {
89
+ test("falls back to plaintext PII when the trio is entirely absent", () => {
90
+ const wiring = resolveKmsWiring({});
91
+
92
+ expect(wiring).toEqual({ allowPlaintextPii: "local dev without subject-keys KMS (fw#818)" });
93
+ });
94
+
95
+ // Regression: every member being optional made TypeScript's weak-type
96
+ // detection reject `process.env` ("no properties in common with
97
+ // KmsWiringEnv"), which pushed each app toward a cast or a six-key mapping.
98
+ // This compiles only while the index signature is there.
99
+ test("accepts process.env directly, with its unrelated keys", () => {
100
+ const processLike: NodeJS.ProcessEnv = { PATH: "/usr/bin", HOME: "/root" };
101
+
102
+ expect(resolveKmsWiring(processLike)).toHaveProperty("allowPlaintextPii");
103
+ });
104
+
105
+ test("carries an app-supplied fallback reason into the boot log", () => {
106
+ const wiring = resolveKmsWiring({}, { plaintextReason: "solon pre-UI gate" });
107
+
108
+ expect(wiring).toEqual({ allowPlaintextPii: "solon pre-UI gate" });
109
+ });
110
+
111
+ // All-or-none is the core of this module: a partial trio means someone
112
+ // dropped one env var and would otherwise boot with unencrypted PII.
113
+ test.each([
114
+ ["PLATFORM_KEK", { PLATFORM_KEK: KEK_A }],
115
+ ["SUBJECT_KEYS_DATABASE_URL", { SUBJECT_KEYS_DATABASE_URL: "postgres://localhost/x" }],
116
+ ["KUMIKO_BLIND_INDEX_KEY", { KUMIKO_BLIND_INDEX_KEY: "key" }],
117
+ ])("throws when only %s is set", (_name, env) => {
118
+ expect(() => resolveKmsWiring(env)).toThrow(/all-or-none/);
119
+ });
120
+
121
+ test("prefixes the all-or-none error with the app's boot-log prefix", () => {
122
+ expect(() => resolveKmsWiring({ PLATFORM_KEK: KEK_A }, { logPrefix: "[solon]" })).toThrow(
123
+ /^\[solon\] PLATFORM_KEK/,
124
+ );
125
+ });
126
+
127
+ test.each([
128
+ ["PLATFORM_KEK_PREVIOUS without version", { PLATFORM_KEK_PREVIOUS: KEK_B }],
129
+ ["version without PLATFORM_KEK_PREVIOUS", { PLATFORM_KEK_PREVIOUS_VERSION: "1" }],
130
+ ])("throws on a half-configured rotation: %s", (_name, extra) => {
131
+ expect(() => resolveKmsWiring({ ...fullTrio, ...extra })).toThrow(/must be set together/);
132
+ });
133
+
134
+ test("builds the adapter when the trio is complete", () => {
135
+ const wiring = resolveKmsWiring(fullTrio);
136
+
137
+ expect("kms" in wiring).toBe(true);
138
+ if (!("kms" in wiring)) throw new Error("unreachable");
139
+ expect(wiring.blindIndexKey).toBe("blind-index-key");
140
+ });
141
+
142
+ // Ownership check: the previous-version-must-be-older rule lives in
143
+ // PgKmsAdapter's constructor, not here. Asserting on ITS message keeps that
144
+ // documented — if the adapter ever stops enforcing it, this test breaks and
145
+ // tells the next person the rule lost its home.
146
+ test("leaves previous-version ordering to PgKmsAdapter, which rejects it", () => {
147
+ expect(() =>
148
+ resolveKmsWiring({
149
+ ...fullTrio,
150
+ PLATFORM_KEK_VERSION: "2",
151
+ PLATFORM_KEK_PREVIOUS: KEK_B,
152
+ PLATFORM_KEK_PREVIOUS_VERSION: "2",
153
+ }),
154
+ ).toThrow(/PgKmsAdapter: previousKeks\[2\] must be older than the active kekVersion 2/);
155
+ });
156
+ });
157
+
158
+ describe("requireKmsWiring", () => {
159
+ test("returns the active wiring without narrowing when the trio is complete", () => {
160
+ // No `in` check needed at the call site — that is the point of the split.
161
+ const { blindIndexKey } = requireKmsWiring(fullTrio);
162
+
163
+ expect(blindIndexKey).toBe("blind-index-key");
164
+ });
165
+
166
+ test("throws instead of falling back when the trio is absent", () => {
167
+ expect(() => requireKmsWiring({}, { logPrefix: "[money-horse]" })).toThrow(
168
+ /no plaintext-PII opt-out on this entry point/,
169
+ );
170
+ });
171
+
172
+ test("still reports a partial trio as all-or-none", () => {
173
+ expect(() => requireKmsWiring({ PLATFORM_KEK: KEK_A })).toThrow(/all-or-none/);
174
+ });
175
+ });
@@ -34,6 +34,17 @@ export {
34
34
  subjectKeyForTenant,
35
35
  subjectKeyForUser,
36
36
  } from "./kms-adapter";
37
+ export {
38
+ type ActiveKmsWiring,
39
+ buildPgKmsOptions,
40
+ type KmsWiring,
41
+ type KmsWiringEnv,
42
+ type KmsWiringOptions,
43
+ type PgKmsRotationEnv,
44
+ type PlaintextPiiWiring,
45
+ requireKmsWiring,
46
+ resolveKmsWiring,
47
+ } from "./kms-wiring";
37
48
  export {
38
49
  createPgKmsAdapter,
39
50
  PgKmsAdapter,
@@ -0,0 +1,163 @@
1
+ // Boot-time env validation for the subject-keys KMS, shared by every app that
2
+ // mounts crypto-shredding. Lived copy-pasted in four apps before fw#1617; the
3
+ // copies drifted apart in their parsers, which is the failure mode this module
4
+ // exists to end.
5
+ //
6
+ // Split in two on purpose: `buildPgKmsOptions` is pure and unit-testable
7
+ // (the rotation slot mapping is the part that silently corrupts data when it
8
+ // is wrong), `resolveKmsWiring` is the boot entry point that also constructs
9
+ // the adapter.
10
+
11
+ import { createPgKmsAdapter, type PgKmsAdapter, type PgKmsAdapterOptions } from "./pg-kms-adapter";
12
+
13
+ // The index signature is what lets callers pass `process.env` directly. Without
14
+ // it every member is optional, so TypeScript's weak-type detection rejects
15
+ // ProcessEnv for having "no properties in common" — and each app would need
16
+ // either a hand-written six-key mapping or a cast. An env map genuinely does
17
+ // carry arbitrary keys, so the signature states a fact rather than loosening
18
+ // the type for convenience.
19
+ export type KmsWiringEnv = {
20
+ readonly PLATFORM_KEK?: string | undefined;
21
+ readonly SUBJECT_KEYS_DATABASE_URL?: string | undefined;
22
+ readonly KUMIKO_BLIND_INDEX_KEY?: string | undefined;
23
+ readonly PLATFORM_KEK_VERSION?: string | undefined;
24
+ readonly PLATFORM_KEK_PREVIOUS?: string | undefined;
25
+ readonly PLATFORM_KEK_PREVIOUS_VERSION?: string | undefined;
26
+ readonly [key: string]: string | undefined;
27
+ };
28
+
29
+ /** Narrowed form for `buildPgKmsOptions` — presence of the two required
30
+ * members is the caller's job (`resolveKmsWiring` does it via the trio check). */
31
+ export type PgKmsRotationEnv = KmsWiringEnv & {
32
+ readonly PLATFORM_KEK: string;
33
+ readonly SUBJECT_KEYS_DATABASE_URL: string;
34
+ };
35
+
36
+ export type ActiveKmsWiring = {
37
+ readonly kms: PgKmsAdapter;
38
+ readonly blindIndexKey: string;
39
+ };
40
+
41
+ /** Plaintext fallback carries its reason so the boot log says WHY PII is
42
+ * unencrypted — an app running like this by accident is a reportable breach. */
43
+ export type PlaintextPiiWiring = { readonly allowPlaintextPii: string };
44
+
45
+ export type KmsWiring = ActiveKmsWiring | PlaintextPiiWiring;
46
+
47
+ export type KmsWiringOptions = {
48
+ /** Prefixes the all-or-none error. Use the app's existing boot-log prefix —
49
+ * people grep for it in production logs. */
50
+ readonly logPrefix?: string;
51
+ /** Reason recorded when the trio is absent and plaintext PII is accepted. */
52
+ readonly plaintextReason?: string;
53
+ };
54
+
55
+ const DEFAULT_PLAINTEXT_REASON = "local dev without subject-keys KMS (fw#818)";
56
+
57
+ // Number(raw) + Number.isInteger is the wrong parser for an env string:
58
+ // Number.isInteger(1e21) is true, so "1e21" passes, and the value then
59
+ // stringifies back to "1e+21" as a previousKeks object key — a lookup against
60
+ // the integer kek_version column never hits that slot, silently leaving
61
+ // rotation-window rows unreadable. Same class: "0x10" -> 16, " 2 " trimmed,
62
+ // "-1"/"0" accepted as versions, values past MAX_SAFE_INTEGER rounded.
63
+ // A digits-only regex closes all of these at once (phronexsis#323).
64
+ function parseKekVersion(raw: string, fieldName: string): number {
65
+ if (!/^[0-9]+$/.test(raw)) {
66
+ throw new Error(`${fieldName} must be a positive integer, got "${raw}".`);
67
+ }
68
+ const version = Number(raw);
69
+ if (!Number.isSafeInteger(version) || version < 1) {
70
+ throw new Error(`${fieldName} must be a positive integer, got "${raw}".`);
71
+ }
72
+ return version;
73
+ }
74
+
75
+ /** Maps the KEK env vars onto adapter options, including which version slot an
76
+ * old KEK lands in during a rotation window.
77
+ *
78
+ * Does NOT check that the previous version is older than the active one —
79
+ * `PgKmsAdapter`'s constructor already rejects `previousKeks[v] >= kekVersion`
80
+ * and stays the single source of that rule. */
81
+ export function buildPgKmsOptions(env: PgKmsRotationEnv): PgKmsAdapterOptions {
82
+ const kekVersion = env.PLATFORM_KEK_VERSION
83
+ ? parseKekVersion(env.PLATFORM_KEK_VERSION, "PLATFORM_KEK_VERSION")
84
+ : 1;
85
+ if (!env.PLATFORM_KEK_PREVIOUS) {
86
+ return {
87
+ databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
88
+ platformKek: env.PLATFORM_KEK,
89
+ kekVersion,
90
+ };
91
+ }
92
+ if (!env.PLATFORM_KEK_PREVIOUS_VERSION) {
93
+ throw new Error("PLATFORM_KEK_PREVIOUS_VERSION must be set when PLATFORM_KEK_PREVIOUS is set.");
94
+ }
95
+ const previousVersion = parseKekVersion(
96
+ env.PLATFORM_KEK_PREVIOUS_VERSION,
97
+ "PLATFORM_KEK_PREVIOUS_VERSION",
98
+ );
99
+ return {
100
+ databaseUrl: env.SUBJECT_KEYS_DATABASE_URL,
101
+ platformKek: env.PLATFORM_KEK,
102
+ kekVersion,
103
+ previousKeks: { [previousVersion]: env.PLATFORM_KEK_PREVIOUS },
104
+ };
105
+ }
106
+
107
+ function assertTrioConsistent(env: KmsWiringEnv, logPrefix: string | undefined): boolean {
108
+ const trio = [env.PLATFORM_KEK, env.SUBJECT_KEYS_DATABASE_URL, env.KUMIKO_BLIND_INDEX_KEY];
109
+ const complete = trio.every(Boolean);
110
+ if (!complete && trio.some(Boolean)) {
111
+ throw new Error(
112
+ `${logPrefix ? `${logPrefix} ` : ""}PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / ` +
113
+ "KUMIKO_BLIND_INDEX_KEY are all-or-none — a partial set means the KMS wiring is broken.",
114
+ );
115
+ }
116
+ if (Boolean(env.PLATFORM_KEK_PREVIOUS) !== Boolean(env.PLATFORM_KEK_PREVIOUS_VERSION)) {
117
+ throw new Error(
118
+ "PLATFORM_KEK_PREVIOUS and PLATFORM_KEK_PREVIOUS_VERSION must be set together " +
119
+ "(KEK rotation, runbook kek-rotation.md).",
120
+ );
121
+ }
122
+ return complete;
123
+ }
124
+
125
+ /** Boot wiring for apps that may run without a KMS (dev, local): complete trio
126
+ * yields the adapter, empty trio yields the plaintext fallback, partial throws.
127
+ *
128
+ * Prod entry points that must never fall back: use `requireKmsWiring`. */
129
+ export function resolveKmsWiring(env: KmsWiringEnv, options: KmsWiringOptions = {}): KmsWiring {
130
+ const complete = assertTrioConsistent(env, options.logPrefix);
131
+ if (complete && env.PLATFORM_KEK && env.SUBJECT_KEYS_DATABASE_URL && env.KUMIKO_BLIND_INDEX_KEY) {
132
+ return {
133
+ kms: createPgKmsAdapter(
134
+ buildPgKmsOptions({
135
+ ...env,
136
+ PLATFORM_KEK: env.PLATFORM_KEK,
137
+ SUBJECT_KEYS_DATABASE_URL: env.SUBJECT_KEYS_DATABASE_URL,
138
+ }),
139
+ ),
140
+ blindIndexKey: env.KUMIKO_BLIND_INDEX_KEY,
141
+ };
142
+ }
143
+ return { allowPlaintextPii: options.plaintextReason ?? DEFAULT_PLAINTEXT_REASON };
144
+ }
145
+
146
+ /** Same as `resolveKmsWiring` but the trio is mandatory — an absent trio throws
147
+ * instead of silently accepting plaintext PII. Separate function rather than a
148
+ * flag so the return type is the active wiring, with no narrowing at the call
149
+ * site. */
150
+ export function requireKmsWiring(
151
+ env: KmsWiringEnv,
152
+ options: KmsWiringOptions = {},
153
+ ): ActiveKmsWiring {
154
+ const wiring = resolveKmsWiring(env, options);
155
+ if ("allowPlaintextPii" in wiring) {
156
+ throw new Error(
157
+ `${options.logPrefix ? `${options.logPrefix} ` : ""}PLATFORM_KEK / ` +
158
+ "SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY are required here — " +
159
+ "no plaintext-PII opt-out on this entry point.",
160
+ );
161
+ }
162
+ return wiring;
163
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { derivePurposeSecret } from "../derive-purpose-secret";
3
+
4
+ const MASTER = "master-secret-for-tests-32-bytes-min";
5
+
6
+ describe("derivePurposeSecret", () => {
7
+ test("is deterministic — same master and purpose give the same secret", () => {
8
+ expect(derivePurposeSecret(MASTER, "mfa-setup-token-v1")).toBe(
9
+ derivePurposeSecret(MASTER, "mfa-setup-token-v1"),
10
+ );
11
+ });
12
+
13
+ // The whole point: two purposes must not share a key, or a token minted for
14
+ // one boundary verifies at another.
15
+ test("separates purposes", () => {
16
+ expect(derivePurposeSecret(MASTER, "mfa-setup-token-v1")).not.toBe(
17
+ derivePurposeSecret(MASTER, "mfa-challenge-token-v1"),
18
+ );
19
+ });
20
+
21
+ test("versioning a purpose yields a different secret, so rotation is per-purpose", () => {
22
+ expect(derivePurposeSecret(MASTER, "deletion-token-v1")).not.toBe(
23
+ derivePurposeSecret(MASTER, "deletion-token-v2"),
24
+ );
25
+ });
26
+
27
+ test("rotating the master rotates every derived secret", () => {
28
+ expect(derivePurposeSecret(MASTER, "deletion-token-v1")).not.toBe(
29
+ derivePurposeSecret(`${MASTER}-rotated`, "deletion-token-v1"),
30
+ );
31
+ });
32
+
33
+ test("never returns the master itself", () => {
34
+ const derived = derivePurposeSecret(MASTER, "mfa-setup-token-v1");
35
+
36
+ expect(derived).not.toBe(MASTER);
37
+ expect(derived).not.toContain(MASTER);
38
+ });
39
+
40
+ test("returns 32 bytes as hex", () => {
41
+ expect(derivePurposeSecret(MASTER, "any-purpose-v1")).toMatch(/^[0-9a-f]{64}$/);
42
+ });
43
+
44
+ // An empty purpose would silently collapse every boundary onto one key —
45
+ // the failure mode this function exists to prevent, so it must be loud.
46
+ test.each([
47
+ ["empty master", "", "a-purpose"],
48
+ ["empty purpose", MASTER, ""],
49
+ ])("throws on %s", (_name, master, purpose) => {
50
+ expect(() => derivePurposeSecret(master, purpose)).toThrow(/must not be empty/);
51
+ });
52
+ });
@@ -0,0 +1,27 @@
1
+ import { hkdfSync } from "node:crypto";
2
+
3
+ // One master secret, many purposes: HKDF turns `JWT_SECRET` into an
4
+ // independent secret per trust boundary, so a token-signing key for MFA setup
5
+ // cannot be used to forge a deletion token — and neither can be walked back to
6
+ // the master. Rotating the master rotates every purpose with it.
7
+ //
8
+ // The alternative is an env var per purpose. That is not more secure (same
9
+ // blast radius if the deploy is compromised), it is just more operations, and
10
+ // in practice one of them ends up unset in some environment.
11
+ //
12
+ // The purpose string is a domain separator and part of the contract: change it
13
+ // and every previously issued token for that purpose stops verifying. Version
14
+ // them ("mfa-setup-token-v1") so a single purpose can be rotated deliberately
15
+ // without touching the master or the other purposes.
16
+ //
17
+ // Lived copy-pasted in four apps before fw#1623 (money-horse, kumiko-studio,
18
+ // publicstatus, plus a stale worktree) — identical bodies, drifting comments.
19
+ export function derivePurposeSecret(masterSecret: string, purpose: string): string {
20
+ if (!masterSecret) {
21
+ throw new Error("derivePurposeSecret: masterSecret must not be empty.");
22
+ }
23
+ if (!purpose) {
24
+ throw new Error("derivePurposeSecret: purpose must not be empty — it is the domain separator.");
25
+ }
26
+ return Buffer.from(hkdfSync("sha256", masterSecret, "", purpose, 32)).toString("hex");
27
+ }
@@ -1,4 +1,5 @@
1
1
  export { createDekCache, type DekCache, type DekCacheOptions, withDekCache } from "./dek-cache";
2
+ export { derivePurposeSecret } from "./derive-purpose-secret";
2
3
  export {
3
4
  createEnvMasterKeyProvider,
4
5
  type EnvMasterKeyProviderOptions,