@wtfalch/keys 0.1.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/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @wtfalch/keys
2
+
3
+ `@wtfalch/keys` issues the estate's own bearer keys to callers and holds keys
4
+ other parties issued to the estate, injecting held keys into a running
5
+ service at request time and into a build at build time. It owns no
6
+ authentication or authorisation of its own — every check runs through
7
+ `@wtfalch/auth` and `@wtfalch/authz`.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pnpm add @wtfalch/keys
13
+ ```
14
+
15
+ ## Dev
16
+
17
+ ```sh
18
+ pnpm build
19
+ pnpm typecheck
20
+ pnpm test
21
+ ```
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Migrations in this estate are hand-written SQL, numbered per app, applied
3
+ * by psql on boot, additive. A package cannot own a number in an app's
4
+ * sequence, so this copies any migration the app has not yet copied into the
5
+ * app's `drizzle/` as the next numbers and records which in a manifest. The
6
+ * same mechanism `@wtfalch/audit`, `@wtfalch/threads` and `@wtfalch/reporting`
7
+ * use; the manifest name differs.
8
+ *
9
+ * `./held` and `./issued` each ship their own numbered migration
10
+ * (`0001_keys_issued.sql`, `0002_keys_held.sql`) into this package's shared
11
+ * `src/migrations/`, whichever lands first -- so this copies every `.sql`
12
+ * file present, not one hardcoded name.
13
+ */
14
+ export declare const MANIFEST = ".keys-migrations.json";
15
+ export interface Manifest {
16
+ /** package file name -> the name it was copied to in the app */
17
+ copied: Record<string, string>;
18
+ }
19
+ export interface CopyResult {
20
+ copied: Array<{
21
+ from: string;
22
+ to: string;
23
+ }>;
24
+ manifest: Manifest;
25
+ }
26
+ export declare function copyMigrations(opts: {
27
+ from: string;
28
+ to: string;
29
+ version?: string;
30
+ }): CopyResult;
31
+ export declare function describeCopy(result: CopyResult): string;
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ /**
4
+ * Migrations in this estate are hand-written SQL, numbered per app, applied
5
+ * by psql on boot, additive. A package cannot own a number in an app's
6
+ * sequence, so this copies any migration the app has not yet copied into the
7
+ * app's `drizzle/` as the next numbers and records which in a manifest. The
8
+ * same mechanism `@wtfalch/audit`, `@wtfalch/threads` and `@wtfalch/reporting`
9
+ * use; the manifest name differs.
10
+ *
11
+ * `./held` and `./issued` each ship their own numbered migration
12
+ * (`0001_keys_issued.sql`, `0002_keys_held.sql`) into this package's shared
13
+ * `src/migrations/`, whichever lands first -- so this copies every `.sql`
14
+ * file present, not one hardcoded name.
15
+ */
16
+ export const MANIFEST = '.keys-migrations.json';
17
+ const NUMBERED = /^(\d{4})_(.+\.sql)$/;
18
+ function readManifest(dir) {
19
+ const file = join(dir, MANIFEST);
20
+ if (!existsSync(file))
21
+ return { copied: {} };
22
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
23
+ return { copied: parsed.copied ?? {} };
24
+ }
25
+ function nextNumber(dir) {
26
+ let max = -1;
27
+ for (const name of readdirSync(dir)) {
28
+ const m = NUMBERED.exec(name);
29
+ if (m?.[1])
30
+ max = Math.max(max, Number(m[1]));
31
+ }
32
+ return max + 1;
33
+ }
34
+ export function copyMigrations(opts) {
35
+ mkdirSync(opts.to, { recursive: true });
36
+ const manifest = readManifest(opts.to);
37
+ const copied = [];
38
+ const sources = readdirSync(opts.from)
39
+ .filter((n) => NUMBERED.test(n))
40
+ .sort();
41
+ let next = nextNumber(opts.to);
42
+ for (const name of sources) {
43
+ if (manifest.copied[name])
44
+ continue;
45
+ const rest = NUMBERED.exec(name)?.[2] ?? name;
46
+ const target = `${String(next).padStart(4, '0')}_${rest}`;
47
+ const body = readFileSync(join(opts.from, name), 'utf8');
48
+ const header = `-- Copied from @wtfalch/keys${opts.version ? ` ${opts.version}` : ''} (migrations/${name}) by keys-migrations.\n-- Do not edit here; the next package version ships the next file.\n\n`;
49
+ writeFileSync(join(opts.to, target), header + body);
50
+ manifest.copied[name] = target;
51
+ copied.push({ from: name, to: target });
52
+ next += 1;
53
+ }
54
+ writeFileSync(join(opts.to, MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`);
55
+ return { copied, manifest };
56
+ }
57
+ export function describeCopy(result) {
58
+ if (result.copied.length === 0)
59
+ return 'keys-migrations: nothing to copy';
60
+ return result.copied.map((c) => `keys-migrations: ${c.from} -> ${basename(c.to)}`).join('\n');
61
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { copyMigrations, describeCopy } from './copy.js';
6
+ /**
7
+ * `keys-migrations [dir]`: copy this package's migrations the app has not
8
+ * yet copied into `dir` (default `drizzle`) as the next numbers. Idempotent;
9
+ * run it after every upgrade of @wtfalch/keys, then commit what it wrote.
10
+ *
11
+ * Ships every `.sql` file under `dist/migrations/` -- both `./held`'s and
12
+ * `./issued`'s, whichever this package version carries.
13
+ */
14
+ const here = dirname(fileURLToPath(import.meta.url));
15
+ const from = join(here, '..', 'migrations');
16
+ const to = resolve(process.cwd(), process.argv[2] ?? 'drizzle');
17
+ const { version } = JSON.parse(readFileSync(join(here, '..', '..', 'package.json'), 'utf8'));
18
+ console.log(describeCopy(copyMigrations({ from, to, version })));
@@ -0,0 +1,13 @@
1
+ import { type KeyBinding } from '../worker-contract.js';
2
+ /**
3
+ * `encodeAad(binding)`, exactly as `worker-contract.ts` specifies it: the
4
+ * UTF-8 bytes of `wtfalch-keys:aad:v1\n` + `JSON.stringify([tenantId,
5
+ * entryId, version])`. Both the wrapped data key (checked by the Worker) and
6
+ * the value's own ciphertext (checked here) use this as associated data, so a
7
+ * row copied to another tenant or relabelled to another version fails the
8
+ * GCM tag on both layers.
9
+ *
10
+ * `TextEncoder` rather than `Buffer`: the Worker imports this file too, and
11
+ * it runs on `workerd`, not Node.
12
+ */
13
+ export declare function encodeAad(binding: KeyBinding): Uint8Array;
@@ -0,0 +1,25 @@
1
+ import { AAD_PREFIX, ID_PATTERN } from '../worker-contract.js';
2
+ /**
3
+ * `encodeAad(binding)`, exactly as `worker-contract.ts` specifies it: the
4
+ * UTF-8 bytes of `wtfalch-keys:aad:v1\n` + `JSON.stringify([tenantId,
5
+ * entryId, version])`. Both the wrapped data key (checked by the Worker) and
6
+ * the value's own ciphertext (checked here) use this as associated data, so a
7
+ * row copied to another tenant or relabelled to another version fails the
8
+ * GCM tag on both layers.
9
+ *
10
+ * `TextEncoder` rather than `Buffer`: the Worker imports this file too, and
11
+ * it runs on `workerd`, not Node.
12
+ */
13
+ export function encodeAad(binding) {
14
+ if (!ID_PATTERN.test(binding.tenantId)) {
15
+ throw new RangeError(`encodeAad: tenantId does not match ID_PATTERN: ${binding.tenantId}`);
16
+ }
17
+ if (!ID_PATTERN.test(binding.entryId)) {
18
+ throw new RangeError(`encodeAad: entryId does not match ID_PATTERN: ${binding.entryId}`);
19
+ }
20
+ if (!Number.isInteger(binding.version) || binding.version < 1) {
21
+ throw new RangeError(`encodeAad: version must be a positive integer: ${binding.version}`);
22
+ }
23
+ const json = JSON.stringify([binding.tenantId, binding.entryId, binding.version]);
24
+ return new TextEncoder().encode(`${AAD_PREFIX}${json}`);
25
+ }
@@ -0,0 +1,134 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import { type KeyBinding, type SerializedCredential, type WorkerClient, type WorkerError, type WorkerErrorCode } from '../worker-contract.js';
3
+ export { encodeAad } from './aad.js';
4
+ export type { KeyBinding, WorkerClient, WorkerError, WorkerErrorCode } from '../worker-contract.js';
5
+ export type { KeysHeldEntryRow, KeysHeldVersionRow } from './schema.js';
6
+ export { shredClocks, shredExpired } from './shred.js';
7
+ export type { ShredClock, ShredClockReason } from './shred.js';
8
+ /**
9
+ * `./held`: the ciphertext half of a held key, in the host's own database
10
+ * (#216), plus the 60-second data-key cache and the callback-shaped `open`
11
+ * (#224). Encrypting and decrypting with the data key is local AES-256-GCM;
12
+ * only wrap/unwrap/rewrap cross the network, to the Worker.
13
+ *
14
+ * The host's own drizzle handle or a transaction open on it. postgres-js in
15
+ * hosts, PGlite in this package's own tests; the queries use nothing
16
+ * driver-specific -- the same shape `@wtfalch/audit`'s `Handle` uses.
17
+ */
18
+ export type HeldKeysDb = PgDatabase<PgQueryResultHKT, any, any>;
19
+ /**
20
+ * Every way `./held` refuses a call. `code` carries the Worker's own
21
+ * `WorkerErrorCode` verbatim when the Worker is what refused; the three
22
+ * local codes are refusals this package makes itself, never sent by a
23
+ * Worker.
24
+ */
25
+ export type HeldKeysErrorCode = WorkerErrorCode | 'not_found' | 'revoked' | 'unseal_failed';
26
+ /**
27
+ * Thrown by `put`, `open` and `rewrap`. Never carries a value or a key --
28
+ * only what a caller can act on: which entry, which code, and (for
29
+ * `rate_limited`) how long to wait.
30
+ */
31
+ export declare class HeldKeysError extends Error {
32
+ readonly code: HeldKeysErrorCode;
33
+ readonly retryAfterMs?: number;
34
+ constructor(code: HeldKeysErrorCode, message?: string, retryAfterMs?: number);
35
+ static fromWorker(error: WorkerError): HeldKeysError;
36
+ }
37
+ /**
38
+ * What `open` writes to the host's own audit log on a cache miss, and only
39
+ * on a cache miss: a hit reuses a value already logged once, and per #224 /
40
+ * ADR 0019 a reuse is never logged, host or Worker.
41
+ */
42
+ export interface KeyUsedEvent {
43
+ readonly name: 'key.used';
44
+ readonly tenantId: string;
45
+ readonly entryId: string;
46
+ readonly version: number;
47
+ /** The same id sent to the Worker's `unwrap` call, so #218's reconciliation can pair the two rows. */
48
+ readonly requestId: string;
49
+ /** Unix milliseconds. */
50
+ readonly at: number;
51
+ }
52
+ export interface HeldEntryRef {
53
+ readonly tenantId: string;
54
+ readonly entryId: string;
55
+ }
56
+ export interface CreateHeldKeysOptions {
57
+ /**
58
+ * The runtime role's ordinary connection for `put`, `open`, `forget`,
59
+ * `revokeEntry` and `archiveTenant` -- it has SELECT and INSERT on
60
+ * `keys_held_versions`, never UPDATE (#232's migration; #225: "The
61
+ * runtime role gets EXECUTE, never UPDATE"). Build a SEPARATE
62
+ * `createHeldKeys` with an operator handle (the migration/owner role) to
63
+ * call `rewrap` -- see that method's own doc.
64
+ */
65
+ readonly db: HeldKeysDb;
66
+ readonly worker: Pick<WorkerClient, 'wrap' | 'unwrap' | 'rewrap'>;
67
+ readonly credential: SerializedCredential;
68
+ readonly audit: (event: KeyUsedEvent) => void | Promise<void>;
69
+ /** Seconds a fetched data key is held in this process before a cache hit no longer serves it. Default 60 (#224). 0 disables caching. */
70
+ readonly cacheSeconds?: number;
71
+ /** Unix milliseconds. Defaults to `Date.now`; tests pass a fake clock to move it without waiting. */
72
+ readonly now?: () => number;
73
+ }
74
+ export interface HeldKeys {
75
+ /** Seals a new version under `entry`, creating the entry on its first call. */
76
+ put(entry: HeldEntryRef, value: string): Promise<{
77
+ readonly version: number;
78
+ }>;
79
+ /**
80
+ * Opens one version and hands the plaintext to `use`, never returning it.
81
+ * The callback narrows *scope* -- the value cannot be assigned to a
82
+ * variable that outlives the call -- but it cannot zero a JS string: a
83
+ * `.toString()`, a template literal, or a copy `use` itself makes can
84
+ * still outlive this call. That is a limit of the language, not a gap in
85
+ * this function.
86
+ */
87
+ open<T>(binding: KeyBinding, use: (value: string) => Promise<T>): Promise<T>;
88
+ /** Drops and zeroes one cached data key, if one is cached for this exact version. */
89
+ forget(binding: KeyBinding): void;
90
+ /**
91
+ * The rewrap sweep: moves every version still under `kekIdOld` onto
92
+ * `kekIdNew`. The raw data key never reaches this process.
93
+ *
94
+ * An operator sweep (#217), not a runtime action: call this only on a
95
+ * `HeldKeys` built with an operator database handle -- the migration or
96
+ * owner role, never the runtime one. The runtime role has no UPDATE on
97
+ * `keys_held_versions` at all (#232's migration), so calling `rewrap`
98
+ * against a runtime-role `db` fails with a permission error at the first
99
+ * row. `keys_held_versions_guard` still limits even an operator to the
100
+ * rewrap shape (`wrapped_key` and `kek_id` changing together, nothing
101
+ * else), as a guard against a fumbled statement, not against malice --
102
+ * Postgres cannot verify a rewrapped key's content, only its shape, so
103
+ * the operator's own privilege is what makes this call trustworthy.
104
+ */
105
+ rewrap(options: {
106
+ readonly kekIdOld: string;
107
+ readonly kekIdNew: string;
108
+ }): Promise<void>;
109
+ /**
110
+ * Marks an entry revoked. This process's own cache is cleared at once, so
111
+ * its next `open` for this entry is a database read and refuses. A
112
+ * rotation (`put`) clears the same way. Neither reaches a sibling
113
+ * process's cache: per #224, a process already holding this entry's data
114
+ * key keeps opening it from that cache until the key's own TTL runs out,
115
+ * at most `cacheSeconds` (default 60) after it was fetched -- the same
116
+ * rule app-template's `core.ts` states for its cache ("on another
117
+ * instance the TTL bounds how long a replaced value can still be handed
118
+ * out").
119
+ */
120
+ revokeEntry(entry: HeldEntryRef): Promise<void>;
121
+ /**
122
+ * Marks every entry this tenant holds archived, once each -- a second
123
+ * call re-dates nothing already archived (#225 revised, "the earliest of
124
+ * those timestamps"). This package has no `tenants` table of its own, so
125
+ * the mark lives on `keys_held_entries.tenant_archived_at` per entry,
126
+ * rather than on a row it doesn't own. It is one of the three inputs
127
+ * `keys_shred_expired()` (#232) reads to start a version's 30-day shred
128
+ * clock; it does not by itself refuse `open` -- whether a departed
129
+ * tenant's calls are refused is `@wtfalch/authz`'s decision, not this
130
+ * package's.
131
+ */
132
+ archiveTenant(tenantId: string): Promise<void>;
133
+ }
134
+ export declare function createHeldKeys(options: CreateHeldKeysOptions): HeldKeys;
@@ -0,0 +1,308 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, randomUUID } from 'node:crypto';
2
+ import { and, eq, sql } from 'drizzle-orm';
3
+ import { ID_PATTERN, } from '../worker-contract.js';
4
+ import { encodeAad } from './aad.js';
5
+ import { keysHeldEntries, keysHeldVersions } from './schema.js';
6
+ export { encodeAad } from './aad.js';
7
+ export { shredClocks, shredExpired } from './shred.js';
8
+ const IV_BYTES = 12;
9
+ const TAG_BYTES = 16;
10
+ const ALGORITHM = 'aes-256-gcm';
11
+ /**
12
+ * Thrown by `put`, `open` and `rewrap`. Never carries a value or a key --
13
+ * only what a caller can act on: which entry, which code, and (for
14
+ * `rate_limited`) how long to wait.
15
+ */
16
+ export class HeldKeysError extends Error {
17
+ code;
18
+ retryAfterMs;
19
+ constructor(code, message, retryAfterMs) {
20
+ super(message ?? `held keys: ${code}`);
21
+ this.name = 'HeldKeysError';
22
+ this.code = code;
23
+ this.retryAfterMs = retryAfterMs;
24
+ }
25
+ static fromWorker(error) {
26
+ return new HeldKeysError(error.code, error.message, error.retryAfterMs);
27
+ }
28
+ }
29
+ function cacheKeyOf(binding) {
30
+ return `${binding.tenantId} ${binding.entryId} ${binding.version}`;
31
+ }
32
+ function requireId(value, label) {
33
+ if (!ID_PATTERN.test(value)) {
34
+ throw new HeldKeysError('bad_request', `${label} does not match ID_PATTERN: ${value}`);
35
+ }
36
+ }
37
+ function requireVersion(version) {
38
+ if (!Number.isInteger(version) || version < 1) {
39
+ throw new HeldKeysError('bad_request', `version must be a positive integer: ${version}`);
40
+ }
41
+ }
42
+ function gcmSeal(dataKey, plaintext, aad) {
43
+ const iv = randomBytes(IV_BYTES);
44
+ const cipher = createCipheriv(ALGORITHM, dataKey, iv);
45
+ cipher.setAAD(aad);
46
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
47
+ return { iv, ciphertext: Buffer.concat([body, cipher.getAuthTag()]) };
48
+ }
49
+ function gcmOpen(dataKey, iv, ciphertext, aad) {
50
+ if (ciphertext.length < TAG_BYTES)
51
+ throw new HeldKeysError('unseal_failed');
52
+ const tag = ciphertext.subarray(ciphertext.length - TAG_BYTES);
53
+ const body = ciphertext.subarray(0, ciphertext.length - TAG_BYTES);
54
+ const decipher = createDecipheriv(ALGORITHM, dataKey, iv);
55
+ decipher.setAAD(aad);
56
+ decipher.setAuthTag(tag);
57
+ try {
58
+ return Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
59
+ }
60
+ catch {
61
+ // Never the underlying error: an attacker learns nothing from why a tag failed.
62
+ throw new HeldKeysError('unseal_failed', 'that version could not be opened with the given data key');
63
+ }
64
+ }
65
+ export function createHeldKeys(options) {
66
+ const { db, worker, credential, audit } = options;
67
+ const cacheMs = (options.cacheSeconds ?? 60) * 1000;
68
+ const now = options.now ?? Date.now;
69
+ const cache = new Map();
70
+ function evict(key, entry) {
71
+ // Not a guarantee -- the runtime may have copied it -- but it shortens
72
+ // the window in which a heap dump holds an evicted data key, same as
73
+ // app-template's envelope.ts.
74
+ entry.dataKey.fill(0);
75
+ cache.delete(key);
76
+ }
77
+ function cachedFor(binding) {
78
+ if (cacheMs <= 0)
79
+ return undefined;
80
+ const key = cacheKeyOf(binding);
81
+ const hit = cache.get(key);
82
+ if (!hit)
83
+ return undefined;
84
+ // TTL only, never a version re-check: re-reading the row on every hit
85
+ // would cost the database round trip caching exists to avoid (#224).
86
+ if (hit.until <= now()) {
87
+ evict(key, hit);
88
+ return undefined;
89
+ }
90
+ return hit;
91
+ }
92
+ function remember(binding, dataKey, iv, ciphertext) {
93
+ if (cacheMs <= 0)
94
+ return;
95
+ cache.set(cacheKeyOf(binding), { dataKey, iv, ciphertext, until: now() + cacheMs });
96
+ }
97
+ function forget(binding) {
98
+ const key = cacheKeyOf(binding);
99
+ const hit = cache.get(key);
100
+ if (hit)
101
+ evict(key, hit);
102
+ }
103
+ /** Every cached version of one entry, evicted -- used on revoke, where the caller does not name a version. */
104
+ function forgetEntry(entry) {
105
+ const prefix = `${entry.tenantId} ${entry.entryId} `;
106
+ for (const [key, hit] of cache) {
107
+ if (key.startsWith(prefix))
108
+ evict(key, hit);
109
+ }
110
+ }
111
+ async function put(entry, value) {
112
+ requireId(entry.tenantId, 'tenantId');
113
+ requireId(entry.entryId, 'entryId');
114
+ // Reserve the next version number under a row lock, and nothing else:
115
+ // the lock is released the instant this short transaction commits, so a
116
+ // slow or unreachable Worker (the next step, outside any lock) cannot
117
+ // hold up a concurrent revokeEntry or another put on the same entry.
118
+ // Losing a version number to a failed wrap is harmless; holding a
119
+ // Postgres lock open across a network call to the Worker is not.
120
+ const version = await db.transaction(async (tx) => {
121
+ await tx
122
+ .insert(keysHeldEntries)
123
+ .values({ tenantId: entry.tenantId, entryId: entry.entryId, currentVersion: 0 })
124
+ .onConflictDoNothing();
125
+ const [row] = await tx
126
+ .select({
127
+ currentVersion: keysHeldEntries.currentVersion,
128
+ revokedAt: keysHeldEntries.revokedAt,
129
+ })
130
+ .from(keysHeldEntries)
131
+ .where(and(eq(keysHeldEntries.tenantId, entry.tenantId), eq(keysHeldEntries.entryId, entry.entryId)))
132
+ .for('update');
133
+ if (!row)
134
+ throw new HeldKeysError('not_found', 'the entry vanished mid-transaction');
135
+ if (row.revokedAt)
136
+ throw new HeldKeysError('revoked', `entry ${entry.entryId} is revoked`);
137
+ const next = row.currentVersion + 1;
138
+ await tx
139
+ .update(keysHeldEntries)
140
+ .set({ currentVersion: next })
141
+ .where(and(eq(keysHeldEntries.tenantId, entry.tenantId), eq(keysHeldEntries.entryId, entry.entryId)));
142
+ return next;
143
+ });
144
+ const binding = { tenantId: entry.tenantId, entryId: entry.entryId, version };
145
+ const result = await worker.wrap({ requestId: randomUUID(), credential, binding });
146
+ if (!result.ok)
147
+ throw HeldKeysError.fromWorker(result.error);
148
+ const dataKey = result.dataKey;
149
+ try {
150
+ const aad = encodeAad(binding);
151
+ const sealed = gcmSeal(dataKey, Buffer.from(value, 'utf8'), aad);
152
+ await db.insert(keysHeldVersions).values({
153
+ tenantId: entry.tenantId,
154
+ entryId: entry.entryId,
155
+ version,
156
+ kekId: result.kekId,
157
+ wrappedKey: Buffer.from(result.wrappedKey),
158
+ iv: sealed.iv,
159
+ ciphertext: sealed.ciphertext,
160
+ });
161
+ // Retiring the version this one supersedes is a side effect of the
162
+ // INSERT above, not a second write here: keys_held_versions_retire_predecessors
163
+ // (#232's migration), an AFTER INSERT, SECURITY DEFINER trigger, sets
164
+ // retired_at on the entry's other still-open versions itself. That
165
+ // keeps this write off the runtime role's own privileges entirely --
166
+ // #225: "The runtime role gets EXECUTE, never UPDATE" -- which this
167
+ // package's own put() used to violate by updating retired_at
168
+ // directly. If the Worker call above had failed, the reserved version
169
+ // number is lost (already harmless, per the comment above) and the
170
+ // INSERT above never runs, so the trigger never fires and the
171
+ // still-current old version is correctly never retired.
172
+ }
173
+ finally {
174
+ dataKey.fill(0);
175
+ }
176
+ // A stale cache entry for a now-superseded version is harmless (a
177
+ // different cache key), but a rotation invalidates anything cached for
178
+ // this entry so a replica does not keep handing out a value a human
179
+ // just believed they overwrote, beyond this cache's TTL.
180
+ forgetEntry(entry);
181
+ return { version };
182
+ }
183
+ async function open(binding, use) {
184
+ requireId(binding.tenantId, 'tenantId');
185
+ requireId(binding.entryId, 'entryId');
186
+ requireVersion(binding.version);
187
+ const cached = cachedFor(binding);
188
+ if (cached) {
189
+ const value = gcmOpen(cached.dataKey, cached.iv, cached.ciphertext, encodeAad(binding));
190
+ return use(value);
191
+ }
192
+ const [entryRow] = await db
193
+ .select({ revokedAt: keysHeldEntries.revokedAt })
194
+ .from(keysHeldEntries)
195
+ .where(and(eq(keysHeldEntries.tenantId, binding.tenantId), eq(keysHeldEntries.entryId, binding.entryId)))
196
+ .limit(1);
197
+ if (!entryRow)
198
+ throw new HeldKeysError('not_found', `there is no entry ${binding.entryId}`);
199
+ if (entryRow.revokedAt)
200
+ throw new HeldKeysError('revoked', `entry ${binding.entryId} is revoked`);
201
+ const [versionRow] = await db
202
+ .select({
203
+ kekId: keysHeldVersions.kekId,
204
+ wrappedKey: keysHeldVersions.wrappedKey,
205
+ iv: keysHeldVersions.iv,
206
+ ciphertext: keysHeldVersions.ciphertext,
207
+ })
208
+ .from(keysHeldVersions)
209
+ .where(and(eq(keysHeldVersions.tenantId, binding.tenantId), eq(keysHeldVersions.entryId, binding.entryId), eq(keysHeldVersions.version, binding.version)))
210
+ .limit(1);
211
+ if (!versionRow)
212
+ throw new HeldKeysError('not_found', `there is no version ${binding.version}`);
213
+ if (versionRow.wrappedKey === null) {
214
+ throw new HeldKeysError('not_found', `version ${binding.version} has been shredded`);
215
+ }
216
+ const requestId = randomUUID();
217
+ const result = await worker.unwrap({
218
+ requestId,
219
+ credential,
220
+ kekId: versionRow.kekId,
221
+ wrappedKey: versionRow.wrappedKey,
222
+ binding,
223
+ });
224
+ if (!result.ok)
225
+ throw HeldKeysError.fromWorker(result.error);
226
+ const dataKey = result.dataKey;
227
+ const aad = encodeAad(binding);
228
+ const value = gcmOpen(dataKey, versionRow.iv, versionRow.ciphertext, aad);
229
+ await audit({
230
+ name: 'key.used',
231
+ tenantId: binding.tenantId,
232
+ entryId: binding.entryId,
233
+ version: binding.version,
234
+ requestId,
235
+ at: now(),
236
+ });
237
+ remember(binding, dataKey, versionRow.iv, versionRow.ciphertext);
238
+ return use(value);
239
+ }
240
+ async function rewrap(rewrapOptions) {
241
+ const { kekIdOld, kekIdNew } = rewrapOptions;
242
+ requireId(kekIdOld, 'kekIdOld');
243
+ requireId(kekIdNew, 'kekIdNew');
244
+ const rows = await db
245
+ .select({
246
+ tenantId: keysHeldVersions.tenantId,
247
+ entryId: keysHeldVersions.entryId,
248
+ version: keysHeldVersions.version,
249
+ wrappedKey: keysHeldVersions.wrappedKey,
250
+ })
251
+ .from(keysHeldVersions)
252
+ .where(and(eq(keysHeldVersions.kekId, kekIdOld), sql `${keysHeldVersions.wrappedKey} is not null`));
253
+ for (const row of rows) {
254
+ if (row.wrappedKey === null)
255
+ continue; // shredded between the read above and this row; nothing to rewrap
256
+ const binding = {
257
+ tenantId: row.tenantId,
258
+ entryId: row.entryId,
259
+ version: row.version,
260
+ };
261
+ const result = await worker.rewrap({
262
+ requestId: randomUUID(),
263
+ credential,
264
+ kekIdOld,
265
+ kekIdNew,
266
+ wrappedKey: row.wrappedKey,
267
+ binding,
268
+ });
269
+ if (!result.ok)
270
+ throw HeldKeysError.fromWorker(result.error);
271
+ await db
272
+ .update(keysHeldVersions)
273
+ .set({ kekId: kekIdNew, wrappedKey: Buffer.from(result.wrappedKey) })
274
+ .where(and(eq(keysHeldVersions.tenantId, binding.tenantId), eq(keysHeldVersions.entryId, binding.entryId), eq(keysHeldVersions.version, binding.version)));
275
+ }
276
+ }
277
+ async function revokeEntry(entry) {
278
+ requireId(entry.tenantId, 'tenantId');
279
+ requireId(entry.entryId, 'entryId');
280
+ // The `revoked_at is null` guard makes a second call a no-op rather than
281
+ // re-dating the revocation: an operator investigating an incident wants
282
+ // the first time this entry went dark, not the last time someone called
283
+ // revokeEntry on an already-revoked one.
284
+ await db
285
+ .update(keysHeldEntries)
286
+ .set({ revokedAt: sql `now()` })
287
+ .where(and(eq(keysHeldEntries.tenantId, entry.tenantId), eq(keysHeldEntries.entryId, entry.entryId), sql `${keysHeldEntries.revokedAt} is null`));
288
+ forgetEntry(entry);
289
+ }
290
+ async function archiveTenant(tenantId) {
291
+ requireId(tenantId, 'tenantId');
292
+ // Every row for this tenant not already archived, in one statement --
293
+ // the `is null` guard is what makes a second call a no-op per row
294
+ // rather than re-dating it, same reasoning as revokeEntry above. A new
295
+ // entry put() after this call is not retroactively covered; that
296
+ // matches revokeEntry's own entry-scoped (not tenant-scoped) reach.
297
+ await db
298
+ .update(keysHeldEntries)
299
+ .set({ tenantArchivedAt: sql `now()` })
300
+ .where(and(eq(keysHeldEntries.tenantId, tenantId), sql `${keysHeldEntries.tenantArchivedAt} is null`));
301
+ const prefix = `${tenantId} `;
302
+ for (const [key, hit] of cache) {
303
+ if (key.startsWith(prefix))
304
+ evict(key, hit);
305
+ }
306
+ }
307
+ return { put, open, forget, rewrap, revokeEntry, archiveTenant };
308
+ }