@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 CHANGED
@@ -9,7 +9,7 @@ authentication or authorisation of its own — every check runs through
9
9
  ## Install
10
10
 
11
11
  ```sh
12
- pnpm add @wtfalch/keys
12
+ pnpm add @wtfalch/keys drizzle-orm@^0.45.2
13
13
  ```
14
14
 
15
15
  ## Dev
@@ -19,3 +19,27 @@ pnpm build
19
19
  pnpm typecheck
20
20
  pnpm test
21
21
  ```
22
+
23
+ ## Security upgrade
24
+
25
+ Apply the additive `0004_keys_security.sql` migration in every consumer database before relying on the updated guards. All issuer processes must upgrade together for issuance/cascade serialization. See [the remediation and rollout notes](../../security/REMEDIATION.md).
26
+
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
+
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`.
@@ -18,11 +18,11 @@ export type { ShredClock, ShredClockReason } from './shred.js';
18
18
  export type HeldKeysDb = PgDatabase<PgQueryResultHKT, any, any>;
19
19
  /**
20
20
  * Every way `./held` refuses a call. `code` carries the Worker's own
21
- * `WorkerErrorCode` verbatim when the Worker is what refused; the three
21
+ * `WorkerErrorCode` verbatim when the Worker is what refused; the
22
22
  * local codes are refusals this package makes itself, never sent by a
23
23
  * Worker.
24
24
  */
25
- export type HeldKeysErrorCode = WorkerErrorCode | 'not_found' | 'revoked' | 'unseal_failed';
25
+ export type HeldKeysErrorCode = WorkerErrorCode | 'not_found' | 'revoked' | 'unseal_failed' | 'disposed' | 'invalidated';
26
26
  /**
27
27
  * Thrown by `put`, `open` and `rewrap`. Never carries a value or a key --
28
28
  * only what a caller can act on: which entry, which code, and (for
@@ -66,8 +66,10 @@ export interface CreateHeldKeysOptions {
66
66
  readonly worker: Pick<WorkerClient, 'wrap' | 'unwrap' | 'rewrap'>;
67
67
  readonly credential: SerializedCredential;
68
68
  readonly audit: (event: KeyUsedEvent) => void | Promise<void>;
69
- /** Seconds a fetched data key is held in this process before a cache hit no longer serves it. Default 60 (#224). 0 disables caching. */
69
+ /** Seconds before active cache eviction and key zeroing. Default 60 (#224). 0 disables caching. */
70
70
  readonly cacheSeconds?: number;
71
+ /** Maximum cached versions. Default 256. Zero disables caching. */
72
+ readonly maxCacheEntries?: number;
71
73
  /** Unix milliseconds. Defaults to `Date.now`; tests pass a fake clock to move it without waiting. */
72
74
  readonly now?: () => number;
73
75
  }
@@ -77,9 +79,8 @@ export interface HeldKeys {
77
79
  readonly version: number;
78
80
  }>;
79
81
  /**
80
- * Opens one version and hands the plaintext to `use`, never returning it.
81
- * The callback narrows *scope* -- the value cannot be assigned to a
82
- * variable that outlives the call -- but it cannot zero a JS string: a
82
+ * Opens one version and hands the plaintext to `use`. The callback can
83
+ * copy or return the value; this API cannot enforce its lifetime. A
83
84
  * `.toString()`, a template literal, or a copy `use` itself makes can
84
85
  * still outlive this call. That is a limit of the language, not a gap in
85
86
  * this function.
@@ -87,6 +88,8 @@ export interface HeldKeys {
87
88
  open<T>(binding: KeyBinding, use: (value: string) => Promise<T>): Promise<T>;
88
89
  /** Drops and zeroes one cached data key, if one is cached for this exact version. */
89
90
  forget(binding: KeyBinding): void;
