@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.
@@ -0,0 +1,361 @@
1
+ -- @wtfalch/keys ./held: the crypto-shred sweep and its guard trigger (#232).
2
+ -- Mirrors src/held/schema.ts's tenant_archived_at and src/held/shred.ts.
3
+ --
4
+ -- Applied by the host's own migrate script after this file is copied into
5
+ -- its drizzle/ directory as the next number (keys-migrations); never edited
6
+ -- there. Every statement is idempotent. Purely additive: adds one column,
7
+ -- four functions, six triggers, one revoke and one grant. Nothing here
8
+ -- drops or renames anything 0002 created.
9
+ --
10
+ -- What "eligible" means (app-template #225, revised answer): a
11
+ -- keys_held_versions row's data key is destroyed once it has been eligible
12
+ -- for keys_shred_delay_days() (30, matching the estate's backup retention;
13
+ -- see #217's revised answer) -- retired past its own grace window, its entry
14
+ -- revoked, or its tenant archived, whichever came first. retired_at is set
15
+ -- once, by an AFTER INSERT trigger (this migration) when a new version
16
+ -- supersedes it; revoked_at already exists on keys_held_entries (0002,
17
+ -- revokeEntry); tenant_archived_at is the one new column this file adds,
18
+ -- set once by archiveTenant() across every entry a tenant holds -- this
19
+ -- package has no tenants table of its own, so "tenant archived" is
20
+ -- recorded per entry rather than on a row it doesn't own.
21
+ --
22
+ -- keys_shred_expired() is SECURITY DEFINER and takes no row-targeting
23
+ -- argument on purpose (#225's refutation, finding 1): a targeted
24
+ -- (entry_id, version) function would turn an ordinary EXECUTE grant into a
25
+ -- way to destroy a still-live secret on demand. This function recomputes
26
+ -- eligibility itself from retired_at / revoked_at / tenant_archived_at, so
27
+ -- calling it early or from a compromised process destroys nothing beyond
28
+ -- what was already due -- PROVIDED those three columns are trustworthy,
29
+ -- which is what the clock-stamping trigger below exists to guarantee.
30
+ --
31
+ -- Three separate guards protect three separate things:
32
+ --
33
+ -- 1. keys_held_versions_guard: every UPDATE on keys_held_versions must be
34
+ -- exactly a rewrap (a non-null wrapped_key replaced by another non-null
35
+ -- one, together with a kek_id change, nothing else different), a retire
36
+ -- (retired_at NULL to non-null, once), or a shred of a row genuinely
37
+ -- eligible for keys_shred_delay_days() (wrapped_key to NULL, kek_id and
38
+ -- retired_at unchanged). Everything else is refused, including
39
+ -- un-retiring, un-shredding (NULL back to a value), re-dating a retire,
40
+ -- and any change to tenant_id, entry_id, version, iv, ciphertext or
41
+ -- created_at.
42
+ --
43
+ -- Shape alone is not privilege: a real refutation on #232 (PR #8) proved
44
+ -- the runtime role could satisfy the "rewrap" shape with GARBAGE --
45
+ -- wrapped_key set to arbitrary bytes plus a real kek_id change -- because
46
+ -- Postgres has no way to verify a rewrapped key's *content*, only its
47
+ -- shape. That destroys the value at once (it can never be unwrapped
48
+ -- again) with no clock started and nothing for shredClocks to alert on --
49
+ -- worse than an early shred, which at least leaves a trace. The fix the
50
+ -- plan already specified (#225: "The runtime role gets EXECUTE, never
51
+ -- UPDATE") is privilege, not shape: the runtime role no longer has
52
+ -- UPDATE on this table AT ALL (see Grants) -- SELECT and INSERT only, so
53
+ -- rewrap and retire cannot be its own ordinary writes any more, and the
54
+ -- guard's shape checks now matter only for whoever the host trusts with
55
+ -- an operator handle (see item 3, Grants).
56
+ --
57
+ -- ciphertext and iv are refused here, matching the plan's own list.
58
+ -- 0002's held.test.ts constructs an AAD-mismatch fixture with a direct
59
+ -- UPDATE of exactly those two columns; that test runs its one fixture
60
+ -- statement with this trigger disabled (ALTER TABLE ... DISABLE
61
+ -- TRIGGER, as the migration owner), with a comment saying so.
62
+ -- Overwriting ciphertext/iv makes a value unopenable at once, the same
63
+ -- effect as an early shred, so there is no case where leaving them
64
+ -- unguarded is defensible.
65
+ --
66
+ -- 2. keys_held_versions_retire_predecessors / keys_held_entries_clock_guard:
67
+ -- keys_shred_delay_days() elapsing means nothing if the clock it
68
+ -- measures from can be forged. #225 revised: "The write that starts the
69
+ -- clock is itself an alert through the housekeeping tick, so an operator
70
+ -- can reverse a forged one before the shred fires" -- which only holds if
71
+ -- the clock's own timestamp cannot predate the write that set it. A
72
+ -- caller with ordinary INSERT/UPDATE privilege on these columns (the
73
+ -- runtime role, for revoked_at / tenant_archived_at; nobody, now, for
74
+ -- retired_at -- see item 3) could otherwise set one to a timestamp 31
75
+ -- days in the past in one statement, and the very next
76
+ -- keys_shred_expired() tick would shred it before any housekeeping-tick
77
+ -- alert had a chance to fire, since the alert (src/held/shred.ts's
78
+ -- shredClocks) only sees a clock once it exists, and this one would
79
+ -- already read as 31 days old the moment it appeared. Both guards close
80
+ -- this: on INSERT or UPDATE, a clock column moving from NULL to
81
+ -- non-null is stamped to now() regardless of what value the caller
82
+ -- sent, and once set, any further change to that same column -- a
83
+ -- different value, or NULL -- is refused outright. A clock can start
84
+ -- once, and only at the instant its own write actually ran.
85
+ --
86
+ -- 3. keys_held_versions_retire_predecessors: retired_at used to be set by
87
+ -- an ordinary UPDATE inside put() (src/held/index.ts) -- which needed
88
+ -- the runtime role to hold UPDATE on this table, the same privilege the
89
+ -- refutation exploited for a garbage rewrap. Retiring is now a SIDE
90
+ -- EFFECT of the INSERT put() already performs, not a second write: this
91
+ -- AFTER INSERT, SECURITY DEFINER trigger retires the entry's other
92
+ -- still-open versions itself, running as its owner rather than the
93
+ -- inserting role, so the runtime role never needs UPDATE at all. Its own
94
+ -- internal UPDATE still passes through keys_held_versions_guard (a
95
+ -- trigger fires regardless of who -- or what -- issued the statement),
96
+ -- lands on the "retire" shape, and gets the same now()-stamped,
97
+ -- set-once clock as any other write to retired_at.
98
+ --
99
+ -- Rewrap has no equivalent side-effect path: it is a deliberate,
100
+ -- infrequent operator sweep (#217), not something any INSERT or ordinary
101
+ -- runtime activity should trigger on its own. src/held/index.ts's
102
+ -- rewrap() stays in the API, documented as requiring an operator
103
+ -- database handle -- the owner or migration role, never the runtime
104
+ -- one. keys_held_versions_guard still limits even that role to the
105
+ -- rewrap shape, as a guard against mistakes, not against malice: an
106
+ -- operator is trusted with the privilege; the shape check just keeps a
107
+ -- fumbled UPDATE from doing something else instead.
108
+ --
109
+ -- Grants, following @wtfalch/audit's pattern (src/migrations/0001_audit.sql):
110
+ -- a host names its runtime role <database>_rt, and the DO block below is a
111
+ -- no-op if that role doesn't exist -- the functions and triggers still exist
112
+ -- and still protect the tables either way. The runtime role keeps its
113
+ -- ordinary default SELECT and INSERT on keys_held_versions (put and open
114
+ -- need them; nothing else does now) and its ordinary SELECT, INSERT, UPDATE
115
+ -- on keys_held_entries (revokeEntry, archiveTenant, and the shred guard's
116
+ -- own eligibility lookup all need them) -- this migration does not touch
117
+ -- keys_held_entries' grants at all. UPDATE, DELETE and TRUNCATE on
118
+ -- keys_held_versions are revoked from the runtime role: #225's revised
119
+ -- answer keeps a shredded row (permanent but unreadable, matching ADR 0019
120
+ -- and audit rows), so DELETE/TRUNCATE were never legitimate; UPDATE is
121
+ -- revoked because every legitimate write to this table -- retire, shred --
122
+ -- now runs through a SECURITY DEFINER function rather than the runtime
123
+ -- role's own privilege, and the one write that still needs ordinary UPDATE
124
+ -- (rewrap) is an operator's job, not the runtime role's (item 3). The
125
+ -- runtime role gets EXECUTE on keys_shred_expired(), nothing wider.
126
+
127
+ alter table keys_held_entries
128
+ add column if not exists tenant_archived_at timestamptz;
129
+
130
+ -- Shared by keys_shred_expired() and keys_held_versions_guard, so the sweep
131
+ -- and the guard's own re-check can never disagree about how long a clock
132
+ -- must run. IMMUTABLE: a pure constant, safe to inline by the planner.
133
+ create or replace function keys_shred_delay_days() returns integer
134
+ language sql immutable as $$
135
+ select 30;
136
+ $$;
137
+
138
+ create or replace function keys_shred_expired()
139
+ returns integer
140
+ language plpgsql
141
+ security definer
142
+ set search_path = pg_catalog, public
143
+ as $$
144
+ declare
145
+ shredded integer;
146
+ begin
147
+ update keys_held_versions kv
148
+ set wrapped_key = null
149
+ from keys_held_entries ke
150
+ where kv.tenant_id = ke.tenant_id
151
+ and kv.entry_id = ke.entry_id
152
+ and kv.wrapped_key is not null
153
+ and (
154
+ (kv.retired_at is not null
155
+ and now() >= kv.retired_at + (keys_shred_delay_days() || ' days')::interval)
156
+ or (ke.revoked_at is not null
157
+ and now() >= ke.revoked_at + (keys_shred_delay_days() || ' days')::interval)
158
+ or (ke.tenant_archived_at is not null
159
+ and now() >= ke.tenant_archived_at + (keys_shred_delay_days() || ' days')::interval)
160
+ );
161
+ get diagnostics shredded = row_count;
162
+ return shredded;
163
+ end
164
+ $$;
165
+ revoke all on function keys_shred_expired() from public;
166
+
167
+ create or replace function keys_held_versions_guard() returns trigger
168
+ language plpgsql as $$
169
+ declare
170
+ is_rewrap boolean;
171
+ is_retire boolean;
172
+ is_shred boolean;
173
+ begin
174
+ if tg_op = 'INSERT' then
175
+ -- Nothing that inserts a row -- put(), or keys_held_versions_retire_predecessors
176
+ -- below, which never inserts -- ever does so with retired_at set; this
177
+ -- guards a raw INSERT bypassing that, the same way the UPDATE branch
178
+ -- below guards a raw UPDATE.
179
+ if new.retired_at is not null then
180
+ new.retired_at := now();
181
+ end if;
182
+ return new;
183
+ elsif tg_op = 'DELETE' then
184
+ raise exception 'keys_held_versions is append-only: delete refused';
185
+ elsif tg_op = 'TRUNCATE' then
186
+ raise exception 'keys_held_versions is append-only: truncate refused';
187
+ elsif tg_op = 'UPDATE' then
188
+ -- Stamp: retired_at moving from NULL to non-null is forced to now(),
189
+ -- whatever the caller sent. Must run before the shape checks below, so
190
+ -- is_retire/is_shred see the real value, not a forged one.
191
+ if old.retired_at is null and new.retired_at is not null then
192
+ new.retired_at := now();
193
+ end if;
194
+ -- Once set, retired_at is fixed: no re-dating, no clearing.
195
+ if old.retired_at is not null and new.retired_at is distinct from old.retired_at then
196
+ raise exception 'keys_held_versions is append-only: retired_at may not be re-dated or cleared once set';
197
+ end if;
198
+
199
+ if new.tenant_id is distinct from old.tenant_id
200
+ or new.entry_id is distinct from old.entry_id
201
+ or new.version is distinct from old.version
202
+ or new.iv is distinct from old.iv
203
+ or new.ciphertext is distinct from old.ciphertext
204
+ or new.created_at is distinct from old.created_at
205
+ then
206
+ raise exception 'keys_held_versions is append-only: tenant_id, entry_id, version, iv, ciphertext and created_at may never change';
207
+ end if;
208
+
209
+ -- rewrap: an operator sweep (#217), run with an operator database
210
+ -- handle -- see the file header, item 3. The data key moves to a new
211
+ -- kek generation; wrapped_key and kek_id change together, retired_at
212
+ -- untouched either way. The guard cannot verify the new wrapped_key is
213
+ -- a genuine rewrap of the old one -- only Postgres-visible shape, never
214
+ -- content -- which is exactly why the runtime role no longer has
215
+ -- UPDATE at all (see Grants): this shape check is a guard against a
216
+ -- fumbled operator statement, not a security boundary on its own.
217
+ is_rewrap := old.wrapped_key is not null
218
+ and new.wrapped_key is not null
219
+ and new.wrapped_key is distinct from old.wrapped_key
220
+ and new.kek_id is distinct from old.kek_id
221
+ and new.retired_at is not distinct from old.retired_at;
222
+
223
+ -- retire: keys_held_versions_retire_predecessors, below, the only
224
+ -- thing that performs this write now. retired_at moves from NULL to a
225
+ -- value (now stamped, above), once, with nothing else about the row
226
+ -- changing.
227
+ is_retire := old.retired_at is null
228
+ and new.retired_at is not null
229
+ and new.wrapped_key is not distinct from old.wrapped_key
230
+ and new.kek_id is not distinct from old.kek_id;
231
+
232
+ -- shred (keys_shred_expired(), or an operator's own ordinary UPDATE
233
+ -- with the same shape -- the trigger cannot and does not distinguish
234
+ -- the two): the shape alone is not enough -- this row's own stored
235
+ -- clocks, which can no longer be forged (see the file header), must
236
+ -- already show keys_shred_delay_days() elapsed, the same predicate
237
+ -- keys_shred_expired()'s WHERE clause applies.
238
+ is_shred := old.wrapped_key is not null
239
+ and new.wrapped_key is null
240
+ and new.kek_id is not distinct from old.kek_id
241
+ and new.retired_at is not distinct from old.retired_at
242
+ and (
243
+ (new.retired_at is not null
244
+ and now() >= new.retired_at + (keys_shred_delay_days() || ' days')::interval)
245
+ or exists (
246
+ select 1 from keys_held_entries ke
247
+ where ke.tenant_id = new.tenant_id
248
+ and ke.entry_id = new.entry_id
249
+ and (
250
+ (ke.revoked_at is not null
251
+ and now() >= ke.revoked_at + (keys_shred_delay_days() || ' days')::interval)
252
+ or (ke.tenant_archived_at is not null
253
+ and now() >= ke.tenant_archived_at + (keys_shred_delay_days() || ' days')::interval)
254
+ )
255
+ )
256
+ );
257
+
258
+ if not (is_rewrap or is_retire or is_shred) then
259
+ 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 keys_shred_delay_days() -- refused, including un-retiring, un-shredding, shredding early, and any change to ciphertext or iv';
260
+ end if;
261
+ end if;
262
+ return new;
263
+ end
264
+ $$;
265
+ drop trigger if exists keys_held_versions_stamp_insert on keys_held_versions;
266
+ create trigger keys_held_versions_stamp_insert
267
+ before insert on keys_held_versions
268
+ for each row execute function keys_held_versions_guard();
269
+ drop trigger if exists keys_held_versions_guarded_update on keys_held_versions;
270
+ create trigger keys_held_versions_guarded_update
271
+ before update on keys_held_versions
272
+ for each row execute function keys_held_versions_guard();
273
+ drop trigger if exists keys_held_versions_no_delete on keys_held_versions;
274
+ create trigger keys_held_versions_no_delete
275
+ before delete on keys_held_versions
276
+ for each row execute function keys_held_versions_guard();
277
+ drop trigger if exists keys_held_versions_no_truncate on keys_held_versions;
278
+ create trigger keys_held_versions_no_truncate
279
+ before truncate on keys_held_versions
280
+ for each statement execute function keys_held_versions_guard();
281
+
282
+ -- The retire-on-supersede write itself (file header, item 3): runs after
283
+ -- put()'s own INSERT commits, as this function's owner rather than the
284
+ -- inserting role, so the runtime role's INSERT privilege alone is enough to
285
+ -- retire a superseded version -- it never needs UPDATE. "Predecessors"
286
+ -- (plural) rather than "the previous version": retires every OTHER
287
+ -- still-open version of this entry, not just NEW.version - 1, so the
288
+ -- function stays correct even if versions were ever inserted out of order.
289
+ create or replace function keys_held_versions_retire_predecessors() returns trigger
290
+ language plpgsql
291
+ security definer
292
+ set search_path = pg_catalog, public
293
+ as $$
294
+ begin
295
+ update keys_held_versions
296
+ set retired_at = now()
297
+ where tenant_id = new.tenant_id
298
+ and entry_id = new.entry_id
299
+ and version <> new.version
300
+ and retired_at is null;
301
+ return null;
302
+ end
303
+ $$;
304
+ drop trigger if exists keys_held_versions_retire_predecessors_insert on keys_held_versions;
305
+ create trigger keys_held_versions_retire_predecessors_insert
306
+ after insert on keys_held_versions
307
+ for each row execute function keys_held_versions_retire_predecessors();
308
+
309
+ -- The keys_held_entries half of the same clock-stamping guarantee, for
310
+ -- revoked_at (0002) and tenant_archived_at (this file). Deliberately
311
+ -- narrower than keys_held_versions_guard: it says nothing about
312
+ -- current_version or any other column on this table, only that these two
313
+ -- clocks can never be backdated, re-dated, or cleared once set.
314
+ create or replace function keys_held_entries_clock_guard() returns trigger
315
+ language plpgsql as $$
316
+ begin
317
+ if tg_op = 'INSERT' then
318
+ if new.revoked_at is not null then
319
+ new.revoked_at := now();
320
+ end if;
321
+ if new.tenant_archived_at is not null then
322
+ new.tenant_archived_at := now();
323
+ end if;
324
+ return new;
325
+ elsif tg_op = 'UPDATE' then
326
+ if old.revoked_at is null and new.revoked_at is not null then
327
+ new.revoked_at := now();
328
+ end if;
329
+ if old.revoked_at is not null and new.revoked_at is distinct from old.revoked_at then
330
+ raise exception 'keys_held_entries: revoked_at may not be re-dated or cleared once set';
331
+ end if;
332
+
333
+ if old.tenant_archived_at is null and new.tenant_archived_at is not null then
334
+ new.tenant_archived_at := now();
335
+ end if;
336
+ if old.tenant_archived_at is not null and new.tenant_archived_at is distinct from old.tenant_archived_at then
337
+ raise exception 'keys_held_entries: tenant_archived_at may not be re-dated or cleared once set';
338
+ end if;
339
+ end if;
340
+ return new;
341
+ end
342
+ $$;
343
+ drop trigger if exists keys_held_entries_clock_stamp_insert on keys_held_entries;
344
+ create trigger keys_held_entries_clock_stamp_insert
345
+ before insert on keys_held_entries
346
+ for each row execute function keys_held_entries_clock_guard();
347
+ drop trigger if exists keys_held_entries_clock_guard_update on keys_held_entries;
348
+ create trigger keys_held_entries_clock_guard_update
349
+ before update on keys_held_entries
350
+ for each row execute function keys_held_entries_clock_guard();
351
+
352
+ do $$
353
+ declare
354
+ rt text := current_database() || '_rt';
355
+ begin
356
+ if exists (select 1 from pg_roles where rolname = rt) then
357
+ execute format('revoke update, delete, truncate on keys_held_versions from %I', rt);
358
+ execute format('grant execute on function keys_shred_expired() to %I', rt);
359
+ end if;
360
+ end
361
+ $$;