@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.
- package/README.md +25 -1
- package/dist/held/index.d.ts +9 -6
- package/dist/held/index.js +77 -18
- package/dist/issued/index.d.ts +29 -3
- package/dist/issued/index.js +160 -57
- package/dist/issued/ratelimit.d.ts +34 -0
- package/dist/issued/ratelimit.js +49 -0
- package/dist/issued/secret.js +19 -5
- package/dist/issued/tables.d.ts +61 -0
- package/dist/issued/tables.js +13 -0
- package/dist/migrations/0004_keys_security.sql +246 -0
- package/dist/migrations/0005_keys_environment.sql +52 -0
- package/dist/migrations/0006_keys_usage.sql +26 -0
- package/dist/migrations/0007_keys_rate_limit.sql +20 -0
- package/dist/webhooks.d.ts +55 -0
- package/dist/webhooks.js +51 -0
- package/package.json +7 -3
|
@@ -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;
|
package/dist/webhooks.js
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
51
|
+
"drizzle-orm": "^0.45.2",
|
|
48
52
|
"postgres": "^3.4.5",
|
|
49
53
|
"typescript": "^5.9.0",
|
|
50
54
|
"vitest": "^4.1.6"
|