@wtfalch/keys 0.2.1 → 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 CHANGED
@@ -27,3 +27,19 @@ Apply the additive `0004_keys_security.sql` migration in every consumer database
27
27
  New bearer secrets have a 32-character base32 tail (160 random bits). Existing 20-character tails remain accepted. Rotation refuses invalid stored signatures and returns `conflict` if the row changes during signing.
28
28
 
29
29
  Held keys use active cache expiry (60 seconds by default) and retain at most 256 versions per instance. Set `cacheSeconds: 0` or `maxCacheEntries: 0` to disable caching. Call `dispose()` to zero cached keys, clear timers and refuse further operations. A local forget/revocation invalidates pending opens; other processes remain bounded by their own cache TTL. Plaintext callbacks can copy or return values, so their lifetime cannot be enforced by the SDK.
30
+
31
+ ## Issued-key environments
32
+
33
+ Every issued credential carries a built-in `environment`, `'live'` or `'test'` (default `'live'`), passed on `issue()` and reported by `check()` and `lineageOf()`. A credential can only mint a child in its own environment — `issue()` returns `{ ok: false, reason: 'environment_mismatch' }` otherwise. Apply `0005_keys_environment.sql` alongside `0004_keys_security.sql`.
34
+
35
+ ## Issued-key usage tracking
36
+
37
+ Every successful `check()` stamps `lastUsedAt` and increments `useCount` on the credential's row — read them off `KeysIssuedCredentialRow` directly (an admin UI's own query, not a new `check()` return field). The write is best-effort: it never turns a genuine secret into a refusal, and a revoked, expired, tampered or wrong-secret check never bumps it. Apply `0006_keys_usage.sql` alongside `0004_keys_security.sql` and `0005_keys_environment.sql`.
38
+
39
+ ## Per-issued-key rate limits
40
+
41
+ `CreateCredentialIssuerOptions.rateLimiter` (optional) is consulted on every `check()`, keyed by the credential's own `id` — spent on every attempt against a known credential, matched secret or not, so an unlimited number of wrong guesses can't get around it. Refused as `{ ok: false, reason: 'rate_limited', retryAfterMs }`. `createPostgresRateLimiter({ db, limitPerWindow, windowMs? })` is a ready-made fixed-window limiter backed by the host's own Postgres (`0007_keys_rate_limit.sql`, applied alongside `0004`–`0006`); bring your own `RateLimiter` for anything else (Redis, D1, …). Omit it and `check()` behaves exactly as before this existed — this was previously only enforced on the host's own service credential (`packages/worker/src/ratelimit.ts`), never on an individual end-user issued key.
42
+
43
+ ## Outbound webhooks
44
+
45
+ `@wtfalch/keys/webhooks`'s `createWebhookDispatcher({ url, secret? })` returns a function that POSTs a lifecycle event as JSON — a drop-in for `./issued`'s `audit` option (`credential.minted` / `.rotated` / `.revoked`) or `./held`'s (`key.used`). With `secret`, the body is HMAC-SHA256-signed into an `X-Keys-Signature` header; the receiver checks it with `verifyWebhookSignature(body, signature, secret)`. Delivery is fire-and-forget and best-effort — a subscriber outage never fails the call that produced the event, it only calls `onDeliveryError`.
@@ -1,7 +1,16 @@
1
1
  import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
2
  import type { SerializedCredential, VerifyKeys, WorkerClient, WorkerErrorCode } from '../worker-contract.js';
3
+ import type { RateLimiter } from './ratelimit.js';
3
4
  export { ID_PATTERN, SIGNED_PREFIX, type RequestMeta, type SerializedCredential, type SignableRow, type SignedKind, type SignedRow, type SignRowRequest, type SignRowResult, type SignRowSuccess, type VerifyKeys, type WorkerClient, type WorkerError, type WorkerErrorCode, type WorkerFailure, } from '../worker-contract.js';
5
+ export { createPostgresRateLimiter, type CreatePostgresRateLimiterOptions, type RateLimiter, type RateLimitResult, } from './ratelimit.js';
4
6
  export type Db = PgDatabase<PgQueryResultHKT, any, any>;