91
+ /** Zero all cached keys, cancel expiry timers, and refuse subsequent operations. */
92
+ dispose(): void;
90
93
  /**
91
94
  * The rewrap sweep: moves every version still under `kekIdOld` onto
92
95
  * `kekIdNew`. The raw data key never reaches this process.
@@ -65,14 +65,25 @@ function gcmOpen(dataKey, iv, ciphertext, aad) {
65
65
  export function createHeldKeys(options) {
66
66
  const { db, worker, credential, audit } = options;
67
67
  const cacheMs = (options.cacheSeconds ?? 60) * 1000;
68
+ const maxCacheEntries = options.maxCacheEntries ?? 256;
69
+ if (!Number.isFinite(cacheMs) || cacheMs < 0 || cacheMs > 2_147_483_647) {
70
+ throw new RangeError('cacheSeconds must be finite, nonnegative and at most 2147483');
71
+ }
72
+ if (!Number.isSafeInteger(maxCacheEntries) || maxCacheEntries < 0) {
73
+ throw new RangeError('maxCacheEntries must be a nonnegative safe integer');
74
+ }
68
75
  const now = options.now ?? Date.now;
76
+ let disposed = false;
77
+ let epoch = 0;
69
78
  const cache = new Map();
70
79
  function evict(key, entry) {
71
80
  // Not a guarantee -- the runtime may have copied it -- but it shortens
72
81
  // the window in which a heap dump holds an evicted data key, same as
73
82
  // app-template's envelope.ts.
83
+ clearTimeout(entry.timer);
74
84
  entry.dataKey.fill(0);
75
- cache.delete(key);
85
+ if (cache.get(key) === entry)
86
+ cache.delete(key);
76
87
  }
77
88
  function cachedFor(binding) {
78
89
  if (cacheMs <= 0)
@@ -89,12 +100,40 @@ export function createHeldKeys(options) {
89
100
  }
90
101
  return hit;
91
102
  }
92
- function remember(binding, dataKey, iv, ciphertext) {
93
- if (cacheMs <= 0)
103
+ function remember(binding, dataKey, iv, ciphertext, until) {
104
+ if (cacheMs <= 0 || maxCacheEntries === 0 || until <= now())
94
105
  return;
95
- cache.set(cacheKeyOf(binding), { dataKey, iv, ciphertext, until: now() + cacheMs });
106
+ const key = cacheKeyOf(binding);
107
+ const previous = cache.get(key);
108
+ if (previous)
109
+ evict(key, previous);
110
+ while (cache.size >= maxCacheEntries) {
111
+ const oldest = cache.entries().next().value;
112
+ if (oldest)
113
+ evict(oldest[0], oldest[1]);
114
+ }
115
+ const entry = {
116
+ dataKey: new Uint8Array(dataKey),
117
+ iv,
118
+ ciphertext,
119
+ until,
120
+ timer: setTimeout(() => evict(key, entry), Math.max(0, until - now())),
121
+ };
122
+ entry.timer.unref?.();
123
+ cache.set(key, entry);
124
+ }
125
+ function requireActive() {
126
+ if (disposed)
127
+ throw new HeldKeysError('disposed');
128
+ }
129
+ function dispose() {
130
+ disposed = true;
131
+ epoch++;
132
+ for (const [key, hit] of cache)
133
+ evict(key, hit);
96
134
  }
97
135
  function forget(binding) {
136
+ epoch++;
98
137
  const key = cacheKeyOf(binding);
99
138
  const hit = cache.get(key);
100
139
  if (hit)
@@ -102,6 +141,7 @@ export function createHeldKeys(options) {
102
141
  }
103
142
  /** Every cached version of one entry, evicted -- used on revoke, where the caller does not name a version. */
