@wtfalch/keys 0.1.0 → 0.2.1

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,11 @@ 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.
@@ -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
  }
@@ -54,7 +54,7 @@ export type CheckResult<TGrant> = {
54
54
  readonly ok: false;
55
55
  readonly reason: CheckRefusalReason;
56
56
  };
57
- export type RotateRefusalReason = 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
57
+ export type RotateRefusalReason = 'signature_invalid' | 'conflict' | 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
58
58
  export type RotateResult = {
59
59
  readonly ok: true;
60
60
  readonly secret: string;
@@ -1,6 +1,7 @@
1
1
  import { and, eq, inArray, isNull } from 'drizzle-orm';
2
2
  import { sql } from 'drizzle-orm';
3
3
  import { fromBase64Url, toBase64Url } from './codec.js';
4
+ import { canonicalJson } from './encoding.js';
4
5
  import { keyPrefixOf, mintSecret, rotateSecret, secretMatches } from './secret.js';
5
6
  import { keysIssuedCredentials } from './tables.js';
6
7
  import { verifyIssuedRowSignature } from './verify.js';
@@ -29,8 +30,8 @@ export function createCredentialIssuer(options) {
29
30
  const { db, worker, credential, verifyKeys, audit, prefix } = options;
30
31
  const now = options.now ?? (() => Date.now());
31
32
  const table = keysIssuedCredentials;
32
- async function loadById(id) {
33
- const [row] = await db.select().from(table).where(eq(table.id, id)).limit(1);
33
+ async function loadById(id, handle = db) {
34
+ const [row] = await handle.select().from(table).where(eq(table.id, id)).limit(1);
34
35
  return row;
35
36
  }
36
37
  async function loadByPrefix(keyPrefix) {
@@ -42,23 +43,29 @@ export function createCredentialIssuer(options) {
42
43
  return row;
43
44
  }
44
45
  /** Every descendant of `id`, `id` itself included. Mirrors valet's `subtreeOf` (`self-service.ts`). */
45
- async function subtreeIds(id) {
46
- const result = await db.execute(sql `
46
+ async function subtreeIds(id, handle) {
47
+ const result = await handle.execute(sql `
47
48
  with recursive descendants as (
48
- select id from keys_issued_credentials where id = ${id}
49
+ select id from public.keys_issued_credentials where id = ${id}
49
50
  union
50
51
  select c.id
51
- from keys_issued_credentials c
52
+ from public.keys_issued_credentials c
52
53
  join descendants d on c.issued_by_id = d.id
53
54
  )
54
55
  select id from descendants
55
56
  `);
56
57
  return rowsOf(result).map((r) => r.id);
57
58
  }
59
+ // One short database-wide lifecycle lock. Never held across a Worker call.
60
+ // The cascade snapshot and child publication must share the same lock; row
61
+ // locks on only the immediate parent miss concurrent ancestor revocations.
62
+ async function lockLifecycle(tx) {
63
+ await tx.execute(sql `select pg_advisory_xact_lock(1801812339, 1)`);
64
+ }
58
65
  async function emit(event) {
59
66
  await audit?.(event);
60
67
  }
61
- async function issue(request) {
68
+ async function issue(request, attempt = 0) {
62
69
  if (request.idempotencyKey) {
63
70
  const existing = await loadByIdempotencyKey(request.idempotencyKey);
64
71
  if (existing) {
@@ -110,17 +117,37 @@ export function createCredentialIssuer(options) {
110
117
  if (!result.ok)
111
118
  return { ok: false, reason: result.error.code };
112
119
  try {
113
- await db.insert(table).values({
114
- id,
115
- issuedById: candidate.issuedById,
116
- keyPrefix: candidate.keyPrefix,
117
- secretHash: candidate.secretHash,
118
- grants: candidate.grants,
119
- expiresAt: candidate.expiresAt,
120
- signature: toBase64Url(result.signature),
121
- signingGenerationId: result.generationId,
122
- idempotencyKey: request.idempotencyKey ?? null,
120
+ const refusal = await db.transaction(async (tx) => {
121
+ await lockLifecycle(tx);
122
+ if (request.minter !== null) {
123
+ const parent = await loadById(request.minter, tx);
124
+ if (!parent)
125
+ return 'minter_not_found';
126
+ if (parent.revokedAt !== null)
127
+ return 'minter_revoked';
128
+ if (parent.expiresAt <= now())
129
+ return 'minter_expired';
130
+ if (!minterAttestation ||
131
+ parent.signature !== toBase64Url(minterAttestation.signature) ||
132
+ canonicalJson(toSignableRow(parent)) !== canonicalJson(minterAttestation.row)) {
133
+ return 'bad_minter_signature';
134
+ }
135
+ }
136
+ await tx.insert(table).values({
137
+ id,
138
+ issuedById: candidate.issuedById,
139
+ keyPrefix: candidate.keyPrefix,
140
+ secretHash: candidate.secretHash,
141
+ grants: candidate.grants,
142
+ expiresAt: candidate.expiresAt,
143
+ signature: toBase64Url(result.signature),
144
+ signingGenerationId: result.generationId,
145
+ idempotencyKey: request.idempotencyKey ?? null,
146
+ });
147
+ return null;
123
148
  });
149
+ if (refusal)
150
+ return { ok: false, reason: refusal };
124
151
  }
125
152
  catch (err) {
126
153
  // A concurrent issue() with the same idempotencyKey won the race: the
@@ -131,6 +158,9 @@ export function createCredentialIssuer(options) {
131
158
  return { ok: true, id: existing.id, keyPrefix: existing.keyPrefix, secret: null };
132
159
  }
133
160
  }
161
+ if (attempt < 3 && isUniqueViolation(err) && (await loadByPrefix(candidate.keyPrefix))) {
162
+ return issue(request, attempt + 1);
163
+ }
134
164
  throw err;
135
165
  }
136
166
  await emit({ name: 'credential.minted', credentialId: id, requestId, at: now() });
@@ -181,6 +211,14 @@ export function createCredentialIssuer(options) {
181
211
  return { ok: false, reason: 'not_found' };
182
212
  if (row.revokedAt !== null)
183
213
  return { ok: false, reason: 'revoked' };
214
+ if (!(await verifyIssuedRowSignature(toSignableRow(row), fromBase64Url(row.signature), verifyKeys))) {
215
+ return { ok: false, reason: 'signature_invalid' };
216
+ }
217
+ if (!Number.isSafeInteger(opts.graceMs) ||
218
+ opts.graceMs < 0 ||
219
+ !Number.isSafeInteger(now() + opts.graceMs)) {
220
+ return { ok: false, reason: 'bad_request' };
221
+ }
184
222
  const minted = rotateSecret(row.keyPrefix);
185
223
  const previousValidUntil = now() + opts.graceMs;
186
224
  const candidate = {
@@ -217,30 +255,55 @@ export function createCredentialIssuer(options) {
217
255
  });
218
256
  if (!result.ok)
219
257
  return { ok: false, reason: result.error.code };
220
- await db
221
- .update(table)
222
- .set({
223
- secretHash: candidate.secretHash,
224
- previousSecretHash: candidate.previousSecretHash,
225
- previousValidUntil: candidate.previousValidUntil,
226
- signature: toBase64Url(result.signature),
227
- signingGenerationId: result.generationId,
228
- })
229
- .where(eq(table.id, id));
258
+ const refusal = await db.transaction(async (tx) => {
259
+ await lockLifecycle(tx);
260
+ const [current] = await tx.select().from(table).where(eq(table.id, id)).for('update');
261
+ if (!current)
262
+ return 'not_found';
263
+ if (current.revokedAt !== null)
264
+ return 'revoked';
265
+ if (current.signature !== row.signature ||
266
+ canonicalJson(toSignableRow(current)) !== canonicalJson(toSignableRow(row))) {
267
+ return 'conflict';
268
+ }
269
+ if (row.issuedById !== null) {
270
+ const parent = await loadById(row.issuedById, tx);
271
+ if (!parent)
272
+ return 'minter_not_found';
273
+ if (parent.revokedAt !== null)
274
+ return 'minter_revoked';
275
+ if (parent.expiresAt <= now())
276
+ return 'minter_expired';
277
+ }
278
+ await tx
279
+ .update(table)
280
+ .set({
281
+ secretHash: candidate.secretHash,
282
+ previousSecretHash: candidate.previousSecretHash,
283
+ previousValidUntil: candidate.previousValidUntil,
284
+ signature: toBase64Url(result.signature),
285
+ signingGenerationId: result.generationId,
286
+ })
287
+ .where(eq(table.id, id));
288
+ return null;
289
+ });
290
+ if (refusal)
291
+ return { ok: false, reason: refusal };
230
292
  await emit({ name: 'credential.rotated', credentialId: id, requestId, at: now() });
231
293
  return { ok: true, secret: minted.secret };
232
294
  }
233
295
  async function revoke(id, opts = {}) {
234
- const cascade = opts.cascade ?? true;
235
- const ids = cascade ? await subtreeIds(id) : [id];
236
- if (ids.length === 0)
237
- return [];
238
- const revokedAt = new Date(now());
239
- const revokedRows = await db
240
- .update(table)
241
- .set({ revokedAt })
242
- .where(and(inArray(table.id, ids), isNull(table.revokedAt)))
243
- .returning({ id: table.id });
296
+ const revokedRows = await db.transaction(async (tx) => {
297
+ await lockLifecycle(tx);
298
+ const ids = (opts.cascade ?? true) ? await subtreeIds(id, tx) : [id];
299
+ if (ids.length === 0)
300
+ return [];
301
+ return tx
302
+ .update(table)
303
+ .set({ revokedAt: new Date(now()) })
304
+ .where(and(inArray(table.id, ids), isNull(table.revokedAt)))
305
+ .returning({ id: table.id });
306
+ });
244
307
  for (const r of revokedRows) {
245
308
  await emit({ name: 'credential.revoked', credentialId: r.id, at: now() });
246
309
  }
@@ -269,5 +332,8 @@ export function createCredentialIssuer(options) {
269
332
  return { issue, check, rotate, revoke, lineageOf };
270
333
  }
271
334
  function isUniqueViolation(err) {
272
- return err?.code === '23505';
335
+ if (!err || typeof err !== 'object')
336
+ return false;
337
+ const e = err;
338
+ return e.code === '23505' || (e.cause !== err && isUniqueViolation(e.cause));
273
339
  }
@@ -2,12 +2,12 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  /**
3
3
  * The split-secret format, carried over from valet's `src/lib/auth/keys.ts`:
4
4
  *
5
- * <prefix><8-char public id><20-byte secret tail>
5
+ * <prefix><8-char public id><32-char tail encoding 20 random bytes>
6
6
  *
7
7
  * The prefix through the public id is stored in the clear and is what a
8
8
  * lookup indexes on; the tail is never stored, only the SHA-256 hash of the
9
9
  * whole secret. `prefix` is host-supplied here (valet hardcodes `vk_live_`;
10
- * a package used by several hosts cannot), everything after it is fixed.
10
+ * a package used by several hosts cannot), legacy 20-character tails remain accepted during migration.
11
11
  *
12
12
  * `node:crypto` rather than WebCrypto: this is a Node-side, host-only
13
13
  * mechanic (unlike encoding.ts, nothing here runs in the Worker), and
@@ -15,6 +15,8 @@ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
15
15
  */
16
16
  const PUBLIC_ID_LEN = 8;
17
17
  const SECRET_BYTES = 20;
18
+ const SECRET_CHARS = 32;
19
+ const LEGACY_SECRET_CHARS = 20;
18
20
  /** Crockford-ish base32: no padding, no vowels-to-digits confusion in logs. */
19
21
  const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
20
22
  function encode(bytes) {
@@ -33,7 +35,19 @@ export function mintSecret(prefix) {
33
35
  }
34
36
  /** A fresh tail under an EXISTING `keyPrefix`: what rotate() calls, so the row keeps its indexed identity. */
35
37
  export function rotateSecret(keyPrefix) {
36
- const tail = encode(randomBytes(SECRET_BYTES));
38
+ // Encode all 160 random bits, instead of discarding three bits per byte.
39
+ const bytes = randomBytes(SECRET_BYTES);
40
+ let tail = '';
41
+ let bits = 0;
42
+ let buffer = 0;
43
+ for (const byte of bytes) {
44
+ buffer = (buffer << 8) | byte;
45
+ bits += 8;
46
+ while (bits >= 5) {
47
+ bits -= 5;
48
+ tail += ALPHABET[(buffer >>> bits) & 31];
49
+ }
50
+ }
37
51
  const secret = `${keyPrefix}${tail}`;
38
52
  return { secret, keyPrefix, secretHash: hashSecret(secret) };
39
53
  }
@@ -48,8 +62,8 @@ export function rotateSecret(keyPrefix) {
48
62
  export function keyPrefixOf(presented, prefix) {
49
63
  if (!presented.startsWith(prefix))
50
64
  return null;
51
- const expected = prefix.length + PUBLIC_ID_LEN + SECRET_BYTES;
52
- if (presented.length !== expected)
65
+ const tailLength = presented.length - prefix.length - PUBLIC_ID_LEN;
66
+ if (tailLength !== SECRET_CHARS && tailLength !== LEGACY_SECRET_CHARS)
53
67
  return null;
54
68
  return presented.slice(0, prefix.length + PUBLIC_ID_LEN);
55
69
  }
@@ -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
+ $$;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/keys",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
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",
@@ -39,12 +39,12 @@
39
39
  "test": "vitest run"
40
40
  },
41
41
  "peerDependencies": {
42
- "drizzle-orm": ">=0.39.0"
42
+ "drizzle-orm": ">=0.45.2"
43
43
  },
44
44
  "devDependencies": {
45
45
  "@electric-sql/pglite": "^0.5.8",
46
46
  "@types/node": "^22",
47
- "drizzle-orm": "^0.39.3",
47
+ "drizzle-orm": "^0.45.2",
48
48
  "postgres": "^3.4.5",
49
49
  "typescript": "^5.9.0",
50
50
  "vitest": "^4.1.6"