@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 +21 -0
- package/dist/bin/copy.d.ts +31 -0
- package/dist/bin/copy.js +61 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +18 -0
- package/dist/held/aad.d.ts +13 -0
- package/dist/held/aad.js +25 -0
- package/dist/held/index.d.ts +134 -0
- package/dist/held/index.js +308 -0
- package/dist/held/schema.d.ts +278 -0
- package/dist/held/schema.js +43 -0
- package/dist/held/shred.d.ts +34 -0
- package/dist/held/shred.js +76 -0
- package/dist/issued/codec.d.ts +3 -0
- package/dist/issued/codec.js +7 -0
- package/dist/issued/encoding.d.ts +48 -0
- package/dist/issued/encoding.js +132 -0
- package/dist/issued/index.d.ts +88 -0
- package/dist/issued/index.js +273 -0
- package/dist/issued/secret.d.ts +30 -0
- package/dist/issued/secret.js +69 -0
- package/dist/issued/tables.d.ts +250 -0
- package/dist/issued/tables.js +35 -0
- package/dist/issued/verify.d.ts +8 -0
- package/dist/issued/verify.js +31 -0
- package/dist/migrations/0001_keys_issued.sql +80 -0
- package/dist/migrations/0002_keys_held.sql +60 -0
- package/dist/migrations/0003_keys_shred.sql +361 -0
- package/dist/worker-contract.d.ts +360 -0
- package/dist/worker-contract.js +94 -0
- package/package.json +52 -0
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;
|
package/dist/bin/copy.js
ADDED
|
@@ -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,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;
|
package/dist/held/aad.js
ADDED
|
@@ -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
|
+
}
|