104
143
  function forgetEntry(entry) {
144
+ epoch++;
105
145
  const prefix = `${entry.tenantId} ${entry.entryId} `;
106
146
  for (const [key, hit] of cache) {
107
147
  if (key.startsWith(prefix))
@@ -109,6 +149,7 @@ export function createHeldKeys(options) {
109
149
  }
110
150
  }
111
151
  async function put(entry, value) {
152
+ requireActive();
112
153
  requireId(entry.tenantId, 'tenantId');
113
154
  requireId(entry.entryId, 'entryId');
114
155
  // Reserve the next version number under a row lock, and nothing else:
@@ -181,6 +222,8 @@ export function createHeldKeys(options) {
181
222
  return { version };
182
223
  }
183
224
  async function open(binding, use) {
225
+ requireActive();
226
+ const openingEpoch = epoch;
184
227
  requireId(binding.tenantId, 'tenantId');
185
228
  requireId(binding.entryId, 'entryId');
186
229
  requireVersion(binding.version);
@@ -224,20 +267,33 @@ export function createHeldKeys(options) {
224
267
  if (!result.ok)
225
268
  throw HeldKeysError.fromWorker(result.error);
226
269
  const dataKey = result.dataKey;
227
- const aad = encodeAad(binding);
228
- const value = gcmOpen(dataKey, versionRow.iv, versionRow.ciphertext, aad);
229
- await audit({
230
- name: 'key.used',
231
- tenantId: binding.tenantId,
232
- entryId: binding.entryId,
233
- version: binding.version,
234
- requestId,
235
- at: now(),
236
- });
237
- remember(binding, dataKey, versionRow.iv, versionRow.ciphertext);
238
- return use(value);
270
+ const until = now() + cacheMs;
271
+ try {
272
+ requireActive();
273
+ if (epoch !== openingEpoch)
274
+ throw new HeldKeysError('invalidated');
275
+ const value = gcmOpen(dataKey, versionRow.iv, versionRow.ciphertext, encodeAad(binding));
276
+ await audit({
277
+ name: 'key.used',
278
+ tenantId: binding.tenantId,
279
+ entryId: binding.entryId,
280
+ version: binding.version,
281
+ requestId,
282
+ at: now(),
283
+ });
284
+ requireActive();
285
+ if (epoch !== openingEpoch)
286
+ throw new HeldKeysError('invalidated');
287
+ remember(binding, dataKey, versionRow.iv, versionRow.ciphertext, until);
288
+ dataKey.fill(0);
289
+ return use(value);
290
+ }
291
+ finally {
292
+ dataKey.fill(0);
293
+ }
239
294
  }
240
295
  async function rewrap(rewrapOptions) {
296
+ requireActive();
241
297
  const { kekIdOld, kekIdNew } = rewrapOptions;
242
298
  requireId(kekIdOld, 'kekIdOld');
243
299
  requireId(kekIdNew, 'kekIdNew');
@@ -271,10 +327,11 @@ export function createHeldKeys(options) {
271
327
  await db
272
328
  .update(keysHeldVersions)
273
329
  .set({ kekId: kekIdNew, wrappedKey: Buffer.from(result.wrappedKey) })
274
- .where(and(eq(keysHeldVersions.tenantId, binding.tenantId), eq(keysHeldVersions.entryId, binding.entryId), eq(keysHeldVersions.version, binding.version)));
330
+ .where(and(eq(keysHeldVersions.tenantId, binding.tenantId), eq(keysHeldVersions.entryId, binding.entryId), eq(keysHeldVersions.version, binding.version), eq(keysHeldVersions.kekId, kekIdOld), eq(keysHeldVersions.wrappedKey, row.wrappedKey)));
275
331
  }
276
332
  }
277
333
  async function revokeEntry(entry) {
334
+ requireActive();
278
335
  requireId(entry.tenantId, 'tenantId');
279
336
  requireId(entry.entryId, 'entryId');
280
337
  // The `revoked_at is null` guard makes a second call a no-op rather than
@@ -288,6 +345,7 @@ export function createHeldKeys(options) {
288
345
  forgetEntry(entry);
289
346
  }
290
347
  async function archiveTenant(tenantId) {
348
+ requireActive();
291
349
  requireId(tenantId, 'tenantId');
292
350
  // Every row for this tenant not already archived, in one statement --
293
351
  // the `is null` guard is what makes a second call a no-op per row
@@ -298,11 +356,12 @@ export function createHeldKeys(options) {
298
356
  .update(keysHeldEntries)
299
357
  .set({ tenantArchivedAt: sql `now()` })
300
358
  .where(and(eq(keysHeldEntries.tenantId, tenantId), sql `${keysHeldEntries.tenantArchivedAt} is null`));
359
+ epoch++;
301
360
  const prefix = `${tenantId} `;
302
361
  for (const [key, hit] of cache) {
303
362
  if (key.startsWith(prefix))
304
363
  evict(key, hit);
305
364
  }
306
365
  }
307
- return { put, open, forget, rewrap, revokeEntry, archiveTenant };
366
+ return { put, open, forget, dispose, rewrap, revokeEntry, archiveTenant };
308
367
  }
@@ -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,17 +64,22 @@ 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
- export type RotateRefusalReason = 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
82
+ export type RotateRefusalReason = 'signature_invalid' | 'conflict' | 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
58
83
  export type RotateResult = {
59
84
  readonly ok: true;
60
85
  readonly secret: string;
@@ -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`. */