@cosmicdrift/kumiko-framework 0.116.0 → 0.118.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 +6 -2
- package/src/crypto/__tests__/kms-adapter-contract.ts +134 -0
- package/src/crypto/__tests__/kms-adapter.contract.test.ts +4 -0
- package/src/crypto/__tests__/request-kms-cache.test.ts +74 -0
- package/src/crypto/__tests__/subject-resolver.test.ts +108 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +28 -0
- package/src/crypto/kms-adapter.ts +110 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/engine/types/handlers.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.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>",
|
|
@@ -131,6 +131,10 @@
|
|
|
131
131
|
"types": "./src/secrets/index.ts",
|
|
132
132
|
"default": "./src/secrets/index.ts"
|
|
133
133
|
},
|
|
134
|
+
"./crypto": {
|
|
135
|
+
"types": "./src/crypto/index.ts",
|
|
136
|
+
"default": "./src/crypto/index.ts"
|
|
137
|
+
},
|
|
134
138
|
"./schema-cli": {
|
|
135
139
|
"types": "./src/schema-cli.ts",
|
|
136
140
|
"default": "./src/schema-cli.ts"
|
|
@@ -181,7 +185,7 @@
|
|
|
181
185
|
"zod": "^4.4.3"
|
|
182
186
|
},
|
|
183
187
|
"devDependencies": {
|
|
184
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
188
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.118.0",
|
|
185
189
|
"bun-types": "^1.3.13",
|
|
186
190
|
"pino-pretty": "^13.1.3"
|
|
187
191
|
},
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isLocalKeyKmsAdapter,
|
|
4
|
+
KeyAlreadyExistsError,
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
type KmsAdapter,
|
|
8
|
+
type KmsContext,
|
|
9
|
+
type SubjectId,
|
|
10
|
+
} from "../kms-adapter";
|
|
11
|
+
|
|
12
|
+
const ctx: KmsContext = { requestId: "contract-test" };
|
|
13
|
+
|
|
14
|
+
const userA: SubjectId = { kind: "user", userId: "6b2f4a0e-1c9d-4f3a-9d2e-000000000001" };
|
|
15
|
+
const userB: SubjectId = { kind: "user", userId: "6b2f4a0e-1c9d-4f3a-9d2e-000000000002" };
|
|
16
|
+
const tenantWithUserAId: SubjectId = {
|
|
17
|
+
kind: "tenant",
|
|
18
|
+
tenantId: "6b2f4a0e-1c9d-4f3a-9d2e-000000000001",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function subjectDek(adapter: KmsAdapter, subject: SubjectId): Promise<Buffer> {
|
|
22
|
+
if (isLocalKeyKmsAdapter(adapter)) return adapter.getKey(subject, ctx);
|
|
23
|
+
// remote-crypto adapters never release keys — probe via roundtrip instead.
|
|
24
|
+
const probe = Buffer.from("contract-probe");
|
|
25
|
+
const encrypted = await adapter.encrypt(subject, probe, ctx);
|
|
26
|
+
return Buffer.from(await adapter.decrypt(subject, encrypted, ctx));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function describeKmsAdapterContract(
|
|
30
|
+
name: string,
|
|
31
|
+
factory: () => KmsAdapter | Promise<KmsAdapter>,
|
|
32
|
+
): void {
|
|
33
|
+
describe(`${name} — KmsAdapter contract`, () => {
|
|
34
|
+
let adapter: KmsAdapter;
|
|
35
|
+
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
adapter = await factory();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("createKey then getKey returns a 32-byte DEK", async () => {
|
|
41
|
+
await adapter.createKey(userA, ctx);
|
|
42
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
43
|
+
const dek = await adapter.getKey(userA, ctx);
|
|
44
|
+
expect(dek.length).toBe(32);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("getKey is stable — same subject, same key", async () => {
|
|
48
|
+
await adapter.createKey(userA, ctx);
|
|
49
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
50
|
+
const first = await adapter.getKey(userA, ctx);
|
|
51
|
+
const second = await adapter.getKey(userA, ctx);
|
|
52
|
+
expect(first.equals(second)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("distinct subjects get distinct keys", async () => {
|
|
56
|
+
await adapter.createKey(userA, ctx);
|
|
57
|
+
await adapter.createKey(userB, ctx);
|
|
58
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
59
|
+
const a = await adapter.getKey(userA, ctx);
|
|
60
|
+
const b = await adapter.getKey(userB, ctx);
|
|
61
|
+
expect(a.equals(b)).toBe(false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("user and tenant subjects with the same raw id are distinct", async () => {
|
|
65
|
+
await adapter.createKey(userA, ctx);
|
|
66
|
+
await adapter.createKey(tenantWithUserAId, ctx);
|
|
67
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
68
|
+
const user = await adapter.getKey(userA, ctx);
|
|
69
|
+
const tenant = await adapter.getKey(tenantWithUserAId, ctx);
|
|
70
|
+
expect(user.equals(tenant)).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("createKey twice throws KeyAlreadyExistsError", async () => {
|
|
74
|
+
await adapter.createKey(userA, ctx);
|
|
75
|
+
await expect(adapter.createKey(userA, ctx)).rejects.toBeInstanceOf(KeyAlreadyExistsError);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("getKey for an unknown subject throws KeyNotFoundError", async () => {
|
|
79
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
80
|
+
await expect(adapter.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyNotFoundError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("eraseKey makes getKey throw KeyErasedError", async () => {
|
|
84
|
+
await adapter.createKey(userA, ctx);
|
|
85
|
+
await adapter.eraseKey(userA, ctx);
|
|
86
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
87
|
+
await expect(adapter.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyErasedError);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("eraseKey is idempotent — second call is a no-op", async () => {
|
|
91
|
+
await adapter.createKey(userA, ctx);
|
|
92
|
+
await adapter.eraseKey(userA, ctx);
|
|
93
|
+
await adapter.eraseKey(userA, ctx);
|
|
94
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
95
|
+
await expect(adapter.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyErasedError);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("eraseKey for an unknown subject is a no-op", async () => {
|
|
99
|
+
await adapter.eraseKey(userA, ctx);
|
|
100
|
+
if (!isLocalKeyKmsAdapter(adapter)) return;
|
|
101
|
+
await expect(adapter.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyNotFoundError);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("createKey after eraseKey throws — the tombstone blocks a new key", async () => {
|
|
105
|
+
await adapter.createKey(userA, ctx);
|
|
106
|
+
await adapter.eraseKey(userA, ctx);
|
|
107
|
+
await expect(adapter.createKey(userA, ctx)).rejects.toBeInstanceOf(KeyAlreadyExistsError);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("erasing one subject leaves other subjects intact", async () => {
|
|
111
|
+
await adapter.createKey(userA, ctx);
|
|
112
|
+
await adapter.createKey(userB, ctx);
|
|
113
|
+
await adapter.eraseKey(userA, ctx);
|
|
114
|
+
const dek = await subjectDek(adapter, userB);
|
|
115
|
+
expect(dek.length).toBeGreaterThan(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("remote-crypto adapters roundtrip encrypt/decrypt", async () => {
|
|
119
|
+
if (isLocalKeyKmsAdapter(adapter)) return;
|
|
120
|
+
await adapter.createKey(userA, ctx);
|
|
121
|
+
const plaintext = Buffer.from("marc@example.com");
|
|
122
|
+
const ciphertext = await adapter.encrypt(userA, plaintext, ctx);
|
|
123
|
+
expect(Buffer.from(ciphertext).equals(plaintext)).toBe(false);
|
|
124
|
+
const roundtrip = await adapter.decrypt(userA, ciphertext, ctx);
|
|
125
|
+
expect(Buffer.from(roundtrip).equals(plaintext)).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("health reports ok for a reachable adapter", async () => {
|
|
129
|
+
const health = await adapter.health();
|
|
130
|
+
expect(health.ok).toBe(true);
|
|
131
|
+
expect(health.latencyMs).toBeGreaterThanOrEqual(0);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { InMemoryKmsAdapter } from "../in-memory-kms-adapter";
|
|
3
|
+
import {
|
|
4
|
+
KeyErasedError,
|
|
5
|
+
type KmsContext,
|
|
6
|
+
type LocalKeyKmsAdapter,
|
|
7
|
+
type SubjectId,
|
|
8
|
+
} from "../kms-adapter";
|
|
9
|
+
import { createRequestKmsCache } from "../request-kms-cache";
|
|
10
|
+
|
|
11
|
+
const ctx: KmsContext = { requestId: "cache-test" };
|
|
12
|
+
const userA: SubjectId = { kind: "user", userId: "6b2f4a0e-1c9d-4f3a-9d2e-0000000000aa" };
|
|
13
|
+
|
|
14
|
+
function countingAdapter(): { adapter: LocalKeyKmsAdapter; calls: () => number } {
|
|
15
|
+
const inner = new InMemoryKmsAdapter();
|
|
16
|
+
let getKeyCalls = 0;
|
|
17
|
+
const adapter: LocalKeyKmsAdapter = {
|
|
18
|
+
capabilities: { mode: "local-key" },
|
|
19
|
+
createKey: (subject) => inner.createKey(subject),
|
|
20
|
+
eraseKey: (subject) => inner.eraseKey(subject),
|
|
21
|
+
health: () => inner.health(),
|
|
22
|
+
getKey: (subject) => {
|
|
23
|
+
getKeyCalls++;
|
|
24
|
+
return inner.getKey(subject);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
return { adapter, calls: () => getKeyCalls };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("createRequestKmsCache", () => {
|
|
31
|
+
test("second getKey for the same subject is served from cache", async () => {
|
|
32
|
+
const { adapter, calls } = countingAdapter();
|
|
33
|
+
await adapter.createKey(userA, ctx);
|
|
34
|
+
const cache = createRequestKmsCache(adapter);
|
|
35
|
+
|
|
36
|
+
const first = await cache.getKey(userA, ctx);
|
|
37
|
+
const second = await cache.getKey(userA, ctx);
|
|
38
|
+
expect(first.equals(second)).toBe(true);
|
|
39
|
+
expect(calls()).toBe(1);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("invalidate drops the entry — next getKey hits the adapter again", async () => {
|
|
43
|
+
const { adapter, calls } = countingAdapter();
|
|
44
|
+
await adapter.createKey(userA, ctx);
|
|
45
|
+
const cache = createRequestKmsCache(adapter);
|
|
46
|
+
|
|
47
|
+
await cache.getKey(userA, ctx);
|
|
48
|
+
cache.invalidate(userA);
|
|
49
|
+
await cache.getKey(userA, ctx);
|
|
50
|
+
expect(calls()).toBe(2);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("erase + invalidate surfaces KeyErasedError instead of a stale DEK", async () => {
|
|
54
|
+
const { adapter } = countingAdapter();
|
|
55
|
+
await adapter.createKey(userA, ctx);
|
|
56
|
+
const cache = createRequestKmsCache(adapter);
|
|
57
|
+
|
|
58
|
+
await cache.getKey(userA, ctx);
|
|
59
|
+
await adapter.eraseKey(userA, ctx);
|
|
60
|
+
cache.invalidate(userA);
|
|
61
|
+
await expect(cache.getKey(userA, ctx)).rejects.toBeInstanceOf(KeyErasedError);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("adapter errors are not cached — a failed lookup retries", async () => {
|
|
65
|
+
const { adapter, calls } = countingAdapter();
|
|
66
|
+
const cache = createRequestKmsCache(adapter);
|
|
67
|
+
|
|
68
|
+
await expect(cache.getKey(userA, ctx)).rejects.toThrow();
|
|
69
|
+
await adapter.createKey(userA, ctx);
|
|
70
|
+
const dek = await cache.getKey(userA, ctx);
|
|
71
|
+
expect(dek.length).toBe(32);
|
|
72
|
+
expect(calls()).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createEntity, createTextField } from "../../engine/factories";
|
|
3
|
+
import {
|
|
4
|
+
collectPiiSubjectFields,
|
|
5
|
+
resolveSubjectForField,
|
|
6
|
+
SubjectResolutionError,
|
|
7
|
+
} from "../subject-resolver";
|
|
8
|
+
|
|
9
|
+
const userLikeEntity = createEntity({
|
|
10
|
+
fields: {
|
|
11
|
+
email: createTextField({ required: true, pii: true }),
|
|
12
|
+
role: createTextField(),
|
|
13
|
+
},
|
|
14
|
+
table: "resolver_users",
|
|
15
|
+
idType: "uuid",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const commentEntity = createEntity({
|
|
19
|
+
fields: {
|
|
20
|
+
body: createTextField({ userOwned: { ownerField: "authorId" } }),
|
|
21
|
+
authorId: createTextField({ required: true }),
|
|
22
|
+
},
|
|
23
|
+
table: "resolver_comments",
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const brandingEntity = createEntity({
|
|
27
|
+
fields: {
|
|
28
|
+
brandColor: createTextField({ tenantOwned: true }),
|
|
29
|
+
},
|
|
30
|
+
table: "resolver_branding",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
34
|
+
const UUID_B = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000b";
|
|
35
|
+
|
|
36
|
+
describe("resolveSubjectForField", () => {
|
|
37
|
+
test("pii: true → the entity row itself is the user subject", () => {
|
|
38
|
+
const subject = resolveSubjectForField(userLikeEntity, "email", { id: UUID_A });
|
|
39
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_A });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("pii self-subject stringifies serial ids", () => {
|
|
43
|
+
const subject = resolveSubjectForField(userLikeEntity, "email", { id: 42 });
|
|
44
|
+
expect(subject).toEqual({ kind: "user", userId: "42" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("userOwned → subject comes from the owner reference field", () => {
|
|
48
|
+
const subject = resolveSubjectForField(commentEntity, "body", {
|
|
49
|
+
id: UUID_A,
|
|
50
|
+
authorId: UUID_B,
|
|
51
|
+
});
|
|
52
|
+
expect(subject).toEqual({ kind: "user", userId: UUID_B });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("tenantOwned → subject from the row's tenantId column", () => {
|
|
56
|
+
const subject = resolveSubjectForField(brandingEntity, "brandColor", {
|
|
57
|
+
id: UUID_A,
|
|
58
|
+
tenantId: UUID_B,
|
|
59
|
+
});
|
|
60
|
+
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("tenantOwned falls back to the write-time tenantId option", () => {
|
|
64
|
+
const subject = resolveSubjectForField(
|
|
65
|
+
brandingEntity,
|
|
66
|
+
"brandColor",
|
|
67
|
+
{ id: UUID_A },
|
|
68
|
+
{ tenantId: UUID_B },
|
|
69
|
+
);
|
|
70
|
+
expect(subject).toEqual({ kind: "tenant", tenantId: UUID_B });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("unannotated field → null (stays plaintext)", () => {
|
|
74
|
+
expect(resolveSubjectForField(userLikeEntity, "role", { id: UUID_A })).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("empty owner reference throws instead of silently falling back to plaintext", () => {
|
|
78
|
+
expect(() => resolveSubjectForField(commentEntity, "body", { id: UUID_A })).toThrow(
|
|
79
|
+
SubjectResolutionError,
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("tenantOwned without any tenant scope throws", () => {
|
|
84
|
+
expect(() => resolveSubjectForField(brandingEntity, "brandColor", { id: UUID_A })).toThrow(
|
|
85
|
+
SubjectResolutionError,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("pii row without id throws", () => {
|
|
90
|
+
expect(() => resolveSubjectForField(userLikeEntity, "email", {})).toThrow(
|
|
91
|
+
SubjectResolutionError,
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("unknown field name throws", () => {
|
|
96
|
+
expect(() => resolveSubjectForField(userLikeEntity, "nope", { id: UUID_A })).toThrow(
|
|
97
|
+
SubjectResolutionError,
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("collectPiiSubjectFields", () => {
|
|
103
|
+
test("collects exactly the annotated fields", () => {
|
|
104
|
+
expect(collectPiiSubjectFields(userLikeEntity)).toEqual(["email"]);
|
|
105
|
+
expect(collectPiiSubjectFields(commentEntity)).toEqual(["body"]);
|
|
106
|
+
expect(collectPiiSubjectFields(brandingEntity)).toEqual(["brandColor"]);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
KeyAlreadyExistsError,
|
|
4
|
+
KeyErasedError,
|
|
5
|
+
KeyNotFoundError,
|
|
6
|
+
type KmsHealth,
|
|
7
|
+
type LocalKeyKmsAdapter,
|
|
8
|
+
type SubjectDek,
|
|
9
|
+
type SubjectId,
|
|
10
|
+
type SubjectKey,
|
|
11
|
+
subjectIdToKey,
|
|
12
|
+
} from "./kms-adapter";
|
|
13
|
+
|
|
14
|
+
interface KeyEntry {
|
|
15
|
+
key: Buffer | null;
|
|
16
|
+
erased: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Non-persistent adapter for tests and dev mode. Erased entries stay as
|
|
20
|
+
// tombstones so the create-after-erase contract holds within a process.
|
|
21
|
+
export class InMemoryKmsAdapter implements LocalKeyKmsAdapter {
|
|
22
|
+
readonly capabilities = { mode: "local-key" } as const;
|
|
23
|
+
|
|
24
|
+
private readonly keys = new Map<SubjectKey, KeyEntry>();
|
|
25
|
+
|
|
26
|
+
async createKey(subject: SubjectId): Promise<void> {
|
|
27
|
+
const subjectKey = subjectIdToKey(subject);
|
|
28
|
+
if (this.keys.has(subjectKey)) throw new KeyAlreadyExistsError(subject);
|
|
29
|
+
this.keys.set(subjectKey, { key: randomBytes(32), erased: false });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async getKey(subject: SubjectId): Promise<SubjectDek> {
|
|
33
|
+
const entry = this.keys.get(subjectIdToKey(subject));
|
|
34
|
+
if (!entry) throw new KeyNotFoundError(subject);
|
|
35
|
+
if (entry.erased || entry.key === null) throw new KeyErasedError(subject);
|
|
36
|
+
return entry.key;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async eraseKey(subject: SubjectId): Promise<void> {
|
|
40
|
+
const entry = this.keys.get(subjectIdToKey(subject));
|
|
41
|
+
// skip: eraseKey is contractually idempotent — unknown subject is a no-op
|
|
42
|
+
if (!entry) return;
|
|
43
|
+
entry.key = null;
|
|
44
|
+
entry.erased = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async health(): Promise<KmsHealth> {
|
|
48
|
+
return { ok: true, latencyMs: 0 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
2
|
+
export {
|
|
3
|
+
isLocalKeyKmsAdapter,
|
|
4
|
+
KeyAlreadyExistsError,
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
type KmsAdapter,
|
|
8
|
+
type KmsContext,
|
|
9
|
+
type KmsHealth,
|
|
10
|
+
type LocalKeyKmsAdapter,
|
|
11
|
+
type RemoteCryptoKmsAdapter,
|
|
12
|
+
type SubjectDek,
|
|
13
|
+
type SubjectId,
|
|
14
|
+
type SubjectKey,
|
|
15
|
+
subjectIdToKey,
|
|
16
|
+
subjectKeyForTenant,
|
|
17
|
+
subjectKeyForUser,
|
|
18
|
+
} from "./kms-adapter";
|
|
19
|
+
export {
|
|
20
|
+
createRequestKmsCache,
|
|
21
|
+
type RequestKmsCache,
|
|
22
|
+
} from "./request-kms-cache";
|
|
23
|
+
export {
|
|
24
|
+
collectPiiSubjectFields,
|
|
25
|
+
type ResolveSubjectOptions,
|
|
26
|
+
resolveSubjectForField,
|
|
27
|
+
SubjectResolutionError,
|
|
28
|
+
} from "./subject-resolver";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
2
|
+
|
|
3
|
+
// The subject a DEK belongs to. User data is shredded on user-forget,
|
|
4
|
+
// tenant data on tenant-destroy — two erase triggers, two subject kinds.
|
|
5
|
+
export type SubjectId =
|
|
6
|
+
| { readonly kind: "user"; readonly userId: string }
|
|
7
|
+
| { readonly kind: "tenant"; readonly tenantId: TenantId };
|
|
8
|
+
|
|
9
|
+
// Compact storage key ("user:<uuid>" / "tenant:<uuid>") — primary key in
|
|
10
|
+
// adapter backends and cache key in the request-level DEK cache.
|
|
11
|
+
export type SubjectKey = string;
|
|
12
|
+
|
|
13
|
+
export function subjectKeyForUser(userId: string): SubjectKey {
|
|
14
|
+
return `user:${userId}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function subjectKeyForTenant(tenantId: TenantId): SubjectKey {
|
|
18
|
+
return `tenant:${tenantId}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function subjectIdToKey(subject: SubjectId): SubjectKey {
|
|
22
|
+
return subject.kind === "user"
|
|
23
|
+
? subjectKeyForUser(subject.userId)
|
|
24
|
+
: subjectKeyForTenant(subject.tenantId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KmsContext {
|
|
28
|
+
readonly tenantId?: TenantId;
|
|
29
|
+
readonly requestId: string;
|
|
30
|
+
readonly userId?: string;
|
|
31
|
+
readonly eraseReason?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface KmsHealth {
|
|
35
|
+
readonly ok: boolean;
|
|
36
|
+
readonly latencyMs: number;
|
|
37
|
+
readonly details?: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 32-byte AES-256 data-encryption key, unwrapped and ready for local use.
|
|
41
|
+
export type SubjectDek = Buffer;
|
|
42
|
+
|
|
43
|
+
interface KmsAdapterBase {
|
|
44
|
+
/**
|
|
45
|
+
* Creates a fresh subject key. Throws KeyAlreadyExistsError when the
|
|
46
|
+
* subject already has one — including an erased tombstone: a shredded
|
|
47
|
+
* subject must never get a new key, or forget could be undone by
|
|
48
|
+
* re-encrypting under it.
|
|
49
|
+
*/
|
|
50
|
+
createKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Erases the key material immediately; the tombstone row stays for the
|
|
54
|
+
* audit trail. Idempotent — repeat calls and unknown subjects are no-ops.
|
|
55
|
+
*/
|
|
56
|
+
eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
57
|
+
|
|
58
|
+
/** Probe for boot + readiness. Throws when the backend is unreachable. */
|
|
59
|
+
health(): Promise<KmsHealth>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Backends that hand out the plaintext DEK (Pg, InMemory). Encrypt/decrypt
|
|
63
|
+
// happens locally; DEKs are cacheable per request.
|
|
64
|
+
export interface LocalKeyKmsAdapter extends KmsAdapterBase {
|
|
65
|
+
readonly capabilities: { readonly mode: "local-key" };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Throws KeyErasedError after eraseKey (callers render "[[erased]]"),
|
|
69
|
+
* KeyNotFoundError when the subject never had a key (typically a bug).
|
|
70
|
+
*/
|
|
71
|
+
getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Backends that never release key material (Vault transit, cloud KMS).
|
|
75
|
+
// Every encrypt/decrypt is a round-trip; nothing is cacheable.
|
|
76
|
+
export interface RemoteCryptoKmsAdapter extends KmsAdapterBase {
|
|
77
|
+
readonly capabilities: { readonly mode: "remote-crypto" };
|
|
78
|
+
|
|
79
|
+
encrypt(subject: SubjectId, plaintext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
80
|
+
|
|
81
|
+
/** Same error contract as LocalKeyKmsAdapter.getKey. */
|
|
82
|
+
decrypt(subject: SubjectId, ciphertext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type KmsAdapter = LocalKeyKmsAdapter | RemoteCryptoKmsAdapter;
|
|
86
|
+
|
|
87
|
+
export function isLocalKeyKmsAdapter(adapter: KmsAdapter): adapter is LocalKeyKmsAdapter {
|
|
88
|
+
return adapter.capabilities.mode === "local-key";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class KeyErasedError extends Error {
|
|
92
|
+
constructor(public readonly subject: SubjectId) {
|
|
93
|
+
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|
|
94
|
+
this.name = "KeyErasedError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class KeyNotFoundError extends Error {
|
|
99
|
+
constructor(public readonly subject: SubjectId) {
|
|
100
|
+
super(`Subject key not found: ${subjectIdToKey(subject)}`);
|
|
101
|
+
this.name = "KeyNotFoundError";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class KeyAlreadyExistsError extends Error {
|
|
106
|
+
constructor(public readonly subject: SubjectId) {
|
|
107
|
+
super(`Subject key already exists: ${subjectIdToKey(subject)}`);
|
|
108
|
+
this.name = "KeyAlreadyExistsError";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
KmsContext,
|
|
3
|
+
LocalKeyKmsAdapter,
|
|
4
|
+
SubjectDek,
|
|
5
|
+
SubjectId,
|
|
6
|
+
SubjectKey,
|
|
7
|
+
} from "./kms-adapter";
|
|
8
|
+
import { subjectIdToKey } from "./kms-adapter";
|
|
9
|
+
|
|
10
|
+
// Per-request DEK cache: a list rendering 50 comments of one author does one
|
|
11
|
+
// adapter round-trip, not 50. Only meaningful for local-key adapters —
|
|
12
|
+
// remote-crypto backends never release keys, every call is a round-trip.
|
|
13
|
+
export interface RequestKmsCache {
|
|
14
|
+
getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
|
|
15
|
+
/** Drops one subject's cached DEK — called when its key is shredded mid-request. */
|
|
16
|
+
invalidate(subject: SubjectId): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createRequestKmsCache(adapter: LocalKeyKmsAdapter): RequestKmsCache {
|
|
21
|
+
const cache = new Map<SubjectKey, SubjectDek>();
|
|
22
|
+
return {
|
|
23
|
+
async getKey(subject, ctx) {
|
|
24
|
+
const subjectKey = subjectIdToKey(subject);
|
|
25
|
+
const hit = cache.get(subjectKey);
|
|
26
|
+
if (hit !== undefined) return hit;
|
|
27
|
+
const dek = await adapter.getKey(subject, ctx);
|
|
28
|
+
cache.set(subjectKey, dek);
|
|
29
|
+
return dek;
|
|
30
|
+
},
|
|
31
|
+
invalidate(subject) {
|
|
32
|
+
cache.delete(subjectIdToKey(subject));
|
|
33
|
+
},
|
|
34
|
+
clear() {
|
|
35
|
+
cache.clear();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
2
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
3
|
+
import type { SubjectId } from "./kms-adapter";
|
|
4
|
+
|
|
5
|
+
// Thrown when a field IS pii-annotated but the row can't name its subject —
|
|
6
|
+
// that must surface as an error, not fall back to plaintext.
|
|
7
|
+
export class SubjectResolutionError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly fieldName: string,
|
|
10
|
+
reason: string,
|
|
11
|
+
) {
|
|
12
|
+
super(`Cannot resolve PII subject for field "${fieldName}": ${reason}`);
|
|
13
|
+
this.name = "SubjectResolutionError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolveSubjectOptions {
|
|
18
|
+
// Write-time tenant scope — consulted for tenantOwned fields when the row
|
|
19
|
+
// itself carries no tenantId column.
|
|
20
|
+
readonly tenantId?: TenantId;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nonEmptyString(value: unknown): string | null {
|
|
24
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Maps a pii-annotated field to the subject whose key encrypts it.
|
|
29
|
+
* Returns null for fields without any PII annotation (stored plaintext).
|
|
30
|
+
*
|
|
31
|
+
* Precedence for multi-annotated fields mirrors the erase triggers:
|
|
32
|
+
* userOwned (user-forget) > tenantOwned (tenant-destroy) > pii (self).
|
|
33
|
+
*/
|
|
34
|
+
export function resolveSubjectForField(
|
|
35
|
+
entity: EntityDefinition,
|
|
36
|
+
fieldName: string,
|
|
37
|
+
row: Record<string, unknown>,
|
|
38
|
+
opts: ResolveSubjectOptions = {},
|
|
39
|
+
): SubjectId | null {
|
|
40
|
+
const field = entity.fields[fieldName];
|
|
41
|
+
if (!field) throw new SubjectResolutionError(fieldName, "field is not defined on the entity");
|
|
42
|
+
|
|
43
|
+
if ("userOwned" in field && field.userOwned !== undefined) {
|
|
44
|
+
const ownerField = field.userOwned.ownerField;
|
|
45
|
+
const userId = nonEmptyString(row[ownerField]);
|
|
46
|
+
if (userId === null) {
|
|
47
|
+
throw new SubjectResolutionError(
|
|
48
|
+
fieldName,
|
|
49
|
+
`owner field "${ownerField}" is empty on the row`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return { kind: "user", userId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ("tenantOwned" in field && field.tenantOwned === true) {
|
|
56
|
+
const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
|
|
57
|
+
if (tenantId === undefined) {
|
|
58
|
+
throw new SubjectResolutionError(
|
|
59
|
+
fieldName,
|
|
60
|
+
"row has no tenantId column and no write-time tenantId was provided",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return { kind: "tenant", tenantId };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ("pii" in field && field.pii === true) {
|
|
67
|
+
// pii: true = the entity itself is the subject (user.email belongs to
|
|
68
|
+
// that user row). Serial ids are stringified — subject keys are text.
|
|
69
|
+
const id = row["id"];
|
|
70
|
+
const userId = nonEmptyString(id) ?? (typeof id === "number" ? String(id) : null);
|
|
71
|
+
if (userId === null) {
|
|
72
|
+
throw new SubjectResolutionError(fieldName, "row has no id to use as the pii self-subject");
|
|
73
|
+
}
|
|
74
|
+
return { kind: "user", userId };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The field names an encrypt engine must process for an entity — precomputed
|
|
81
|
+
// once at executor build time, like the sensitiveFields set.
|
|
82
|
+
export function collectPiiSubjectFields(entity: EntityDefinition): readonly string[] {
|
|
83
|
+
return Object.entries(entity.fields)
|
|
84
|
+
.filter(
|
|
85
|
+
([, field]) =>
|
|
86
|
+
("userOwned" in field && field.userOwned !== undefined) ||
|
|
87
|
+
("tenantOwned" in field && field.tenantOwned === true) ||
|
|
88
|
+
("pii" in field && field.pii === true),
|
|
89
|
+
)
|
|
90
|
+
.map(([name]) => name);
|
|
91
|
+
}
|
|
@@ -267,6 +267,10 @@ type SharedContextFields = {
|
|
|
267
267
|
// job which deliberately operates outside the per-call audit trail (it
|
|
268
268
|
// processes rows system-wide, not a per-user read).
|
|
269
269
|
readonly masterKeyProvider?: import("../../secrets").MasterKeyProvider;
|
|
270
|
+
// Subject-key adapter for crypto-shredding (GDPR Art. 17). Present when
|
|
271
|
+
// the app wired a KmsAdapter at boot; the PII envelope engine and the
|
|
272
|
+
// forget pipeline reach for it. Absent = crypto-shredding not enabled.
|
|
273
|
+
readonly kms?: import("../../crypto").KmsAdapter;
|
|
270
274
|
// Observability: optional at the outer boundary, always populated by the
|
|
271
275
|
// time a handler receives its ctx (Noop fallback when no provider is
|
|
272
276
|
// configured, so handler code can call ctx.tracer/ctx.metrics without
|