@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 +9 -1
- package/dist/held/index.d.ts +9 -6
- package/dist/held/index.js +77 -18
- package/dist/issued/index.d.ts +1 -1
- package/dist/issued/index.js +104 -38
- package/dist/issued/secret.js +19 -5
- package/dist/migrations/0004_keys_security.sql +246 -0
- package/package.json +3 -3
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.
|
package/dist/held/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
81
|
-
*
|
|
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.
|
package/dist/held/index.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
}
|
package/dist/issued/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/issued/index.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/issued/secret.js
CHANGED
|
@@ -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><
|
|
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),
|
|
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
|
-
|
|
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
|
|
52
|
-
if (
|
|
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
|
|
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.
|
|
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.
|
|
47
|
+
"drizzle-orm": "^0.45.2",
|
|
48
48
|
"postgres": "^3.4.5",
|
|
49
49
|
"typescript": "^5.9.0",
|
|
50
50
|
"vitest": "^4.1.6"
|