7
+ /**
8
+ * #47: a built-in sandbox/live split, so a stamped host does not invent its
9
+ * own convention on top of `prefix`. Stored on the row (`environment`,
10
+ * immutable once set) but never signed -- `prefix` alone still decides what
11
+ * a presented secret is, same as before this existed.
12
+ */
13
+ export type KeyEnvironment = 'live' | 'test';
5
14
  export interface AuditEvent {
6
15
  readonly name: 'credential.minted' | 'credential.rotated' | 'credential.revoked';
7
16
  readonly credentialId: string;
@@ -22,6 +31,13 @@ export interface CreateCredentialIssuerOptions<TGrant> {
22
31
  readonly audit?: AuditCallback;
23
32
  /** This host's split-secret prefix, e.g. `ai_live_`. Fixed per issuer; never inferred from a row. */
24
33
  readonly prefix: string;
34
+ /**
35
+ * #45: an abuse limit `check()` consults per credential `id`, spent on
36
+ * every attempt (matched secret or not) once a row is found. Omit it and
37
+ * `check()` behaves exactly as before this existed. `createPostgresRateLimiter`
38
+ * (this module) is a ready-made one backed by the host's own `db`.
39
+ */
40
+ readonly rateLimiter?: RateLimiter;
25
41
  /** Unix milliseconds. Defaults to `Date.now`; a test passes a fake clock. */
26
42
  readonly now?: () => number;
27
43
  }
@@ -32,8 +48,12 @@ export interface IssueRequest<TGrant> {
32
48
  /** Unix milliseconds. Required: refused when missing or already past, never clamped to the minter's own. */
33
49
  readonly expiresAt: number;
34
50
  readonly idempotencyKey?: string;
51
+ /** Default `'live'`. A child must match its minter's environment -- see `IssueRefusalReason`. */
52
+ readonly environment?: KeyEnvironment;
35
53
  }
36
- export type IssueRefusalReason = 'expires_at_required' | 'expires_at_past' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
54
+ export type IssueRefusalReason = 'expires_at_required' | 'expires_at_past' | 'minter_not_found' | 'minter_revoked' | 'minter_expired'
55
+ /** #47: `request.environment` (or its `'live'` default) does not match the minter row's own. */
56
+ | 'environment_mismatch' | WorkerErrorCode;
37
57
  export type IssueResult = {
38
58
  readonly ok: true;
39
59
  readonly id: string;
@@ -44,15 +64,20 @@ export type IssueResult = {
44
64
  readonly ok: false;
45
65
  readonly reason: IssueRefusalReason;
46
66
  };
47
- export type CheckRefusalReason = 'not_found' | 'signature_invalid' | 'revoked' | 'expired' | 'secret_mismatch';
67
+ export type CheckRefusalReason = 'not_found' | 'signature_invalid' | 'revoked' | 'expired' | 'secret_mismatch'
68
+ /** #45: `rateLimiter` refused this credential id for the current window. */
69
+ | 'rate_limited';
48
70
  export type CheckResult<TGrant> = {
49
71
  readonly ok: true;
50
72
  readonly id: string;
51
73
  readonly issuedById: string | null;
52
74
  readonly grants: readonly TGrant[];
75
+ readonly environment: KeyEnvironment;
53
76
  } | {
54
77
  readonly ok: false;
55
78
  readonly reason: CheckRefusalReason;
79
+ /** Only meaningful with `reason: 'rate_limited'`. */
80
+ readonly retryAfterMs?: number;
56
81
  };
57
82
  export type RotateRefusalReason = 'signature_invalid' | 'conflict' | 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
58
83
  export type RotateResult = {
@@ -68,6 +93,7 @@ export interface CredentialLink<TGrant> {
68
93
  readonly grants: readonly TGrant[];
69
94
  readonly expiresAt: number;
70
95
  readonly revokedAt: number | null;
96
+ readonly environment: KeyEnvironment;
71
97
  }
72
98
  export interface CredentialIssuer<TGrant> {
73
99
  /** Calls `worker.signRow`. */
@@ -6,6 +6,7 @@ import { keyPrefixOf, mintSecret, rotateSecret, secretMatches } from './secret.j
6
6
  import { keysIssuedCredentials } from './tables.js';
7
7
  import { verifyIssuedRowSignature } from './verify.js';
8
8
  export { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
9
+ export { createPostgresRateLimiter, } from './ratelimit.js';
9
10
  function rowsOf(result) {
10
11
  if (Array.isArray(result))
11
12
  return result;
@@ -27,7 +28,7 @@ function toSignableRow(row) {
27
28
  };
28
29
  }
29
30
  export function createCredentialIssuer(options) {
30
- const { db, worker, credential, verifyKeys, audit, prefix } = options;
31
+ const { db, worker, credential, verifyKeys, audit, prefix, rateLimiter } = options;
31
32
  const now = options.now ?? (() => Date.now());
32
33
  const table = keysIssuedCredentials;
33
34
  async function loadById(id, handle = db) {
@@ -78,6 +79,7 @@ export function createCredentialIssuer(options) {
78
79
  if (request.expiresAt <= now()) {
79
80
  return { ok: false, reason: 'expires_at_past' };
80
81
  }
82
+ const environment = request.environment ?? 'live';
81
83
  let minterAttestation = null;
82
84
  if (request.minter !== null) {
83
85
  const minterRow = await loadById(request.minter);
@@ -90,6 +92,11 @@ export function createCredentialIssuer(options) {
90
92
  return { ok: false, reason: 'minter_revoked' };
91
93
  if (minterRow.expiresAt <= now())
92
94
  return { ok: false, reason: 'minter_expired' };
95
+ // #47: a test key can only mint a test key, a live key only a live
96
+ // one -- the pairing the issue asked for, not just a label.
97
+ if (minterRow.environment !== environment) {
98
+ return { ok: false, reason: 'environment_mismatch' };
99
+ }
93
100
  minterAttestation = {
94
101
  row: toSignableRow(minterRow),
95
102
  signature: fromBase64Url(minterRow.signature),
@@ -127,6 +134,8 @@ export function createCredentialIssuer(options) {
127
134
  return 'minter_revoked';
128
135
  if (parent.expiresAt <= now())
129
136
  return 'minter_expired';
137
+ if (parent.environment !== environment)
138
+ return 'environment_mismatch';
130
139
  if (!minterAttestation ||
131
140
  parent.signature !== toBase64Url(minterAttestation.signature) ||
132
141
  canonicalJson(toSignableRow(parent)) !== canonicalJson(minterAttestation.row)) {
@@ -143,6 +152,7 @@ export function createCredentialIssuer(options) {
143
152
  signature: toBase64Url(result.signature),
144
153
  signingGenerationId: result.generationId,
145
154
  idempotencyKey: request.idempotencyKey ?? null,
155
+ environment,
146
156
  });
147
157
  return null;
148
158
  });
@@ -173,6 +183,24 @@ export function createCredentialIssuer(options) {
173
183
  const row = await loadByPrefix(keyPrefix);
174
184
  if (!row)
175
185
  return { ok: false, reason: 'not_found' };
186
+ // #45: spent on every attempt against a known credential, matched
187
+ // secret or not -- an unlimited number of wrong guesses against an
188
+ // identified row would otherwise let an attacker brute-force it past
189
+ // any per-successful-use limit. Before the signature check below, so a
190
+ // flood of requests against one credential id never reaches the more
191
+ // expensive Ed25519 verify.
192
+ // A `rateLimiter.check` failure is NOT caught, unlike #46's usage
193
+ // write below: rate limiting is a security control the host opted
194
+ // into, and failing open on it (silently never limiting anything
195
+ // whenever its store is unreachable) is a worse failure mode than a
196
+ // loud one a host notices in its own staging environment the first
197
+ // time the migration is missing or the store is down.
198
+ if (rateLimiter) {
199
+ const limit = await rateLimiter.check(row.id, now());
200
+ if (limit.limited) {
201
+ return { ok: false, reason: 'rate_limited', retryAfterMs: limit.retryAfterMs };
202
+ }
203
+ }
176
204
  // Verified before anything else is trusted about the row: a signature
177
205
  // check over the row's CURRENT fields refuses a row a raw UPDATE
178
206
  // tampered with, whatever that tamper changed.
@@ -184,26 +212,34 @@ export function createCredentialIssuer(options) {
184
212
  return { ok: false, reason: 'revoked' };
185
213
  if (now() >= row.expiresAt)
186
214
  return { ok: false, reason: 'expired' };
187
- if (secretMatches(secret, row.secretHash)) {
188
- return {
189
- ok: true,
190
- id: row.id,
191
- issuedById: row.issuedById,
192
- grants: row.grants,
193
- };
215
+ const matched = secretMatches(secret, row.secretHash) ||
216
+ (row.previousSecretHash !== null &&
217
+ row.previousValidUntil !== null &&
218
+ now() < row.previousValidUntil &&
219
+ secretMatches(secret, row.previousSecretHash));
220
+ if (!matched)
221
+ return { ok: false, reason: 'secret_mismatch' };
222
+ // #46: last-used/use-count, for an admin UI to read directly off the
223
+ // row -- best-effort. A write failure here is observability lost, not
224
+ // an authorization decision, so it must never turn an otherwise-genuine
225
+ // secret into a refusal; check() stays available for the one thing it
226
+ // exists to do even if this bookkeeping write cannot land.
227
+ try {
228
+ await db
229
+ .update(table)
230
+ .set({ lastUsedAt: new Date(now()), useCount: sql `${table.useCount} + 1` })
231
+ .where(eq(table.id, row.id));
194
232
  }
195
- if (row.previousSecretHash !== null &&
196
- row.previousValidUntil !== null &&
197
- now() < row.previousValidUntil &&
198
- secretMatches(secret, row.previousSecretHash)) {
199
- return {
200
- ok: true,
201
- id: row.id,
202
- issuedById: row.issuedById,
203
- grants: row.grants,
204
- };
233
+ catch {
234
+ // Swallowed by design -- see comment above.
205
235
  }
206
- return { ok: false, reason: 'secret_mismatch' };
236
+ return {
237
+ ok: true,
238
+ id: row.id,
239
+ issuedById: row.issuedById,
240
+ grants: row.grants,
241
+ environment: row.environment,
242
+ };
207
243
  }
208
244
  async function rotate(id, opts) {
209
245
  const row = await loadById(id);
@@ -324,6 +360,7 @@ export function createCredentialIssuer(options) {
324
360
  grants: row.grants,
325
361
  expiresAt: row.expiresAt,
326
362
  revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
363
+ environment: row.environment,
327
364
  });
328
365
  currentId = row.issuedById;
329
366
  }
@@ -0,0 +1,34 @@
1
+ import type { Db } from './index.js';
2
+ /**
3
+ * #45: a per-issued-credential abuse limit. `packages/worker/src/ratelimit.ts`
4
+ * already limits per `hostId` + the HOST's own service credential; nothing
5
+ * limited an END USER's individual issued key until this. `check()` consults
6
+ * `CreateCredentialIssuerOptions.rateLimiter`, when one is given, keyed by
7
+ * the credential's own `id` -- once the row is found (so a known
8
+ * credential's budget is spent on every attempt, matched secret or not,
9
+ * not only on a successful one), before its secret is verified.
10
+ */
11
+ export interface RateLimitResult {
12
+ readonly limited: boolean;
13
+ /** Milliseconds until the current window resets. Meaningful whether or not `limited`. */
14
+ readonly retryAfterMs: number;
15
+ }
16
+ export interface RateLimiter {
17
+ /** `now`: Unix milliseconds, the same clock `check()`'s own `now` option gives -- never read internally, so a fake clock in a test reaches this too. */
18
+ check(credentialId: string, now: number): Promise<RateLimitResult>;
19
+ }
20
+ export interface CreatePostgresRateLimiterOptions {
21
+ readonly db: Db;
22
+ /** Requests a single credential may make per window before `limited` is true. */
23
+ readonly limitPerWindow: number;
24
+ /** Milliseconds. Default 60_000. */
25
+ readonly windowMs?: number;
26
+ }
27
+ /**
28
+ * A fixed-window counter in the host's own Postgres, one bounded row per
29
+ * credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
30
+ * atomically reset each window by a single upsert -- the same shape
31
+ * `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
32
+ * level down: keyed by the end user's credential id, not the host's.
33
+ */
34
+ export declare function createPostgresRateLimiter(options: CreatePostgresRateLimiterOptions): RateLimiter;
@@ -0,0 +1,49 @@
1
+ import { sql } from 'drizzle-orm';
2
+ function rowsOf(result) {
3
+ if (Array.isArray(result))
4
+ return result;
5
+ return (result.rows ?? []);
6
+ }
7
+ /**
8
+ * A fixed-window counter in the host's own Postgres, one bounded row per
9
+ * credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
10
+ * atomically reset each window by a single upsert -- the same shape
11
+ * `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
12
+ * level down: keyed by the end user's credential id, not the host's.
13
+ */
14
+ export function createPostgresRateLimiter(options) {
15
+ const { db, limitPerWindow } = options;
16
+ const windowMs = options.windowMs ?? 60_000;
17
+ return {
18
+ async check(credentialId, now) {
19
+ const windowStart = Math.floor(now / windowMs) * windowMs;
20
+ const result = await db.execute(sql `
21
+ insert into keys_issued_rate_limit_windows (credential_id, window_start, count)
22
+ values (${credentialId}, ${windowStart}, 1)
23
+ on conflict (credential_id) do update set
24
+ window_start = excluded.window_start,
25
+ count = case
26
+ when keys_issued_rate_limit_windows.window_start = excluded.window_start
27
+ then keys_issued_rate_limit_windows.count + 1
28
+ else 1
29
+ end
30
+ returning window_start, count
31
+ `);
32
+ const [row] = rowsOf(result);
33
+ if (!row)
34
+ throw new Error('rate limit write returned no row');
35
+ // `count` is `integer` (int4), always JS-number-safe -- but a raw
36
+ // (non-query-builder) result is still driver-dependent about handing
37
+ // it back as a number or a numeric string, so `Number(...)` either
38
+ // way. `window_start` itself is never re-read from the row: the
39
+ // upsert always sets it to `excluded.window_start`, never
40
+ // conditionally kept, so it equals the `windowStart` just computed
41
+ // above.
42
+ const count = Number(row.count);
43
+ return {
44
+ limited: count > limitPerWindow,
45
+ retryAfterMs: windowStart + windowMs - now,
46
+ };
47
+ },
48
+ };
49
+ }
@@ -16,6 +16,16 @@
16
16
  * a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
17
17
  * nothing this package's own tests check, since the constraint that actually
18
18
  * runs is the one in the SQL migration.
19
+ *
20
+ * `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
21
+ * footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
22
+ * extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
23
+ * once at `issue()`, immutable for the row's life.
24
+ *
25
+ * `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
26
+ * this row that is NOT set-once: `check()` writes both on every successful
27
+ * verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
28
+ * them out of its immutable-columns check.
19
29
  */
20
30
  export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTableWithColumns<{
21
31
  name: "keys_issued_credentials";
@@ -208,6 +218,23 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
208
218
  identity: undefined;
209
219
  generated: undefined;
210
220
  }, {}, {}>;
221
+ environment: import("drizzle-orm/pg-core").PgColumn<{
222
+ name: "environment";
223
+ tableName: "keys_issued_credentials";
224
+ dataType: "string";
225
+ columnType: "PgText";
226
+ data: string;
227
+ driverParam: string;
228
+ notNull: true;
229
+ hasDefault: false;
230
+ isPrimaryKey: false;
231
+ isAutoincrement: false;
232
+ hasRuntimeDefault: false;
233
+ enumValues: [string, ...string[]];
234
+ baseColumn: never;
235
+ identity: undefined;
236
+ generated: undefined;
237
+ }, {}, {}>;
211
238
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
212
239
  name: "created_at";
213
240
  tableName: "keys_issued_credentials";
@@ -242,6 +269,40 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
242
269
  identity: undefined;
243
270
  generated: undefined;
244
271
  }, {}, {}>;
272
+ lastUsedAt: import("drizzle-orm/pg-core").PgColumn<{
273
+ name: "last_used_at";
274
+ tableName: "keys_issued_credentials";
275
+ dataType: "date";
276
+ columnType: "PgTimestamp";
277
+ data: Date;
278
+ driverParam: string;
279
+ notNull: false;
280
+ hasDefault: false;
281
+ isPrimaryKey: false;
282
+ isAutoincrement: false;
283
+ hasRuntimeDefault: false;
284
+ enumValues: undefined;
285
+ baseColumn: never;
286
+ identity: undefined;
287
+ generated: undefined;
288
+ }, {}, {}>;
289
+ useCount: import("drizzle-orm/pg-core").PgColumn<{
290
+ name: "use_count";
291
+ tableName: "keys_issued_credentials";
292
+ dataType: "number";
293
+ columnType: "PgBigInt53";
294
+ data: number;
295
+ driverParam: string | number;
296
+ notNull: true;
297
+ hasDefault: true;
298
+ isPrimaryKey: false;
299
+ isAutoincrement: false;
300
+ hasRuntimeDefault: false;
301
+ enumValues: undefined;
302
+ baseColumn: never;
303
+ identity: undefined;
304
+ generated: undefined;
305
+ }, {}, {}>;
245
306
  };
246
307
  dialect: "pg";
247
308
  }>;
@@ -17,6 +17,16 @@ import { bigint, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
17
17
  * a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
18
18
  * nothing this package's own tests check, since the constraint that actually
19
19
  * runs is the one in the SQL migration.
20
+ *
21
+ * `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
22
+ * footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
23
+ * extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
24
+ * once at `issue()`, immutable for the row's life.
25
+ *
26
+ * `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
27
+ * this row that is NOT set-once: `check()` writes both on every successful
28
+ * verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
29
+ * them out of its immutable-columns check.
20
30
  */
21
31
  export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
22
32
  id: text('id').primaryKey(),
@@ -30,6 +40,9 @@ export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
30
40
  signature: text('signature').notNull(),
31
41
  signingGenerationId: text('signing_generation_id').notNull(),
32
42
  idempotencyKey: text('idempotency_key'),
43
+ environment: text('environment').notNull(),
33
44
  createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
34
45
  revokedAt: timestamp('revoked_at', { withTimezone: true }),
46
+ lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
47
+ useCount: bigint('use_count', { mode: 'number' }).notNull().default(0),
35
48
  });
@@ -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.2.1",
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
  },