@cosmicdrift/kumiko-framework 0.116.1 → 0.119.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/api/__tests__/pii-leak-guard.integration.test.ts +103 -0
- package/src/api/pii-leak-guard.ts +45 -0
- package/src/api/server.ts +5 -0
- package/src/bun-db/query.ts +27 -2
- package/src/crypto/__tests__/blind-index.test.ts +130 -0
- 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__/pg-kms-adapter.integration.test.ts +117 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +216 -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/blind-index.ts +114 -0
- package/src/crypto/in-memory-kms-adapter.ts +50 -0
- package/src/crypto/index.ts +55 -0
- package/src/crypto/kms-adapter.ts +118 -0
- package/src/crypto/pg-kms-adapter.ts +173 -0
- package/src/crypto/pii-field-encryption.ts +181 -0
- package/src/crypto/request-kms-cache.ts +38 -0
- package/src/crypto/subject-resolver.ts +91 -0
- package/src/db/__tests__/blind-index.integration.test.ts +232 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +169 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +39 -0
- package/src/db/apply-entity-event.ts +8 -0
- package/src/db/blind-index-cleanup.ts +53 -0
- package/src/db/entity-table-meta.ts +47 -2
- package/src/db/event-store-executor.ts +125 -30
- package/src/db/index.ts +1 -0
- package/src/db/table-builder.ts +98 -51
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +95 -4
- package/src/engine/__tests__/unmanaged-table.test.ts +46 -1
- package/src/engine/boot-validator/pii-retention.ts +45 -9
- package/src/engine/define-feature.ts +3 -1
- package/src/engine/registry.ts +9 -0
- package/src/engine/types/feature.ts +10 -1
- package/src/engine/types/fields.ts +6 -0
- package/src/engine/types/handlers.ts +4 -0
- package/src/engine/types/index.ts +1 -0
|
@@ -0,0 +1,118 @@
|
|
|
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 function subjectIdFromKey(key: SubjectKey): SubjectId {
|
|
28
|
+
if (key.startsWith("user:")) return { kind: "user", userId: key.slice("user:".length) };
|
|
29
|
+
if (key.startsWith("tenant:")) {
|
|
30
|
+
return { kind: "tenant", tenantId: key.slice("tenant:".length) as TenantId }; // @cast-boundary parse of a key this module minted
|
|
31
|
+
}
|
|
32
|
+
throw new Error(`Invalid subject key: ${key}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface KmsContext {
|
|
36
|
+
readonly tenantId?: TenantId;
|
|
37
|
+
readonly requestId: string;
|
|
38
|
+
readonly userId?: string;
|
|
39
|
+
readonly eraseReason?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface KmsHealth {
|
|
43
|
+
readonly ok: boolean;
|
|
44
|
+
readonly latencyMs: number;
|
|
45
|
+
readonly details?: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 32-byte AES-256 data-encryption key, unwrapped and ready for local use.
|
|
49
|
+
export type SubjectDek = Buffer;
|
|
50
|
+
|
|
51
|
+
interface KmsAdapterBase {
|
|
52
|
+
/**
|
|
53
|
+
* Creates a fresh subject key. Throws KeyAlreadyExistsError when the
|
|
54
|
+
* subject already has one — including an erased tombstone: a shredded
|
|
55
|
+
* subject must never get a new key, or forget could be undone by
|
|
56
|
+
* re-encrypting under it.
|
|
57
|
+
*/
|
|
58
|
+
createKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Erases the key material immediately; the tombstone row stays for the
|
|
62
|
+
* audit trail. Idempotent — repeat calls and unknown subjects are no-ops.
|
|
63
|
+
*/
|
|
64
|
+
eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void>;
|
|
65
|
+
|
|
66
|
+
/** Probe for boot + readiness. Throws when the backend is unreachable. */
|
|
67
|
+
health(): Promise<KmsHealth>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Backends that hand out the plaintext DEK (Pg, InMemory). Encrypt/decrypt
|
|
71
|
+
// happens locally; DEKs are cacheable per request.
|
|
72
|
+
export interface LocalKeyKmsAdapter extends KmsAdapterBase {
|
|
73
|
+
readonly capabilities: { readonly mode: "local-key" };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Throws KeyErasedError after eraseKey (callers render "[[erased]]"),
|
|
77
|
+
* KeyNotFoundError when the subject never had a key (typically a bug).
|
|
78
|
+
*/
|
|
79
|
+
getKey(subject: SubjectId, ctx: KmsContext): Promise<SubjectDek>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Backends that never release key material (Vault transit, cloud KMS).
|
|
83
|
+
// Every encrypt/decrypt is a round-trip; nothing is cacheable.
|
|
84
|
+
export interface RemoteCryptoKmsAdapter extends KmsAdapterBase {
|
|
85
|
+
readonly capabilities: { readonly mode: "remote-crypto" };
|
|
86
|
+
|
|
87
|
+
encrypt(subject: SubjectId, plaintext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
88
|
+
|
|
89
|
+
/** Same error contract as LocalKeyKmsAdapter.getKey. */
|
|
90
|
+
decrypt(subject: SubjectId, ciphertext: Uint8Array, ctx: KmsContext): Promise<Uint8Array>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type KmsAdapter = LocalKeyKmsAdapter | RemoteCryptoKmsAdapter;
|
|
94
|
+
|
|
95
|
+
export function isLocalKeyKmsAdapter(adapter: KmsAdapter): adapter is LocalKeyKmsAdapter {
|
|
96
|
+
return adapter.capabilities.mode === "local-key";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export class KeyErasedError extends Error {
|
|
100
|
+
constructor(public readonly subject: SubjectId) {
|
|
101
|
+
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|
|
102
|
+
this.name = "KeyErasedError";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class KeyNotFoundError extends Error {
|
|
107
|
+
constructor(public readonly subject: SubjectId) {
|
|
108
|
+
super(`Subject key not found: ${subjectIdToKey(subject)}`);
|
|
109
|
+
this.name = "KeyNotFoundError";
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class KeyAlreadyExistsError extends Error {
|
|
114
|
+
constructor(public readonly subject: SubjectId) {
|
|
115
|
+
super(`Subject key already exists: ${subjectIdToKey(subject)}`);
|
|
116
|
+
this.name = "KeyAlreadyExistsError";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
import postgres from "postgres";
|
|
3
|
+
import {
|
|
4
|
+
KeyAlreadyExistsError,
|
|
5
|
+
KeyErasedError,
|
|
6
|
+
KeyNotFoundError,
|
|
7
|
+
type KmsContext,
|
|
8
|
+
type KmsHealth,
|
|
9
|
+
type LocalKeyKmsAdapter,
|
|
10
|
+
type SubjectDek,
|
|
11
|
+
type SubjectId,
|
|
12
|
+
subjectIdToKey,
|
|
13
|
+
} from "./kms-adapter";
|
|
14
|
+
|
|
15
|
+
const IV_LENGTH = 12;
|
|
16
|
+
const TAG_LENGTH = 16;
|
|
17
|
+
|
|
18
|
+
// Envelope layout [iv:12][tag:16][ct] — the platform KEK wraps each subject
|
|
19
|
+
// DEK at rest, so a subject-keys-DB dump alone reveals no key material.
|
|
20
|
+
function wrapDek(kek: Buffer, dek: Buffer): Buffer {
|
|
21
|
+
const iv = randomBytes(IV_LENGTH);
|
|
22
|
+
const cipher = createCipheriv("aes-256-gcm", kek, iv);
|
|
23
|
+
const ciphertext = Buffer.concat([cipher.update(dek), cipher.final()]);
|
|
24
|
+
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function unwrapDek(kek: Buffer, wrapped: Uint8Array): SubjectDek {
|
|
28
|
+
const envelope = Buffer.from(wrapped);
|
|
29
|
+
const iv = envelope.subarray(0, IV_LENGTH);
|
|
30
|
+
const tag = envelope.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH);
|
|
31
|
+
const ciphertext = envelope.subarray(IV_LENGTH + TAG_LENGTH);
|
|
32
|
+
const decipher = createDecipheriv("aes-256-gcm", kek, iv);
|
|
33
|
+
decipher.setAuthTag(tag);
|
|
34
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function decodePlatformKek(base64: string): Buffer {
|
|
38
|
+
const kek = Buffer.from(base64, "base64");
|
|
39
|
+
if (kek.length !== 32) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`PgKmsAdapter: platformKek must decode to 32 bytes, got ${kek.length} — expected a base64-encoded AES-256 key (openssl rand -base64 32)`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
return kek;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PG_UNIQUE_VIOLATION = "23505";
|
|
48
|
+
|
|
49
|
+
function isPgUniqueViolation(error: unknown): boolean {
|
|
50
|
+
return (
|
|
51
|
+
typeof error === "object" &&
|
|
52
|
+
error !== null &&
|
|
53
|
+
"code" in error &&
|
|
54
|
+
error.code === PG_UNIQUE_VIOLATION
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface SubjectKeyRow {
|
|
59
|
+
cipher_key: Uint8Array | null;
|
|
60
|
+
erased: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface PgKmsAdapterOptions {
|
|
64
|
+
/** Connection string of the DEDICATED subject-keys cluster — never the app DB (its backup retention must stay shorter, see kms-adapter.md). */
|
|
65
|
+
readonly databaseUrl: string;
|
|
66
|
+
/** Base64-encoded 32-byte platform KEK (PLATFORM_KEK env var). */
|
|
67
|
+
readonly platformKek: string;
|
|
68
|
+
readonly maxConnections?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Default production adapter: DEKs live in a separate Postgres cluster,
|
|
72
|
+
// KEK-wrapped at rest. Schema is created lazily on first use — the
|
|
73
|
+
// subject-keys cluster has no drizzle migration pipeline of its own.
|
|
74
|
+
export class PgKmsAdapter implements LocalKeyKmsAdapter {
|
|
75
|
+
readonly capabilities = { mode: "local-key" } as const;
|
|
76
|
+
|
|
77
|
+
private readonly sql: ReturnType<typeof postgres>;
|
|
78
|
+
private readonly kek: Buffer;
|
|
79
|
+
private schemaReady: Promise<void> | undefined;
|
|
80
|
+
|
|
81
|
+
constructor(options: PgKmsAdapterOptions) {
|
|
82
|
+
this.kek = decodePlatformKek(options.platformKek);
|
|
83
|
+
this.sql = postgres(options.databaseUrl, {
|
|
84
|
+
max: options.maxConnections ?? 4,
|
|
85
|
+
// The connection is exclusive to this adapter and its only DDL is
|
|
86
|
+
// idempotent IF NOT EXISTS — notices are pure boot-log noise.
|
|
87
|
+
onnotice: () => {},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async createKey(subject: SubjectId, ctx: KmsContext): Promise<void> {
|
|
92
|
+
await this.ensureSchema();
|
|
93
|
+
const wrapped = wrapDek(this.kek, randomBytes(32));
|
|
94
|
+
try {
|
|
95
|
+
await this.sql`
|
|
96
|
+
INSERT INTO kumiko_subject_keys (subject_id, cipher_key, created_by)
|
|
97
|
+
VALUES (${subjectIdToKey(subject)}, ${wrapped}, ${ctx.userId ?? null})`;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (isPgUniqueViolation(error)) throw new KeyAlreadyExistsError(subject);
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getKey(subject: SubjectId, _ctx: KmsContext): Promise<SubjectDek> {
|
|
105
|
+
await this.ensureSchema();
|
|
106
|
+
const rows = await this.sql<SubjectKeyRow[]>`
|
|
107
|
+
SELECT cipher_key, (erased_at IS NOT NULL) AS erased
|
|
108
|
+
FROM kumiko_subject_keys
|
|
109
|
+
WHERE subject_id = ${subjectIdToKey(subject)}`;
|
|
110
|
+
const row = rows[0];
|
|
111
|
+
if (!row) throw new KeyNotFoundError(subject);
|
|
112
|
+
if (row.erased || row.cipher_key === null) throw new KeyErasedError(subject);
|
|
113
|
+
return unwrapDek(this.kek, row.cipher_key);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async eraseKey(subject: SubjectId, ctx: KmsContext): Promise<void> {
|
|
117
|
+
await this.ensureSchema();
|
|
118
|
+
// Guard on erased_at IS NULL keeps repeat erases from overwriting the
|
|
119
|
+
// original tombstone audit fields; unknown subjects update zero rows.
|
|
120
|
+
await this.sql`
|
|
121
|
+
UPDATE kumiko_subject_keys
|
|
122
|
+
SET cipher_key = NULL,
|
|
123
|
+
erased_at = now(),
|
|
124
|
+
erased_by = ${ctx.userId ?? null},
|
|
125
|
+
erase_reason = ${ctx.eraseReason ?? null}
|
|
126
|
+
WHERE subject_id = ${subjectIdToKey(subject)} AND erased_at IS NULL`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async health(): Promise<KmsHealth> {
|
|
130
|
+
const start = Date.now();
|
|
131
|
+
await this.ensureSchema();
|
|
132
|
+
await this.sql`SELECT 1`;
|
|
133
|
+
return { ok: true, latencyMs: Date.now() - start };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async close(): Promise<void> {
|
|
137
|
+
await this.sql.end();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private ensureSchema(): Promise<void> {
|
|
141
|
+
// A failed attempt resets the memo so a transient DB outage does not
|
|
142
|
+
// poison the adapter for the rest of the process lifetime.
|
|
143
|
+
this.schemaReady ??= this.createSchema().catch((error) => {
|
|
144
|
+
this.schemaReady = undefined;
|
|
145
|
+
throw error;
|
|
146
|
+
});
|
|
147
|
+
return this.schemaReady;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async createSchema(): Promise<void> {
|
|
151
|
+
await this.sql`
|
|
152
|
+
CREATE TABLE IF NOT EXISTS kumiko_subject_keys (
|
|
153
|
+
subject_id TEXT PRIMARY KEY,
|
|
154
|
+
cipher_key BYTEA,
|
|
155
|
+
kek_version INTEGER NOT NULL DEFAULT 1,
|
|
156
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
157
|
+
created_by TEXT,
|
|
158
|
+
erased_at TIMESTAMPTZ,
|
|
159
|
+
erased_by TEXT,
|
|
160
|
+
erase_reason TEXT
|
|
161
|
+
)`;
|
|
162
|
+
await this.sql`
|
|
163
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_erased_idx
|
|
164
|
+
ON kumiko_subject_keys (erased_at) WHERE erased_at IS NOT NULL`;
|
|
165
|
+
await this.sql`
|
|
166
|
+
CREATE INDEX IF NOT EXISTS kumiko_subject_keys_audit_idx
|
|
167
|
+
ON kumiko_subject_keys (created_at, erased_at)`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function createPgKmsAdapter(options: PgKmsAdapterOptions): PgKmsAdapter {
|
|
172
|
+
return new PgKmsAdapter(options);
|
|
173
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// PII field encryption with per-subject DEKs (crypto-shredding, #724 phase C).
|
|
2
|
+
// Same executor hook points as `encrypted: true`, but the key belongs to the
|
|
3
|
+
// erase subject — kms.eraseKey(subject) makes every value unreadable at once.
|
|
4
|
+
// Storage format is a sniffable string that fits existing text columns and
|
|
5
|
+
// names its subject inline, so decrypt needs no schema change and no resolver:
|
|
6
|
+
// kumiko-pii:v1:<subjectKey>:<base64(iv|tag|ciphertext)>
|
|
7
|
+
|
|
8
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
9
|
+
import type { EntityDefinition } from "../engine/types/fields";
|
|
10
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
11
|
+
import {
|
|
12
|
+
isLocalKeyKmsAdapter,
|
|
13
|
+
KeyAlreadyExistsError,
|
|
14
|
+
KeyErasedError,
|
|
15
|
+
KeyNotFoundError,
|
|
16
|
+
type KmsAdapter,
|
|
17
|
+
type KmsContext,
|
|
18
|
+
type LocalKeyKmsAdapter,
|
|
19
|
+
type SubjectDek,
|
|
20
|
+
type SubjectId,
|
|
21
|
+
subjectIdFromKey,
|
|
22
|
+
subjectIdToKey,
|
|
23
|
+
} from "./kms-adapter";
|
|
24
|
+
import { resolveSubjectForField } from "./subject-resolver";
|
|
25
|
+
|
|
26
|
+
// Spec value (crypto-shredding.md) — renderers show it verbatim.
|
|
27
|
+
export const PII_ERASED_SENTINEL = "[[erased]]";
|
|
28
|
+
|
|
29
|
+
export const PII_CIPHERTEXT_PREFIX = "kumiko-pii:v1:";
|
|
30
|
+
const IV_LENGTH = 12;
|
|
31
|
+
const AUTH_TAG_LENGTH = 16;
|
|
32
|
+
|
|
33
|
+
export function isPiiCiphertext(value: unknown): value is string {
|
|
34
|
+
return typeof value === "string" && value.startsWith(PII_CIPHERTEXT_PREFIX);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function encryptValue(subject: SubjectId, dek: SubjectDek, plaintext: string): string {
|
|
38
|
+
const iv = randomBytes(IV_LENGTH);
|
|
39
|
+
const cipher = createCipheriv("aes-256-gcm", dek, iv);
|
|
40
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
41
|
+
const blob = Buffer.concat([iv, cipher.getAuthTag(), ciphertext]);
|
|
42
|
+
return `${PII_CIPHERTEXT_PREFIX}${subjectIdToKey(subject)}:${blob.toString("base64")}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseCiphertext(value: string): { subject: SubjectId; blob: Buffer } {
|
|
46
|
+
const rest = value.slice(PII_CIPHERTEXT_PREFIX.length);
|
|
47
|
+
// subjectKey itself contains ":" ("user:<id>") — base64 never does, so the
|
|
48
|
+
// last ":" is always the key/blob separator.
|
|
49
|
+
const sep = rest.lastIndexOf(":");
|
|
50
|
+
if (sep === -1) throw new Error(`Malformed PII ciphertext (no subject/blob separator)`);
|
|
51
|
+
return {
|
|
52
|
+
subject: subjectIdFromKey(rest.slice(0, sep)),
|
|
53
|
+
blob: Buffer.from(rest.slice(sep + 1), "base64"),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function decryptValue(dek: SubjectDek, blob: Buffer): string {
|
|
58
|
+
const iv = blob.subarray(0, IV_LENGTH);
|
|
59
|
+
const tag = blob.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
|
|
60
|
+
const ciphertext = blob.subarray(IV_LENGTH + AUTH_TAG_LENGTH);
|
|
61
|
+
const decipher = createDecipheriv("aes-256-gcm", dek, iv);
|
|
62
|
+
decipher.setAuthTag(tag);
|
|
63
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// First write to a PII field of a new subject creates the key implicitly
|
|
67
|
+
// (user-signup / tenant-create need no extra hook). The AlreadyExists catch
|
|
68
|
+
// covers the concurrent-first-write race; a tombstoned subject re-throws
|
|
69
|
+
// KeyErasedError from the final getKey — writes to a forgotten subject fail.
|
|
70
|
+
async function getOrCreateDek(
|
|
71
|
+
kms: LocalKeyKmsAdapter,
|
|
72
|
+
subject: SubjectId,
|
|
73
|
+
ctx: KmsContext,
|
|
74
|
+
): Promise<SubjectDek> {
|
|
75
|
+
try {
|
|
76
|
+
return await kms.getKey(subject, ctx);
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (!(e instanceof KeyNotFoundError)) throw e;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
await kms.createKey(subject, ctx);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (!(e instanceof KeyAlreadyExistsError)) throw e;
|
|
84
|
+
}
|
|
85
|
+
return kms.getKey(subject, ctx);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface EncryptPiiOptions {
|
|
89
|
+
readonly onlyKeys?: Iterable<string>;
|
|
90
|
+
// Write-time tenant for tenantOwned fields on rows without a tenantId column.
|
|
91
|
+
readonly tenantId?: TenantId;
|
|
92
|
+
// Row to resolve subjects from when `row` is a partial (update changes may
|
|
93
|
+
// carry a pii field without its ownerField — the merged row still has it).
|
|
94
|
+
readonly subjectSource?: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function encryptPiiFieldValues(
|
|
98
|
+
row: Record<string, unknown>,
|
|
99
|
+
entity: EntityDefinition,
|
|
100
|
+
piiFields: readonly string[],
|
|
101
|
+
kms: LocalKeyKmsAdapter,
|
|
102
|
+
kmsCtx: KmsContext,
|
|
103
|
+
opts: EncryptPiiOptions = {},
|
|
104
|
+
): Promise<Record<string, unknown>> {
|
|
105
|
+
if (piiFields.length === 0) return row;
|
|
106
|
+
const only = opts.onlyKeys ? new Set(opts.onlyKeys) : null;
|
|
107
|
+
const subjectSource = opts.subjectSource ?? row;
|
|
108
|
+
const out = { ...row };
|
|
109
|
+
for (const name of piiFields) {
|
|
110
|
+
if (only && !only.has(name)) continue;
|
|
111
|
+
if (!(name in out)) continue;
|
|
112
|
+
const value = out[name];
|
|
113
|
+
if (value === null || value === undefined) continue;
|
|
114
|
+
// Re-encrypt paths (update previous, detail cache) may see values that
|
|
115
|
+
// are already ciphertext or the erased sentinel — both stay as-is.
|
|
116
|
+
if (isPiiCiphertext(value) || value === PII_ERASED_SENTINEL) continue;
|
|
117
|
+
if (typeof value !== "string") {
|
|
118
|
+
throw new Error(`PII field "${name}" must be a string, got ${typeof value}`);
|
|
119
|
+
}
|
|
120
|
+
const subject = resolveSubjectForField(entity, name, subjectSource, {
|
|
121
|
+
...(opts.tenantId !== undefined && { tenantId: opts.tenantId }),
|
|
122
|
+
});
|
|
123
|
+
// skip: collectPiiSubjectFields only yields annotated fields — null is unreachable, kept as a type guard
|
|
124
|
+
if (subject === null) continue;
|
|
125
|
+
const dek = await getOrCreateDek(kms, subject, kmsCtx);
|
|
126
|
+
out[name] = encryptValue(subject, dek, value);
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function decryptPiiFieldValues(
|
|
132
|
+
row: Record<string, unknown>,
|
|
133
|
+
piiFields: readonly string[],
|
|
134
|
+
kms: LocalKeyKmsAdapter,
|
|
135
|
+
kmsCtx: KmsContext,
|
|
136
|
+
): Promise<Record<string, unknown>> {
|
|
137
|
+
if (piiFields.length === 0) return row;
|
|
138
|
+
const out = { ...row };
|
|
139
|
+
for (const name of piiFields) {
|
|
140
|
+
const value = out[name];
|
|
141
|
+
// Pre-engine plaintext rows pass through unchanged (mixed-state reads
|
|
142
|
+
// work during rollout; backfill is tracked in kumiko-framework#799).
|
|
143
|
+
if (!isPiiCiphertext(value)) continue;
|
|
144
|
+
const { subject, blob } = parseCiphertext(value);
|
|
145
|
+
try {
|
|
146
|
+
out[name] = decryptValue(await kms.getKey(subject, kmsCtx), blob);
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// KeyNotFound deliberately propagates: ciphertext without a key row
|
|
149
|
+
// means the key store is wrong (not shredded) — fail loud.
|
|
150
|
+
if (!(e instanceof KeyErasedError)) throw e;
|
|
151
|
+
out[name] = PII_ERASED_SENTINEL;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Boot-injected app-wide subject KMS, mirroring configureEntityFieldEncryption:
|
|
158
|
+
// run{Prod,Dev}App call configurePiiSubjectKms once; executors resolve lazily.
|
|
159
|
+
// Absent adapter = engine off (pii fields stay plaintext, pre-phase-C
|
|
160
|
+
// behavior) — the hard boot gate arrives with the prod-grade PgKmsAdapter
|
|
161
|
+
// (phase E); until then no production deployment can satisfy it.
|
|
162
|
+
let injectedKms: LocalKeyKmsAdapter | undefined;
|
|
163
|
+
|
|
164
|
+
export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
|
|
165
|
+
if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"PII field encryption requires a local-key KMS adapter — remote-crypto " +
|
|
168
|
+
"(Vault transit) support lands with the BYOK adapter.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
injectedKms = adapter;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
|
|
175
|
+
return injectedKms;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** @internal test-only */
|
|
179
|
+
export function resetPiiSubjectKmsForTests(): void {
|
|
180
|
+
injectedKms = undefined;
|
|
181
|
+
}
|
|
@@ -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
|
+
}
|