@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 +16 -0
- package/dist/issued/index.d.ts +28 -2
- package/dist/issued/index.js +56 -19
- package/dist/issued/ratelimit.d.ts +34 -0
- package/dist/issued/ratelimit.js +49 -0
- package/dist/issued/tables.d.ts +61 -0
- package/dist/issued/tables.js +13 -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 +5 -1
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`.
|
package/dist/issued/index.d.ts
CHANGED
|
@@ -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'
|
|
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`. */
|
package/dist/issued/index.js
CHANGED
|
@@ -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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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 {
|
|
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
|
+
}
|
package/dist/issued/tables.d.ts
CHANGED
|
@@ -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
|
}>;
|
package/dist/issued/tables.js
CHANGED
|
@@ -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;
|
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
|
},
|