@wtfalch/keys 0.1.0 → 0.3.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.
@@ -0,0 +1,246 @@
1
+ -- Security review K01/K07. Additive upgrade; do not edit copied migrations.
2
+ -- Explicit relation qualification defeats caller-owned temporary relations.
3
+ -- The runtime role must not own these objects or have CREATE on public.
4
+ create or replace function public.keys_shred_expired()
5
+ returns integer
6
+ language plpgsql
7
+ security definer
8
+ set search_path = pg_catalog, public, pg_temp
9
+ as $$
10
+ declare
11
+ shredded integer;
12
+ begin
13
+ update public.keys_held_versions kv
14
+ set wrapped_key = null
15
+ from public.keys_held_entries ke
16
+ where kv.tenant_id = ke.tenant_id
17
+ and kv.entry_id = ke.entry_id
18
+ and kv.wrapped_key is not null
19
+ and (
20
+ (kv.retired_at is not null
21
+ and now() >= kv.retired_at + (public.keys_shred_delay_days() || ' days')::interval)
22
+ or (ke.revoked_at is not null
23
+ and now() >= ke.revoked_at + (public.keys_shred_delay_days() || ' days')::interval)
24
+ or (ke.tenant_archived_at is not null
25
+ and now() >= ke.tenant_archived_at + (public.keys_shred_delay_days() || ' days')::interval)
26
+ );
27
+ get diagnostics shredded = row_count;
28
+ return shredded;
29
+ end
30
+ $$;
31
+ revoke all on function public.keys_shred_expired() from public;
32
+
33
+ create or replace function public.keys_held_versions_guard() returns trigger
34
+ language plpgsql
35
+ set search_path = pg_catalog, public, pg_temp
36
+ as $$
37
+ declare
38
+ is_rewrap boolean;
39
+ is_retire boolean;
40
+ is_shred boolean;
41
+ begin
42
+ if tg_op = 'INSERT' then
43
+ -- Nothing that inserts a row -- put(), or keys_held_versions_retire_predecessors
44
+ -- below, which never inserts -- ever does so with retired_at set; this
45
+ -- guards a raw INSERT bypassing that, the same way the UPDATE branch
46
+ -- below guards a raw UPDATE.
47
+ if new.retired_at is not null then
48
+ new.retired_at := clock_timestamp();
49
+ end if;
50
+ return new;
51
+ elsif tg_op = 'DELETE' then
52
+ raise exception 'keys_held_versions is append-only: delete refused';
53
+ elsif tg_op = 'TRUNCATE' then
54
+ raise exception 'keys_held_versions is append-only: truncate refused';
55
+ elsif tg_op = 'UPDATE' then
56
+ -- Stamp: retired_at moving from NULL to non-null is forced to now(),
57
+ -- whatever the caller sent. Must run before the shape checks below, so
58
+ -- is_retire/is_shred see the real value, not a forged one.
59
+ if old.retired_at is null and new.retired_at is not null then
60
+ new.retired_at := clock_timestamp();
61
+ end if;
62
+ -- Once set, retired_at is fixed: no re-dating, no clearing.
63
+ if old.retired_at is not null and new.retired_at is distinct from old.retired_at then
64
+ raise exception 'keys_held_versions is append-only: retired_at may not be re-dated or cleared once set';
65
+ end if;
66
+
67
+ if new.tenant_id is distinct from old.tenant_id
68
+ or new.entry_id is distinct from old.entry_id
69
+ or new.version is distinct from old.version
70
+ or new.iv is distinct from old.iv
71
+ or new.ciphertext is distinct from old.ciphertext
72
+ or new.created_at is distinct from old.created_at
73
+ then
74
+ raise exception 'keys_held_versions is append-only: tenant_id, entry_id, version, iv, ciphertext and created_at may never change';
75
+ end if;
76
+
77
+ -- rewrap: an operator sweep (#217), run with an operator database
78
+ -- handle -- see the file header, item 3. The data key moves to a new
79
+ -- kek generation; wrapped_key and kek_id change together, retired_at
80
+ -- untouched either way. The guard cannot verify the new wrapped_key is
81
+ -- a genuine rewrap of the old one -- only Postgres-visible shape, never
82
+ -- content -- which is exactly why the runtime role no longer has
83
+ -- UPDATE at all (see Grants): this shape check is a guard against a
84
+ -- fumbled operator statement, not a security boundary on its own.
85
+ is_rewrap := old.wrapped_key is not null
86
+ and new.wrapped_key is not null
87
+ and new.wrapped_key is distinct from old.wrapped_key
88
+ and new.kek_id is distinct from old.kek_id
89
+ and new.retired_at is not distinct from old.retired_at;
90
+
91
+ -- retire: keys_held_versions_retire_predecessors, below, the only
92
+ -- thing that performs this write now. retired_at moves from NULL to a
93
+ -- value (now stamped, above), once, with nothing else about the row
94
+ -- changing.
95
+ is_retire := old.retired_at is null
96
+ and new.retired_at is not null
97
+ and new.wrapped_key is not distinct from old.wrapped_key
98
+ and new.kek_id is not distinct from old.kek_id;
99
+
100
+ -- shred (keys_shred_expired(), or an operator's own ordinary UPDATE
101
+ -- with the same shape -- the trigger cannot and does not distinguish
102
+ -- the two): the shape alone is not enough -- this row's own stored
103
+ -- clocks, which can no longer be forged (see the file header), must
104
+ -- already show public.keys_shred_delay_days() elapsed, the same predicate
105
+ -- keys_shred_expired()'s WHERE clause applies.
106
+ is_shred := old.wrapped_key is not null
107
+ and new.wrapped_key is null
108
+ and new.kek_id is not distinct from old.kek_id
109
+ and new.retired_at is not distinct from old.retired_at
110
+ and (
111
+ (new.retired_at is not null
112
+ and now() >= new.retired_at + (public.keys_shred_delay_days() || ' days')::interval)
113
+ or exists (
114
+ select 1 from public.keys_held_entries ke
115
+ where ke.tenant_id = new.tenant_id
116
+ and ke.entry_id = new.entry_id
117
+ and (
118
+ (ke.revoked_at is not null
119
+ and now() >= ke.revoked_at + (public.keys_shred_delay_days() || ' days')::interval)
120
+ or (ke.tenant_archived_at is not null
121
+ and now() >= ke.tenant_archived_at + (public.keys_shred_delay_days() || ' days')::interval)
122
+ )
123
+ )
124
+ );
125
+
126
+ if not (is_rewrap or is_retire or is_shred) then
127
+ raise exception 'keys_held_versions is append-only: an update must be exactly a rewrap (wrapped_key and kek_id together), a retire (retired_at NULL to non-null, once), or a shred of a row genuinely eligible for public.keys_shred_delay_days() -- refused, including un-retiring, un-shredding, shredding early, and any change to ciphertext or iv';
128
+ end if;
129
+ end if;
130
+ return new;
131
+ end
132
+ $$;
133
+
134
+ -- Serialize publication before INSERT takes a snapshot of other versions.
135
+ -- Reservations may finish out of order; current_version remains the reservation
136
+ -- counter, not a promise that that version was successfully published.
137
+ create or replace function public.keys_held_versions_publish_guard() returns trigger
138
+ language plpgsql
139
+ security definer
140
+ set search_path = pg_catalog, public, pg_temp
141
+ as $$
142
+ declare
143
+ revoked timestamptz;
144
+ begin
145
+ select revoked_at into revoked from public.keys_held_entries
146
+ where tenant_id = new.tenant_id and entry_id = new.entry_id for update;
147
+ if revoked is not null then
148
+ raise exception 'held entry is revoked';
149
+ end if;
150
+ if exists (select 1 from public.keys_held_versions
151
+ where tenant_id = new.tenant_id and entry_id = new.entry_id and version > new.version) then
152
+ new.retired_at := clock_timestamp();
153
+ end if;
154
+ return new;
155
+ end
156
+ $$;
157
+ drop trigger if exists keys_held_versions_publish on public.keys_held_versions;
158
+ create trigger keys_held_versions_publish before insert on public.keys_held_versions
159
+ for each row execute function public.keys_held_versions_publish_guard();
160
+
161
+ create or replace function public.keys_held_versions_retire_predecessors() returns trigger
162
+ language plpgsql
163
+ security definer
164
+ set search_path = pg_catalog, public, pg_temp
165
+ as $$
166
+ begin
167
+ update public.keys_held_versions set retired_at = clock_timestamp()
168
+ where tenant_id = new.tenant_id and entry_id = new.entry_id
169
+ and version < new.version and retired_at is null;
170
+ return null;
171
+ end
172
+ $$;
173
+
174
+ -- A transaction's start time is caller-controlled by holding it open. Stamp
175
+ -- clocks at the actual write, so long transactions cannot backdate eligibility.
176
+ create or replace function public.keys_held_entries_clock_guard() returns trigger
177
+ language plpgsql
178
+ set search_path = pg_catalog, public, pg_temp
179
+ as $$
180
+ begin
181
+ if tg_op = 'INSERT' then
182
+ if new.revoked_at is not null then
183
+ new.revoked_at := clock_timestamp();
184
+ end if;
185
+ if new.tenant_archived_at is not null then
186
+ new.tenant_archived_at := clock_timestamp();
187
+ end if;
188
+ return new;
189
+ elsif tg_op = 'UPDATE' then
190
+ if old.revoked_at is null and new.revoked_at is not null then
191
+ new.revoked_at := clock_timestamp();
192
+ end if;
193
+ if old.revoked_at is not null and new.revoked_at is distinct from old.revoked_at then
194
+ raise exception 'keys_held_entries: revoked_at may not be re-dated or cleared once set';
195
+ end if;
196
+
197
+ if old.tenant_archived_at is null and new.tenant_archived_at is not null then
198
+ new.tenant_archived_at := clock_timestamp();
199
+ end if;
200
+ if old.tenant_archived_at is not null and new.tenant_archived_at is distinct from old.tenant_archived_at then
201
+ raise exception 'keys_held_entries: tenant_archived_at may not be re-dated or cleared once set';
202
+ end if;
203
+ end if;
204
+ return new;
205
+ end
206
+ $$;
207
+
208
+ -- K03: keep issued revocations irreversible under ordinary table privileges.
209
+ -- Retaining IDs also prevents deleting and reinserting a revoked signed row.
210
+ create or replace function public.keys_issued_guard() returns trigger
211
+ language plpgsql
212
+ set search_path = pg_catalog, public, pg_temp
213
+ as $$
214
+ begin
215
+ if tg_op in ('DELETE', 'TRUNCATE') then
216
+ raise exception 'keys_issued_credentials: delete and truncate refused';
217
+ end if;
218
+ if tg_op = 'UPDATE' then
219
+ if new.id is distinct from old.id or new.issued_by_id is distinct from old.issued_by_id
220
+ or new.key_prefix is distinct from old.key_prefix then
221
+ raise exception 'keys_issued_credentials: identity and lineage are immutable';
222
+ end if;
223
+ if old.revoked_at is not null and new.revoked_at is distinct from old.revoked_at then
224
+ raise exception 'keys_issued_credentials: revocation is permanent';
225
+ end if;
226
+ end if;
227
+ return new;
228
+ end
229
+ $$;
230
+ drop trigger if exists keys_issued_guard_update on public.keys_issued_credentials;
231
+ create trigger keys_issued_guard_update before update or delete on public.keys_issued_credentials
232
+ for each row execute function public.keys_issued_guard();
233
+ drop trigger if exists keys_issued_guard_truncate on public.keys_issued_credentials;
234
+ create trigger keys_issued_guard_truncate before truncate on public.keys_issued_credentials
235
+ for each statement execute function public.keys_issued_guard();
236
+
237
+ do $$
238
+ declare rt text := current_database() || '_rt';
239
+ begin
240
+ revoke create on schema public from public;
241
+ if exists (select 1 from pg_roles where rolname = rt) then
242
+ execute format('revoke create on schema public from %I', rt);
243
+ execute format('revoke delete, truncate on public.keys_issued_credentials from %I', rt);
244
+ end if;
245
+ end
246
+ $$;
@@ -0,0 +1,52 @@
1
+ -- #47: a built-in `environment` on every issued credential, so a host's
2
+ -- sandbox/live split is a structured field rather than a convention the host
3
+ -- invents on top of `key_prefix`. Additive; do not edit copied migrations.
4
+ --
5
+ -- Not part of SignableRow (worker-contract.ts): the Worker's signature never
6
+ -- covers it, same footing as idempotency_key and created_at. What keeps it
7
+ -- trustworthy despite that is keys_issued_guard() below, extended to refuse
8
+ -- any UPDATE that changes it -- set once at issue(), for that row's life.
9
+ --
10
+ -- Backfilled 'live' on existing rows: every row minted before this migration
11
+ -- was minted under a single, unqualified prefix, which today's default
12
+ -- environment ('live') matches.
13
+ alter table keys_issued_credentials
14
+ add column if not exists environment text not null default 'live';
15
+ alter table keys_issued_credentials alter column environment drop default;
16
+
17
+ do $$
18
+ begin
19
+ if not exists (
20
+ select 1 from pg_constraint
21
+ where conname = 'keys_issued_credentials_environment_check'
22
+ ) then
23
+ alter table keys_issued_credentials
24
+ add constraint keys_issued_credentials_environment_check
25
+ check (environment in ('live', 'test'));
26
+ end if;
27
+ end
28
+ $$;
29
+
30
+ -- Extends 0004's keys_issued_guard(): identity, lineage AND environment are
31
+ -- immutable once a row is inserted.
32
+ create or replace function public.keys_issued_guard() returns trigger
33
+ language plpgsql
34
+ set search_path = pg_catalog, public, pg_temp
35
+ as $$
36
+ begin
37
+ if tg_op in ('DELETE', 'TRUNCATE') then
38
+ raise exception 'keys_issued_credentials: delete and truncate refused';
39
+ end if;
40
+ if tg_op = 'UPDATE' then
41
+ if new.id is distinct from old.id or new.issued_by_id is distinct from old.issued_by_id
42
+ or new.key_prefix is distinct from old.key_prefix
43
+ or new.environment is distinct from old.environment then
44
+ raise exception 'keys_issued_credentials: identity, lineage and environment are immutable';
45
+ end if;
46
+ if old.revoked_at is not null and new.revoked_at is distinct from old.revoked_at then
47
+ raise exception 'keys_issued_credentials: revocation is permanent';
48
+ end if;
49
+ end if;
50
+ return new;
51
+ end
52
+ $$;
@@ -0,0 +1,26 @@
1
+ -- #46: last-used and usage tracking on every issued credential, so an admin
2
+ -- UI (manage) can tell which issued keys are live versus stale. Additive; do
3
+ -- not edit copied migrations.
4
+ --
5
+ -- Neither column is part of SignableRow (worker-contract.ts) -- unsigned,
6
+ -- same footing as environment/idempotency_key/created_at (0005) -- and, per
7
+ -- keys_issued_guard() below, is NOT frozen the way those are: check() writes
8
+ -- both on every successful verification, so they must stay ordinarily
9
+ -- updatable rather than joining the identity/lineage/environment immutable
10
+ -- set.
11
+ alter table keys_issued_credentials
12
+ add column if not exists last_used_at timestamptz,
13
+ add column if not exists use_count bigint not null default 0;
14
+
15
+ do $$
16
+ begin
17
+ if not exists (
18
+ select 1 from pg_constraint
19
+ where conname = 'keys_issued_credentials_use_count_check'
20
+ ) then
21
+ alter table keys_issued_credentials
22
+ add constraint keys_issued_credentials_use_count_check
23
+ check (use_count >= 0);
24
+ end if;
25
+ end
26
+ $$;
@@ -0,0 +1,20 @@
1
+ -- #45: a per-issued-credential rate limit window, backing
2
+ -- `createPostgresRateLimiter` (`src/issued/ratelimit.ts`). Additive; do not
3
+ -- edit copied migrations.
4
+ --
5
+ -- One bounded row per credential, atomically reset each window by the same
6
+ -- upsert shape `packages/worker/src/ratelimit.ts`'s D1
7
+ -- `host_rate_limit_windows` uses for the host's own service credential --
8
+ -- this table is the same idea one level down, keyed by the END USER's
9
+ -- issued credential id rather than the host's.
10
+ --
11
+ -- No foreign key to keys_issued_credentials: a row here outlives a revoked
12
+ -- or even (in principle) a since-deleted credential without orphaning a
13
+ -- constraint, and check() only ever looks one up by an id it already
14
+ -- verified against a signed row.
15
+ create table if not exists keys_issued_rate_limit_windows (
16
+ credential_id text not null primary key,
17
+ window_start bigint not null,
18
+ count integer not null,
19
+ constraint keys_issued_rate_limit_windows_count_check check (count > 0)
20
+ );
@@ -0,0 +1,55 @@
1
+ /**
2
+ * #48: outbound HTTP delivery for the events `./issued`'s `AuditCallback`
3
+ * and `./held`'s `audit: (event: KeyUsedEvent) => …` already carry
4
+ * in-process. `createWebhookDispatcher` is a drop-in for either option --
5
+ * both already call their callback with one JSON-serializable event object
6
+ * carrying a `name` -- so a stamped service (agora, manage, operator) can
7
+ * subscribe to key lifecycle events over HTTP instead of polling another
8
+ * service's database.
9
+ *
10
+ * `node:crypto`, not WebCrypto: this runs host-side only, the same posture
11
+ * as `secret.ts`.
12
+ */
13
+ export interface WebhookDeliveryOptions {
14
+ readonly url: string;
15
+ /**
16
+ * HMAC-SHA256 over the raw JSON body, sent hex-encoded as
17
+ * `X-Keys-Signature`. Omit only when the endpoint's own network
18
+ * boundary (an internal-only URL, a bearer token baked into `url`) is
19
+ * the sole protection -- `verifyWebhookSignature` is how the receiver
20
+ * checks it.
21
+ */
22
+ readonly secret?: string;
23
+ /** Defaults to the global `fetch`. Tests pass a stub. */
24
+ readonly fetch?: typeof fetch;
25
+ /**
26
+ * Called with whatever made one delivery fail -- a thrown error, or an
27
+ * `Error` wrapping a non-2xx response -- and the event that failed to
28
+ * deliver. Never rethrown: a webhook subscriber's outage must never fail
29
+ * the `issue()`/`rotate()`/`revoke()`/`open()` call that produced the
30
+ * event. Delivery is fire-and-forget and best-effort, not at-least-once;
31
+ * a host that needs a retried or durable queue puts one in front of
32
+ * `url`, or wraps this callback itself.
33
+ */
34
+ readonly onDeliveryError?: (error: unknown, event: unknown) => void;
35
+ }
36
+ /**
37
+ * An `AuditCallback` (`./issued`) or `KeyUsedEvent` callback (`./held`) that
38
+ * POSTs the event as JSON to `options.url`.
39
+ */
40
+ export declare function createWebhookDispatcher<TEvent extends {
41
+ readonly name: string;
42
+ }>(options: WebhookDeliveryOptions): (event: TEvent) => Promise<void>;
43
+ /** Hex HMAC-SHA256 of `body` under `secret`. What `X-Keys-Signature` carries. */
44
+ export declare function signWebhookBody(body: string, secret: string): string;
45
+ /**
46
+ * Constant-time check that `signature` (as received in `X-Keys-Signature`)
47
+ * matches `body` under `secret`. What a webhook receiver calls before
48
+ * trusting a delivered event.
49
+ *
50
+ * `body` must be the exact raw request body bytes (as text) the signature
51
+ * was computed over -- parsing it to JSON and re-serializing it before
52
+ * verifying, or trimming/normalizing whitespace, changes the bytes and the
53
+ * signature will not match even for a genuine delivery.
54
+ */
55
+ export declare function verifyWebhookSignature(body: string, signature: string, secret: string): boolean;
@@ -0,0 +1,51 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * An `AuditCallback` (`./issued`) or `KeyUsedEvent` callback (`./held`) that
4
+ * POSTs the event as JSON to `options.url`.
5
+ */
6
+ export function createWebhookDispatcher(options) {
7
+ const doFetch = options.fetch ?? fetch;
8
+ return async (event) => {
9
+ try {
10
+ const body = JSON.stringify(event);
11
+ const headers = { 'content-type': 'application/json' };
12
+ if (options.secret !== undefined) {
13
+ headers['x-keys-signature'] = signWebhookBody(body, options.secret);
14
+ }
15
+ const response = await doFetch(options.url, { method: 'POST', headers, body });
16
+ if (!response.ok) {
17
+ throw new Error(`webhook delivery to ${options.url} got HTTP ${response.status}`);
18
+ }
19
+ }
20
+ catch (err) {
21
+ options.onDeliveryError?.(err, event);
22
+ }
23
+ };
24
+ }
25
+ /** Hex HMAC-SHA256 of `body` under `secret`. What `X-Keys-Signature` carries. */
26
+ export function signWebhookBody(body, secret) {
27
+ return createHmac('sha256', secret).update(body, 'utf8').digest('hex');
28
+ }
29
+ /**
30
+ * Constant-time check that `signature` (as received in `X-Keys-Signature`)
31
+ * matches `body` under `secret`. What a webhook receiver calls before
32
+ * trusting a delivered event.
33
+ *
34
+ * `body` must be the exact raw request body bytes (as text) the signature
35
+ * was computed over -- parsing it to JSON and re-serializing it before
36
+ * verifying, or trimming/normalizing whitespace, changes the bytes and the
37
+ * signature will not match even for a genuine delivery.
38
+ */
39
+ export function verifyWebhookSignature(body, signature, secret) {
40
+ const expected = Buffer.from(signWebhookBody(body, secret), 'hex');
41
+ let presented;
42
+ try {
43
+ presented = Buffer.from(signature, 'hex');
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ if (presented.length !== expected.length)
49
+ return false;
50
+ return timingSafeEqual(presented, expected);
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/keys",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The estate's own bearer keys, issued to callers and held on their behalf: two entries, issued and held, nothing stored in common.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,6 +22,10 @@
22
22
  "types": "./dist/held/index.d.ts",
23
23
  "default": "./dist/held/index.js"
24
24
  },
25
+ "./webhooks": {
26
+ "types": "./dist/webhooks.d.ts",
27
+ "default": "./dist/webhooks.js"
28
+ },
25
29
  "./migrations/*.sql": "./dist/migrations/*.sql",
26
30
  "./package.json": "./package.json"
27
31
  },
@@ -39,12 +43,12 @@
39
43
  "test": "vitest run"
40
44
  },
41
45
  "peerDependencies": {
42
- "drizzle-orm": ">=0.39.0"
46
+ "drizzle-orm": ">=0.45.2"
43
47
  },
44
48
  "devDependencies": {
45
49
  "@electric-sql/pglite": "^0.5.8",
46
50
  "@types/node": "^22",
47
- "drizzle-orm": "^0.39.3",
51
+ "drizzle-orm": "^0.45.2",
48
52
  "postgres": "^3.4.5",
49
53
  "typescript": "^5.9.0",
50
54
  "vitest": "^4.1.6"