@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 +25 -1
- package/dist/held/index.d.ts +9 -6
- package/dist/held/index.js +77 -18
- package/dist/issued/index.d.ts +29 -3
- package/dist/issued/index.js +160 -57
- package/dist/issued/ratelimit.d.ts +34 -0
- package/dist/issued/ratelimit.js +49 -0
- package/dist/issued/secret.js +19 -5
- package/dist/issued/tables.d.ts +61 -0
- package/dist/issued/tables.js +13 -0
- package/dist/migrations/0004_keys_security.sql +246 -0
- package/dist/migrations/0005_keys_environment.sql +52 -0
- package/dist/migrations/0006_keys_usage.sql +26 -0
- package/dist/migrations/0007_keys_rate_limit.sql +20 -0
- package/dist/webhooks.d.ts +55 -0
- package/dist/webhooks.js +51 -0
- package/package.json +7 -3
package/dist/issued/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
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';
|
|
7
8
|
export { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
|
|
9
|
+
export { createPostgresRateLimiter, } from './ratelimit.js';
|
|
8
10
|
function rowsOf(result) {
|
|
9
11
|
if (Array.isArray(result))
|
|
10
12
|
return result;
|
|
@@ -26,11 +28,11 @@ function toSignableRow(row) {
|
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
export function createCredentialIssuer(options) {
|
|
29
|
-
const { db, worker, credential, verifyKeys, audit, prefix } = options;
|
|
31
|
+
const { db, worker, credential, verifyKeys, audit, prefix, rateLimiter } = options;
|
|
30
32
|
const now = options.now ?? (() => Date.now());
|
|
31
33
|
const table = keysIssuedCredentials;
|
|
32
|
-
async function loadById(id) {
|
|
33
|
-
const [row] = await
|
|
34
|
+
async function loadById(id, handle = db) {
|
|
35
|
+
const [row] = await handle.select().from(table).where(eq(table.id, id)).limit(1);
|
|
34
36
|
return row;
|
|
35
37
|
}
|
|
36
38
|
async function loadByPrefix(keyPrefix) {
|
|
@@ -42,23 +44,29 @@ export function createCredentialIssuer(options) {
|
|
|
42
44
|
return row;
|
|
43
45
|
}
|
|
44
46
|
/** Every descendant of `id`, `id` itself included. Mirrors valet's `subtreeOf` (`self-service.ts`). */
|
|
45
|
-
async function subtreeIds(id) {
|
|
46
|
-
const result = await
|
|
47
|
+
async function subtreeIds(id, handle) {
|
|
48
|
+
const result = await handle.execute(sql `
|
|
47
49
|
with recursive descendants as (
|
|
48
|
-
select id from keys_issued_credentials where id = ${id}
|
|
50
|
+
select id from public.keys_issued_credentials where id = ${id}
|
|
49
51
|
union
|
|
50
52
|
select c.id
|
|
51
|
-
from keys_issued_credentials c
|
|
53
|
+
from public.keys_issued_credentials c
|
|
52
54
|
join descendants d on c.issued_by_id = d.id
|
|
53
55
|
)
|
|
54
56
|
select id from descendants
|
|
55
57
|
`);
|
|
56
58
|
return rowsOf(result).map((r) => r.id);
|
|
57
59
|
}
|
|
60
|
+
// One short database-wide lifecycle lock. Never held across a Worker call.
|
|
61
|
+
// The cascade snapshot and child publication must share the same lock; row
|
|
62
|
+
// locks on only the immediate parent miss concurrent ancestor revocations.
|
|
63
|
+
async function lockLifecycle(tx) {
|
|
64
|
+
await tx.execute(sql `select pg_advisory_xact_lock(1801812339, 1)`);
|
|
65
|
+
}
|
|
58
66
|
async function emit(event) {
|
|
59
67
|
await audit?.(event);
|
|
60
68
|
}
|
|
61
|
-
async function issue(request) {
|
|
69
|
+
async function issue(request, attempt = 0) {
|
|
62
70
|
if (request.idempotencyKey) {
|
|
63
71
|
const existing = await loadByIdempotencyKey(request.idempotencyKey);
|
|
64
72
|
if (existing) {
|
|
@@ -71,6 +79,7 @@ export function createCredentialIssuer(options) {
|
|
|
71
79
|
if (request.expiresAt <= now()) {
|
|
72
80
|
return { ok: false, reason: 'expires_at_past' };
|
|
73
81
|
}
|
|
82
|
+
const environment = request.environment ?? 'live';
|
|
74
83
|
let minterAttestation = null;
|
|
75
84
|
if (request.minter !== null) {
|
|
76
85
|
const minterRow = await loadById(request.minter);
|
|
@@ -83,6 +92,11 @@ export function createCredentialIssuer(options) {
|
|
|
83
92
|
return { ok: false, reason: 'minter_revoked' };
|
|
84
93
|
if (minterRow.expiresAt <= now())
|
|
85
94
|
return { ok: false, reason: 'minter_expired' };
|
|
95
|
+
// #47: a test key can only mint a test key, a live key only a live
|
|
96
|
+
// one -- the pairing the issue asked for, not just a label.
|
|
97
|
+
if (minterRow.environment !== environment) {
|
|
98
|
+
return { ok: false, reason: 'environment_mismatch' };
|
|
99
|
+
}
|
|
86
100
|
minterAttestation = {
|
|
87
101
|
row: toSignableRow(minterRow),
|
|
88
102
|
signature: fromBase64Url(minterRow.signature),
|
|
@@ -110,17 +124,40 @@ export function createCredentialIssuer(options) {
|
|
|
110
124
|
if (!result.ok)
|
|
111
125
|
return { ok: false, reason: result.error.code };
|
|
112
126
|
try {
|
|
113
|
-
await db.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
const refusal = await db.transaction(async (tx) => {
|
|
128
|
+
await lockLifecycle(tx);
|
|
129
|
+
if (request.minter !== null) {
|
|
130
|
+
const parent = await loadById(request.minter, tx);
|
|
131
|
+
if (!parent)
|
|
132
|
+
return 'minter_not_found';
|
|
133
|
+
if (parent.revokedAt !== null)
|
|
134
|
+
return 'minter_revoked';
|
|
135
|
+
if (parent.expiresAt <= now())
|
|
136
|
+
return 'minter_expired';
|
|
137
|
+
if (parent.environment !== environment)
|
|
138
|
+
return 'environment_mismatch';
|
|
139
|
+
if (!minterAttestation ||
|
|
140
|
+
parent.signature !== toBase64Url(minterAttestation.signature) ||
|
|
141
|
+
canonicalJson(toSignableRow(parent)) !== canonicalJson(minterAttestation.row)) {
|
|
142
|
+
return 'bad_minter_signature';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
await tx.insert(table).values({
|
|
146
|
+
id,
|
|
147
|
+
issuedById: candidate.issuedById,
|
|
148
|
+
keyPrefix: candidate.keyPrefix,
|
|
149
|
+
secretHash: candidate.secretHash,
|
|
150
|
+
grants: candidate.grants,
|
|
151
|
+
expiresAt: candidate.expiresAt,
|
|
152
|
+
signature: toBase64Url(result.signature),
|
|
153
|
+
signingGenerationId: result.generationId,
|
|
154
|
+
idempotencyKey: request.idempotencyKey ?? null,
|
|
155
|
+
environment,
|
|
156
|
+
});
|
|
157
|
+
return null;
|
|
123
158
|
});
|
|
159
|
+
if (refusal)
|
|
160
|
+
return { ok: false, reason: refusal };
|
|
124
161
|
}
|
|
125
162
|
catch (err) {
|
|
126
163
|
// A concurrent issue() with the same idempotencyKey won the race: the
|
|
@@ -131,6 +168,9 @@ export function createCredentialIssuer(options) {
|
|
|
131
168
|
return { ok: true, id: existing.id, keyPrefix: existing.keyPrefix, secret: null };
|
|
132
169
|
}
|
|
133
170
|
}
|
|
171
|
+
if (attempt < 3 && isUniqueViolation(err) && (await loadByPrefix(candidate.keyPrefix))) {
|
|
172
|
+
return issue(request, attempt + 1);
|
|
173
|
+
}
|
|
134
174
|
throw err;
|
|
135
175
|
}
|
|
136
176
|
await emit({ name: 'credential.minted', credentialId: id, requestId, at: now() });
|
|
@@ -143,6 +183,24 @@ export function createCredentialIssuer(options) {
|
|
|
143
183
|
const row = await loadByPrefix(keyPrefix);
|
|
144
184
|
if (!row)
|
|
145
185
|
return { ok: false, reason: 'not_found' };
|
|
186
|
+
// #45: spent on every attempt against a known credential, matched
|
|
187
|
+
// secret or not -- an unlimited number of wrong guesses against an
|
|
188
|
+
// identified row would otherwise let an attacker brute-force it past
|
|
189
|
+
// any per-successful-use limit. Before the signature check below, so a
|
|
190
|
+
// flood of requests against one credential id never reaches the more
|
|
191
|
+
// expensive Ed25519 verify.
|
|
192
|
+
// A `rateLimiter.check` failure is NOT caught, unlike #46's usage
|
|
193
|
+
// write below: rate limiting is a security control the host opted
|
|
194
|
+
// into, and failing open on it (silently never limiting anything
|
|
195
|
+
// whenever its store is unreachable) is a worse failure mode than a
|
|
196
|
+
// loud one a host notices in its own staging environment the first
|
|
197
|
+
// time the migration is missing or the store is down.
|
|
198
|
+
if (rateLimiter) {
|
|
199
|
+
const limit = await rateLimiter.check(row.id, now());
|
|
200
|
+
if (limit.limited) {
|
|
201
|
+
return { ok: false, reason: 'rate_limited', retryAfterMs: limit.retryAfterMs };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
146
204
|
// Verified before anything else is trusted about the row: a signature
|
|
147
205
|
// check over the row's CURRENT fields refuses a row a raw UPDATE
|
|
148
206
|
// tampered with, whatever that tamper changed.
|
|
@@ -154,26 +212,34 @@ export function createCredentialIssuer(options) {
|
|
|
154
212
|
return { ok: false, reason: 'revoked' };
|
|
155
213
|
if (now() >= row.expiresAt)
|
|
156
214
|
return { ok: false, reason: 'expired' };
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
};
|
|
215
|
+
const matched = secretMatches(secret, row.secretHash) ||
|
|
216
|
+
(row.previousSecretHash !== null &&
|
|
217
|
+
row.previousValidUntil !== null &&
|
|
218
|
+
now() < row.previousValidUntil &&
|
|
219
|
+
secretMatches(secret, row.previousSecretHash));
|
|
220
|
+
if (!matched)
|
|
221
|
+
return { ok: false, reason: 'secret_mismatch' };
|
|
222
|
+
// #46: last-used/use-count, for an admin UI to read directly off the
|
|
223
|
+
// row -- best-effort. A write failure here is observability lost, not
|
|
224
|
+
// an authorization decision, so it must never turn an otherwise-genuine
|
|
225
|
+
// secret into a refusal; check() stays available for the one thing it
|
|
226
|
+
// exists to do even if this bookkeeping write cannot land.
|
|
227
|
+
try {
|
|
228
|
+
await db
|
|
229
|
+
.update(table)
|
|
230
|
+
.set({ lastUsedAt: new Date(now()), useCount: sql `${table.useCount} + 1` })
|
|
231
|
+
.where(eq(table.id, row.id));
|
|
164
232
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
now() < row.previousValidUntil &&
|
|
168
|
-
secretMatches(secret, row.previousSecretHash)) {
|
|
169
|
-
return {
|
|
170
|
-
ok: true,
|
|
171
|
-
id: row.id,
|
|
172
|
-
issuedById: row.issuedById,
|
|
173
|
-
grants: row.grants,
|
|
174
|
-
};
|
|
233
|
+
catch {
|
|
234
|
+
// Swallowed by design -- see comment above.
|
|
175
235
|
}
|
|
176
|
-
return {
|
|
236
|
+
return {
|
|
237
|
+
ok: true,
|
|
238
|
+
id: row.id,
|
|
239
|
+
issuedById: row.issuedById,
|
|
240
|
+
grants: row.grants,
|
|
241
|
+
environment: row.environment,
|
|
242
|
+
};
|
|
177
243
|
}
|
|
178
244
|
async function rotate(id, opts) {
|
|
179
245
|
const row = await loadById(id);
|
|
@@ -181,6 +247,14 @@ export function createCredentialIssuer(options) {
|
|
|
181
247
|
return { ok: false, reason: 'not_found' };
|
|
182
248
|
if (row.revokedAt !== null)
|
|
183
249
|
return { ok: false, reason: 'revoked' };
|
|
250
|
+
if (!(await verifyIssuedRowSignature(toSignableRow(row), fromBase64Url(row.signature), verifyKeys))) {
|
|
251
|
+
return { ok: false, reason: 'signature_invalid' };
|
|
252
|
+
}
|
|
253
|
+
if (!Number.isSafeInteger(opts.graceMs) ||
|
|
254
|
+
opts.graceMs < 0 ||
|
|
255
|
+
!Number.isSafeInteger(now() + opts.graceMs)) {
|
|
256
|
+
return { ok: false, reason: 'bad_request' };
|
|
257
|
+
}
|
|
184
258
|
const minted = rotateSecret(row.keyPrefix);
|
|
185
259
|
const previousValidUntil = now() + opts.graceMs;
|
|
186
260
|
const candidate = {
|
|
@@ -217,30 +291,55 @@ export function createCredentialIssuer(options) {
|
|
|
217
291
|
});
|
|
218
292
|
if (!result.ok)
|
|
219
293
|
return { ok: false, reason: result.error.code };
|
|
220
|
-
await db
|
|
221
|
-
|
|
222
|
-
.
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
294
|
+
const refusal = await db.transaction(async (tx) => {
|
|
295
|
+
await lockLifecycle(tx);
|
|
296
|
+
const [current] = await tx.select().from(table).where(eq(table.id, id)).for('update');
|
|
297
|
+
if (!current)
|
|
298
|
+
return 'not_found';
|
|
299
|
+
if (current.revokedAt !== null)
|
|
300
|
+
return 'revoked';
|
|
301
|
+
if (current.signature !== row.signature ||
|
|
302
|
+
canonicalJson(toSignableRow(current)) !== canonicalJson(toSignableRow(row))) {
|
|
303
|
+
return 'conflict';
|
|
304
|
+
}
|
|
305
|
+
if (row.issuedById !== null) {
|
|
306
|
+
const parent = await loadById(row.issuedById, tx);
|
|
307
|
+
if (!parent)
|
|
308
|
+
return 'minter_not_found';
|
|
309
|
+
if (parent.revokedAt !== null)
|
|
310
|
+
return 'minter_revoked';
|
|
311
|
+
if (parent.expiresAt <= now())
|
|
312
|
+
return 'minter_expired';
|
|
313
|
+
}
|
|
314
|
+
await tx
|
|
315
|
+
.update(table)
|
|
316
|
+
.set({
|
|
317
|
+
secretHash: candidate.secretHash,
|
|
318
|
+
previousSecretHash: candidate.previousSecretHash,
|
|
319
|
+
previousValidUntil: candidate.previousValidUntil,
|
|
320
|
+
signature: toBase64Url(result.signature),
|
|
321
|
+
signingGenerationId: result.generationId,
|
|
322
|
+
})
|
|
323
|
+
.where(eq(table.id, id));
|
|
324
|
+
return null;
|
|
325
|
+
});
|
|
326
|
+
if (refusal)
|
|
327
|
+
return { ok: false, reason: refusal };
|
|
230
328
|
await emit({ name: 'credential.rotated', credentialId: id, requestId, at: now() });
|
|
231
329
|
return { ok: true, secret: minted.secret };
|
|
232
330
|
}
|
|
233
331
|
async function revoke(id, opts = {}) {
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
332
|
+
const revokedRows = await db.transaction(async (tx) => {
|
|
333
|
+
await lockLifecycle(tx);
|
|
334
|
+
const ids = (opts.cascade ?? true) ? await subtreeIds(id, tx) : [id];
|
|
335
|
+
if (ids.length === 0)
|
|
336
|
+
return [];
|
|
337
|
+
return tx
|
|
338
|
+
.update(table)
|
|
339
|
+
.set({ revokedAt: new Date(now()) })
|
|
340
|
+
.where(and(inArray(table.id, ids), isNull(table.revokedAt)))
|
|
341
|
+
.returning({ id: table.id });
|
|
342
|
+
});
|
|
244
343
|
for (const r of revokedRows) {
|
|
245
344
|
await emit({ name: 'credential.revoked', credentialId: r.id, at: now() });
|
|
246
345
|
}
|
|
@@ -261,6 +360,7 @@ export function createCredentialIssuer(options) {
|
|
|
261
360
|
grants: row.grants,
|
|
262
361
|
expiresAt: row.expiresAt,
|
|
263
362
|
revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
|
|
363
|
+
environment: row.environment,
|
|
264
364
|
});
|
|
265
365
|
currentId = row.issuedById;
|
|
266
366
|
}
|
|
@@ -269,5 +369,8 @@ export function createCredentialIssuer(options) {
|
|
|
269
369
|
return { issue, check, rotate, revoke, lineageOf };
|
|
270
370
|
}
|
|
271
371
|
function isUniqueViolation(err) {
|
|
272
|
-
|
|
372
|
+
if (!err || typeof err !== 'object')
|
|
373
|
+
return false;
|
|
374
|
+
const e = err;
|
|
375
|
+
return e.code === '23505' || (e.cause !== err && isUniqueViolation(e.cause));
|
|
273
376
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Db } from './index.js';
|
|
2
|
+
/**
|
|
3
|
+
* #45: a per-issued-credential abuse limit. `packages/worker/src/ratelimit.ts`
|
|
4
|
+
* already limits per `hostId` + the HOST's own service credential; nothing
|
|
5
|
+
* limited an END USER's individual issued key until this. `check()` consults
|
|
6
|
+
* `CreateCredentialIssuerOptions.rateLimiter`, when one is given, keyed by
|
|
7
|
+
* the credential's own `id` -- once the row is found (so a known
|
|
8
|
+
* credential's budget is spent on every attempt, matched secret or not,
|
|
9
|
+
* not only on a successful one), before its secret is verified.
|
|
10
|
+
*/
|
|
11
|
+
export interface RateLimitResult {
|
|
12
|
+
readonly limited: boolean;
|
|
13
|
+
/** Milliseconds until the current window resets. Meaningful whether or not `limited`. */
|
|
14
|
+
readonly retryAfterMs: number;
|
|
15
|
+
}
|
|
16
|
+
export interface RateLimiter {
|
|
17
|
+
/** `now`: Unix milliseconds, the same clock `check()`'s own `now` option gives -- never read internally, so a fake clock in a test reaches this too. */
|
|
18
|
+
check(credentialId: string, now: number): Promise<RateLimitResult>;
|
|
19
|
+
}
|
|
20
|
+
export interface CreatePostgresRateLimiterOptions {
|
|
21
|
+
readonly db: Db;
|
|
22
|
+
/** Requests a single credential may make per window before `limited` is true. */
|
|
23
|
+
readonly limitPerWindow: number;
|
|
24
|
+
/** Milliseconds. Default 60_000. */
|
|
25
|
+
readonly windowMs?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A fixed-window counter in the host's own Postgres, one bounded row per
|
|
29
|
+
* credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
|
|
30
|
+
* atomically reset each window by a single upsert -- the same shape
|
|
31
|
+
* `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
|
|
32
|
+
* level down: keyed by the end user's credential id, not the host's.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createPostgresRateLimiter(options: CreatePostgresRateLimiterOptions): RateLimiter;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { sql } from 'drizzle-orm';
|
|
2
|
+
function rowsOf(result) {
|
|
3
|
+
if (Array.isArray(result))
|
|
4
|
+
return result;
|
|
5
|
+
return (result.rows ?? []);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A fixed-window counter in the host's own Postgres, one bounded row per
|
|
9
|
+
* credential (`keys_issued_rate_limit_windows`, `0007_keys_rate_limit.sql`),
|
|
10
|
+
* atomically reset each window by a single upsert -- the same shape
|
|
11
|
+
* `packages/worker/src/ratelimit.ts`'s D1 `host_rate_limit_windows` uses, a
|
|
12
|
+
* level down: keyed by the end user's credential id, not the host's.
|
|
13
|
+
*/
|
|
14
|
+
export function createPostgresRateLimiter(options) {
|
|
15
|
+
const { db, limitPerWindow } = options;
|
|
16
|
+
const windowMs = options.windowMs ?? 60_000;
|
|
17
|
+
return {
|
|
18
|
+
async check(credentialId, now) {
|
|
19
|
+
const windowStart = Math.floor(now / windowMs) * windowMs;
|
|
20
|
+
const result = await db.execute(sql `
|
|
21
|
+
insert into keys_issued_rate_limit_windows (credential_id, window_start, count)
|
|
22
|
+
values (${credentialId}, ${windowStart}, 1)
|
|
23
|
+
on conflict (credential_id) do update set
|
|
24
|
+
window_start = excluded.window_start,
|
|
25
|
+
count = case
|
|
26
|
+
when keys_issued_rate_limit_windows.window_start = excluded.window_start
|
|
27
|
+
then keys_issued_rate_limit_windows.count + 1
|
|
28
|
+
else 1
|
|
29
|
+
end
|
|
30
|
+
returning window_start, count
|
|
31
|
+
`);
|
|
32
|
+
const [row] = rowsOf(result);
|
|
33
|
+
if (!row)
|
|
34
|
+
throw new Error('rate limit write returned no row');
|
|
35
|
+
// `count` is `integer` (int4), always JS-number-safe -- but a raw
|
|
36
|
+
// (non-query-builder) result is still driver-dependent about handing
|
|
37
|
+
// it back as a number or a numeric string, so `Number(...)` either
|
|
38
|
+
// way. `window_start` itself is never re-read from the row: the
|
|
39
|
+
// upsert always sets it to `excluded.window_start`, never
|
|
40
|
+
// conditionally kept, so it equals the `windowStart` just computed
|
|
41
|
+
// above.
|
|
42
|
+
const count = Number(row.count);
|
|
43
|
+
return {
|
|
44
|
+
limited: count > limitPerWindow,
|
|
45
|
+
retryAfterMs: windowStart + windowMs - now,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/dist/issued/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
|
}
|
package/dist/issued/tables.d.ts
CHANGED
|
@@ -16,6 +16,16 @@
|
|
|
16
16
|
* a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
|
|
17
17
|
* nothing this package's own tests check, since the constraint that actually
|
|
18
18
|
* runs is the one in the SQL migration.
|
|
19
|
+
*
|
|
20
|
+
* `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
|
|
21
|
+
* footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
|
|
22
|
+
* extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
|
|
23
|
+
* once at `issue()`, immutable for the row's life.
|
|
24
|
+
*
|
|
25
|
+
* `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
|
|
26
|
+
* this row that is NOT set-once: `check()` writes both on every successful
|
|
27
|
+
* verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
|
|
28
|
+
* them out of its immutable-columns check.
|
|
19
29
|
*/
|
|
20
30
|
export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
21
31
|
name: "keys_issued_credentials";
|
|
@@ -208,6 +218,23 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
|
|
|
208
218
|
identity: undefined;
|
|
209
219
|
generated: undefined;
|
|
210
220
|
}, {}, {}>;
|
|
221
|
+
environment: import("drizzle-orm/pg-core").PgColumn<{
|
|
222
|
+
name: "environment";
|
|
223
|
+
tableName: "keys_issued_credentials";
|
|
224
|
+
dataType: "string";
|
|
225
|
+
columnType: "PgText";
|
|
226
|
+
data: string;
|
|
227
|
+
driverParam: string;
|
|
228
|
+
notNull: true;
|
|
229
|
+
hasDefault: false;
|
|
230
|
+
isPrimaryKey: false;
|
|
231
|
+
isAutoincrement: false;
|
|
232
|
+
hasRuntimeDefault: false;
|
|
233
|
+
enumValues: [string, ...string[]];
|
|
234
|
+
baseColumn: never;
|
|
235
|
+
identity: undefined;
|
|
236
|
+
generated: undefined;
|
|
237
|
+
}, {}, {}>;
|
|
211
238
|
createdAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
212
239
|
name: "created_at";
|
|
213
240
|
tableName: "keys_issued_credentials";
|
|
@@ -242,6 +269,40 @@ export declare const keysIssuedCredentials: import("drizzle-orm/pg-core").PgTabl
|
|
|
242
269
|
identity: undefined;
|
|
243
270
|
generated: undefined;
|
|
244
271
|
}, {}, {}>;
|
|
272
|
+
lastUsedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
273
|
+
name: "last_used_at";
|
|
274
|
+
tableName: "keys_issued_credentials";
|
|
275
|
+
dataType: "date";
|
|
276
|
+
columnType: "PgTimestamp";
|
|
277
|
+
data: Date;
|
|
278
|
+
driverParam: string;
|
|
279
|
+
notNull: false;
|
|
280
|
+
hasDefault: false;
|
|
281
|
+
isPrimaryKey: false;
|
|
282
|
+
isAutoincrement: false;
|
|
283
|
+
hasRuntimeDefault: false;
|
|
284
|
+
enumValues: undefined;
|
|
285
|
+
baseColumn: never;
|
|
286
|
+
identity: undefined;
|
|
287
|
+
generated: undefined;
|
|
288
|
+
}, {}, {}>;
|
|
289
|
+
useCount: import("drizzle-orm/pg-core").PgColumn<{
|
|
290
|
+
name: "use_count";
|
|
291
|
+
tableName: "keys_issued_credentials";
|
|
292
|
+
dataType: "number";
|
|
293
|
+
columnType: "PgBigInt53";
|
|
294
|
+
data: number;
|
|
295
|
+
driverParam: string | number;
|
|
296
|
+
notNull: true;
|
|
297
|
+
hasDefault: true;
|
|
298
|
+
isPrimaryKey: false;
|
|
299
|
+
isAutoincrement: false;
|
|
300
|
+
hasRuntimeDefault: false;
|
|
301
|
+
enumValues: undefined;
|
|
302
|
+
baseColumn: never;
|
|
303
|
+
identity: undefined;
|
|
304
|
+
generated: undefined;
|
|
305
|
+
}, {}, {}>;
|
|
245
306
|
};
|
|
246
307
|
dialect: "pg";
|
|
247
308
|
}>;
|
package/dist/issued/tables.js
CHANGED
|
@@ -17,6 +17,16 @@ import { bigint, jsonb, pgTable, text, timestamp } from 'drizzle-orm/pg-core';
|
|
|
17
17
|
* a lazily-declared drizzle table needs an `AnyPgColumn` callback and buys
|
|
18
18
|
* nothing this package's own tests check, since the constraint that actually
|
|
19
19
|
* runs is the one in the SQL migration.
|
|
20
|
+
*
|
|
21
|
+
* `environment` (#47) is not part of `SignableRow` -- unsigned metadata, same
|
|
22
|
+
* footing as `idempotencyKey`/`createdAt`. `0005_keys_environment.sql`'s
|
|
23
|
+
* extended `keys_issued_guard()` trigger is what keeps it trustworthy: set
|
|
24
|
+
* once at `issue()`, immutable for the row's life.
|
|
25
|
+
*
|
|
26
|
+
* `lastUsedAt`/`useCount` (#46, `0006_keys_usage.sql`) are the one pair on
|
|
27
|
+
* this row that is NOT set-once: `check()` writes both on every successful
|
|
28
|
+
* verification, so -- unlike `environment` -- `keys_issued_guard()` leaves
|
|
29
|
+
* them out of its immutable-columns check.
|
|
20
30
|
*/
|
|
21
31
|
export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
|
|
22
32
|
id: text('id').primaryKey(),
|
|
@@ -30,6 +40,9 @@ export const keysIssuedCredentials = pgTable('keys_issued_credentials', {
|
|
|
30
40
|
signature: text('signature').notNull(),
|
|
31
41
|
signingGenerationId: text('signing_generation_id').notNull(),
|
|
32
42
|
idempotencyKey: text('idempotency_key'),
|
|
43
|
+
environment: text('environment').notNull(),
|
|
33
44
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
34
45
|
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
|
46
|
+
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
|
47
|
+
useCount: bigint('use_count', { mode: 'number' }).notNull().default(0),
|
|
35
48
|
});
|