@wtfalch/keys 0.1.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.
@@ -0,0 +1,88 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core';
2
+ import type { SerializedCredential, VerifyKeys, WorkerClient, WorkerErrorCode } from '../worker-contract.js';
3
+ export { ID_PATTERN, SIGNED_PREFIX, type RequestMeta, type SerializedCredential, type SignableRow, type SignedKind, type SignedRow, type SignRowRequest, type SignRowResult, type SignRowSuccess, type VerifyKeys, type WorkerClient, type WorkerError, type WorkerErrorCode, type WorkerFailure, } from '../worker-contract.js';
4
+ export type Db = PgDatabase<PgQueryResultHKT, any, any>;
5
+ export interface AuditEvent {
6
+ readonly name: 'credential.minted' | 'credential.rotated' | 'credential.revoked';
7
+ readonly credentialId: string;
8
+ /** The id sent to the Worker on the same call, when there was one — omitted for `revoke()`, which never calls it. */
9
+ readonly requestId?: string;
10
+ readonly at: number;
11
+ }
12
+ export type AuditCallback = (event: AuditEvent) => Promise<void> | void;
13
+ export interface CreateCredentialIssuerOptions<TGrant> {
14
+ readonly db: Db;
15
+ /** Only `signRow` is called: `check()` and `revoke()` stay fully local. */
16
+ readonly worker: Pick<WorkerClient<TGrant>, 'signRow'>;
17
+ /** This host's own Worker-signed service credential, sent as the bearer on every `signRow` call. */
18
+ readonly credential: SerializedCredential;
19
+ /** The Worker's current Ed25519 public key, and its previous one during a Worker key-rotation window. */
20
+ readonly verifyKeys: VerifyKeys;
21
+ /** `credential.minted` / `credential.rotated` / `credential.revoked`, for the host's own audit chain. */
22
+ readonly audit?: AuditCallback;
23
+ /** This host's split-secret prefix, e.g. `ai_live_`. Fixed per issuer; never inferred from a row. */
24
+ readonly prefix: string;
25
+ /** Unix milliseconds. Defaults to `Date.now`; a test passes a fake clock. */
26
+ readonly now?: () => number;
27
+ }
28
+ export interface IssueRequest<TGrant> {
29
+ readonly grants: readonly TGrant[];
30
+ /** The id of the credential minting this one, or `null` for a root mint. */
31
+ readonly minter: string | null;
32
+ /** Unix milliseconds. Required: refused when missing or already past, never clamped to the minter's own. */
33
+ readonly expiresAt: number;
34
+ readonly idempotencyKey?: string;
35
+ }
36
+ export type IssueRefusalReason = 'expires_at_required' | 'expires_at_past' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
37
+ export type IssueResult = {
38
+ readonly ok: true;
39
+ readonly id: string;
40
+ readonly keyPrefix: string;
41
+ /** Shown exactly once. `null` on an idempotent replay — only the hash was ever stored. */
42
+ readonly secret: string | null;
43
+ } | {
44
+ readonly ok: false;
45
+ readonly reason: IssueRefusalReason;
46
+ };
47
+ export type CheckRefusalReason = 'not_found' | 'signature_invalid' | 'revoked' | 'expired' | 'secret_mismatch';
48
+ export type CheckResult<TGrant> = {
49
+ readonly ok: true;
50
+ readonly id: string;
51
+ readonly issuedById: string | null;
52
+ readonly grants: readonly TGrant[];
53
+ } | {
54
+ readonly ok: false;
55
+ readonly reason: CheckRefusalReason;
56
+ };
57
+ export type RotateRefusalReason = 'not_found' | 'revoked' | 'minter_not_found' | 'minter_revoked' | 'minter_expired' | WorkerErrorCode;
58
+ export type RotateResult = {
59
+ readonly ok: true;
60
+ readonly secret: string;
61
+ } | {
62
+ readonly ok: false;
63
+ readonly reason: RotateRefusalReason;
64
+ };
65
+ export interface CredentialLink<TGrant> {
66
+ readonly id: string;
67
+ readonly issuedById: string | null;
68
+ readonly grants: readonly TGrant[];
69
+ readonly expiresAt: number;
70
+ readonly revokedAt: number | null;
71
+ }
72
+ export interface CredentialIssuer<TGrant> {
73
+ /** Calls `worker.signRow`. */
74
+ issue(request: IssueRequest<TGrant>): Promise<IssueResult>;
75
+ /** Fully local — never calls `worker`. */
76
+ check(secret: string): Promise<CheckResult<TGrant>>;
77
+ /** Calls `worker.signRow`. */
78
+ rotate(id: string, options: {
79
+ readonly graceMs: number;
80
+ }): Promise<RotateResult>;
81
+ /** Fully local — works during a Worker outage. Cascades by default. */
82
+ revoke(id: string, options?: {
83
+ readonly cascade?: boolean;
84
+ }): Promise<readonly string[]>;
85
+ /** The chain from `id` to its root, `id` first. Fully local. */
86
+ lineageOf(id: string): Promise<readonly CredentialLink<TGrant>[]>;
87
+ }
88
+ export declare function createCredentialIssuer<TGrant>(options: CreateCredentialIssuerOptions<TGrant>): CredentialIssuer<TGrant>;
@@ -0,0 +1,273 @@
1
+ import { and, eq, inArray, isNull } from 'drizzle-orm';
2
+ import { sql } from 'drizzle-orm';
3
+ import { fromBase64Url, toBase64Url } from './codec.js';
4
+ import { keyPrefixOf, mintSecret, rotateSecret, secretMatches } from './secret.js';
5
+ import { keysIssuedCredentials } from './tables.js';
6
+ import { verifyIssuedRowSignature } from './verify.js';
7
+ export { ID_PATTERN, SIGNED_PREFIX, } from '../worker-contract.js';
8
+ function rowsOf(result) {
9
+ if (Array.isArray(result))
10
+ return result;
11
+ return (result.rows ?? []);
12
+ }
13
+ function randomId() {
14
+ return crypto.randomUUID();
15
+ }
16
+ function toSignableRow(row) {
17
+ return {
18
+ id: row.id,
19
+ issuedById: row.issuedById,
20
+ keyPrefix: row.keyPrefix,
21
+ secretHash: row.secretHash,
22
+ previousSecretHash: row.previousSecretHash,
23
+ previousValidUntil: row.previousValidUntil,
24
+ grants: row.grants,
25
+ expiresAt: row.expiresAt,
26
+ };
27
+ }
28
+ export function createCredentialIssuer(options) {
29
+ const { db, worker, credential, verifyKeys, audit, prefix } = options;
30
+ const now = options.now ?? (() => Date.now());
31
+ const table = keysIssuedCredentials;
32
+ async function loadById(id) {
33
+ const [row] = await db.select().from(table).where(eq(table.id, id)).limit(1);
34
+ return row;
35
+ }
36
+ async function loadByPrefix(keyPrefix) {
37
+ const [row] = await db.select().from(table).where(eq(table.keyPrefix, keyPrefix)).limit(1);
38
+ return row;
39
+ }
40
+ async function loadByIdempotencyKey(key) {
41
+ const [row] = await db.select().from(table).where(eq(table.idempotencyKey, key)).limit(1);
42
+ return row;
43
+ }
44
+ /** 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 `
47
+ with recursive descendants as (
48
+ select id from keys_issued_credentials where id = ${id}
49
+ union
50
+ select c.id
51
+ from keys_issued_credentials c
52
+ join descendants d on c.issued_by_id = d.id
53
+ )
54
+ select id from descendants
55
+ `);
56
+ return rowsOf(result).map((r) => r.id);
57
+ }
58
+ async function emit(event) {
59
+ await audit?.(event);
60
+ }
61
+ async function issue(request) {
62
+ if (request.idempotencyKey) {
63
+ const existing = await loadByIdempotencyKey(request.idempotencyKey);
64
+ if (existing) {
65
+ return { ok: true, id: existing.id, keyPrefix: existing.keyPrefix, secret: null };
66
+ }
67
+ }
68
+ if (typeof request.expiresAt !== 'number' || !Number.isFinite(request.expiresAt)) {
69
+ return { ok: false, reason: 'expires_at_required' };
70
+ }
71
+ if (request.expiresAt <= now()) {
72
+ return { ok: false, reason: 'expires_at_past' };
73
+ }
74
+ let minterAttestation = null;
75
+ if (request.minter !== null) {
76
+ const minterRow = await loadById(request.minter);
77
+ if (!minterRow)
78
+ return { ok: false, reason: 'minter_not_found' };
79
+ // Local and authoritative: revoke() never calls the Worker, so its own
80
+ // mint log can lag a routine local revoke. The host's own table is the
81
+ // source of truth for whether a minter is still good.
82
+ if (minterRow.revokedAt !== null)
83
+ return { ok: false, reason: 'minter_revoked' };
84
+ if (minterRow.expiresAt <= now())
85
+ return { ok: false, reason: 'minter_expired' };
86
+ minterAttestation = {
87
+ row: toSignableRow(minterRow),
88
+ signature: fromBase64Url(minterRow.signature),
89
+ };
90
+ }
91
+ const id = randomId();
92
+ const minted = mintSecret(prefix);
93
+ const candidate = {
94
+ id,
95
+ issuedById: request.minter,
96
+ keyPrefix: minted.keyPrefix,
97
+ secretHash: minted.secretHash,
98
+ previousSecretHash: null,
99
+ previousValidUntil: null,
100
+ grants: request.grants,
101
+ expiresAt: request.expiresAt,
102
+ };
103
+ const requestId = randomId();
104
+ const result = await worker.signRow({
105
+ requestId,
106
+ credential,
107
+ candidate,
108
+ minter: minterAttestation,
109
+ });
110
+ if (!result.ok)
111
+ return { ok: false, reason: result.error.code };
112
+ 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,
123
+ });
124
+ }
125
+ catch (err) {
126
+ // A concurrent issue() with the same idempotencyKey won the race: the
127
+ // partial unique index refused this insert. Replay rather than throw.
128
+ if (request.idempotencyKey && isUniqueViolation(err)) {
129
+ const existing = await loadByIdempotencyKey(request.idempotencyKey);
130
+ if (existing) {
131
+ return { ok: true, id: existing.id, keyPrefix: existing.keyPrefix, secret: null };
132
+ }
133
+ }
134
+ throw err;
135
+ }
136
+ await emit({ name: 'credential.minted', credentialId: id, requestId, at: now() });
137
+ return { ok: true, id, keyPrefix: candidate.keyPrefix, secret: minted.secret };
138
+ }
139
+ async function check(secret) {
140
+ const keyPrefix = keyPrefixOf(secret, prefix);
141
+ if (keyPrefix === null)
142
+ return { ok: false, reason: 'not_found' };
143
+ const row = await loadByPrefix(keyPrefix);
144
+ if (!row)
145
+ return { ok: false, reason: 'not_found' };
146
+ // Verified before anything else is trusted about the row: a signature
147
+ // check over the row's CURRENT fields refuses a row a raw UPDATE
148
+ // tampered with, whatever that tamper changed.
149
+ const signable = toSignableRow(row);
150
+ const genuine = await verifyIssuedRowSignature(signable, fromBase64Url(row.signature), verifyKeys);
151
+ if (!genuine)
152
+ return { ok: false, reason: 'signature_invalid' };
153
+ if (row.revokedAt !== null)
154
+ return { ok: false, reason: 'revoked' };
155
+ if (now() >= row.expiresAt)
156
+ return { ok: false, reason: 'expired' };
157
+ if (secretMatches(secret, row.secretHash)) {
158
+ return {
159
+ ok: true,
160
+ id: row.id,
161
+ issuedById: row.issuedById,
162
+ grants: row.grants,
163
+ };
164
+ }
165
+ if (row.previousSecretHash !== null &&
166
+ row.previousValidUntil !== null &&
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
+ };
175
+ }
176
+ return { ok: false, reason: 'secret_mismatch' };
177
+ }
178
+ async function rotate(id, opts) {
179
+ const row = await loadById(id);
180
+ if (!row)
181
+ return { ok: false, reason: 'not_found' };
182
+ if (row.revokedAt !== null)
183
+ return { ok: false, reason: 'revoked' };
184
+ const minted = rotateSecret(row.keyPrefix);
185
+ const previousValidUntil = now() + opts.graceMs;
186
+ const candidate = {
187
+ id: row.id,
188
+ issuedById: row.issuedById,
189
+ keyPrefix: row.keyPrefix,
190
+ secretHash: minted.secretHash,
191
+ previousSecretHash: row.secretHash,
192
+ previousValidUntil,
193
+ grants: row.grants,
194
+ expiresAt: row.expiresAt,
195
+ };
196
+ let minterAttestation = null;
197
+ if (row.issuedById !== null) {
198
+ const parent = await loadById(row.issuedById);
199
+ if (!parent)
200
+ return { ok: false, reason: 'minter_not_found' };
201
+ // Same local, authoritative check as issue() — see the comment there.
202
+ if (parent.revokedAt !== null)
203
+ return { ok: false, reason: 'minter_revoked' };
204
+ if (parent.expiresAt <= now())
205
+ return { ok: false, reason: 'minter_expired' };
206
+ minterAttestation = {
207
+ row: toSignableRow(parent),
208
+ signature: fromBase64Url(parent.signature),
209
+ };
210
+ }
211
+ const requestId = randomId();
212
+ const result = await worker.signRow({
213
+ requestId,
214
+ credential,
215
+ candidate,
216
+ minter: minterAttestation,
217
+ });
218
+ if (!result.ok)
219
+ 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));
230
+ await emit({ name: 'credential.rotated', credentialId: id, requestId, at: now() });
231
+ return { ok: true, secret: minted.secret };
232
+ }
233
+ 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 });
244
+ for (const r of revokedRows) {
245
+ await emit({ name: 'credential.revoked', credentialId: r.id, at: now() });
246
+ }
247
+ return revokedRows.map((r) => r.id);
248
+ }
249
+ async function lineageOf(id) {
250
+ const chain = [];
251
+ const seen = new Set();
252
+ let currentId = id;
253
+ while (currentId !== null && !seen.has(currentId)) {
254
+ seen.add(currentId);
255
+ const row = await loadById(currentId);
256
+ if (!row)
257
+ break;
258
+ chain.push({
259
+ id: row.id,
260
+ issuedById: row.issuedById,
261
+ grants: row.grants,
262
+ expiresAt: row.expiresAt,
263
+ revokedAt: row.revokedAt ? row.revokedAt.getTime() : null,
264
+ });
265
+ currentId = row.issuedById;
266
+ }
267
+ return chain;
268
+ }
269
+ return { issue, check, rotate, revoke, lineageOf };
270
+ }
271
+ function isUniqueViolation(err) {
272
+ return err?.code === '23505';
273
+ }
@@ -0,0 +1,30 @@
1
+ export interface MintedSecret {
2
+ /** Shown to the caller exactly once. Never stored. */
3
+ readonly secret: string;
4
+ /** Stored in the clear; the indexed lookup key. */
5
+ readonly keyPrefix: string;
6
+ /** Hex SHA-256 of the whole secret. Stored instead of the secret. */
7
+ readonly secretHash: string;
8
+ }
9
+ export declare function hashSecret(secret: string): string;
10
+ /** A fresh public id and a fresh tail: a brand-new credential. */
11
+ export declare function mintSecret(prefix: string): MintedSecret;
12
+ /** A fresh tail under an EXISTING `keyPrefix`: what rotate() calls, so the row keeps its indexed identity. */
13
+ export declare function rotateSecret(keyPrefix: string): MintedSecret;
14
+ /**
15
+ * The indexed half of a presented secret, or null if it is not shaped like
16
+ * one under this host's `prefix`.
17
+ *
18
+ * Returning null rather than throwing keeps the caller's refusal uniform: a
19
+ * malformed secret and an unknown one must be indistinguishable, or refusal
20
+ * becomes an oracle for which prefixes exist.
21
+ */
22
+ export declare function keyPrefixOf(presented: string, prefix: string): string | null;
23
+ /**
24
+ * Constant-time comparison of a presented secret against a stored hex hash.
25
+ *
26
+ * `timingSafeEqual` throws on length mismatch, which would itself leak, so
27
+ * lengths are checked first. Both sides are fixed-length hex here (SHA-256),
28
+ * so that branch means corrupt data rather than an attack.
29
+ */
30
+ export declare function secretMatches(presented: string, storedHashHex: string): boolean;
@@ -0,0 +1,69 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ /**
3
+ * The split-secret format, carried over from valet's `src/lib/auth/keys.ts`:
4
+ *
5
+ * <prefix><8-char public id><20-byte secret tail>
6
+ *
7
+ * The prefix through the public id is stored in the clear and is what a
8
+ * lookup indexes on; the tail is never stored, only the SHA-256 hash of the
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.
11
+ *
12
+ * `node:crypto` rather than WebCrypto: this is a Node-side, host-only
13
+ * mechanic (unlike encoding.ts, nothing here runs in the Worker), and
14
+ * `timingSafeEqual` is what makes the comparison constant-time.
15
+ */
16
+ const PUBLIC_ID_LEN = 8;
17
+ const SECRET_BYTES = 20;
18
+ /** Crockford-ish base32: no padding, no vowels-to-digits confusion in logs. */
19
+ const ALPHABET = '0123456789abcdefghjkmnpqrstvwxyz';
20
+ function encode(bytes) {
21
+ let out = '';
22
+ for (const b of bytes)
23
+ out += ALPHABET[b % ALPHABET.length];
24
+ return out;
25
+ }
26
+ export function hashSecret(secret) {
27
+ return createHash('sha256').update(secret, 'utf8').digest('hex');
28
+ }
29
+ /** A fresh public id and a fresh tail: a brand-new credential. */
30
+ export function mintSecret(prefix) {
31
+ const publicId = encode(randomBytes(PUBLIC_ID_LEN));
32
+ return rotateSecret(`${prefix}${publicId}`);
33
+ }
34
+ /** A fresh tail under an EXISTING `keyPrefix`: what rotate() calls, so the row keeps its indexed identity. */
35
+ export function rotateSecret(keyPrefix) {
36
+ const tail = encode(randomBytes(SECRET_BYTES));
37
+ const secret = `${keyPrefix}${tail}`;
38
+ return { secret, keyPrefix, secretHash: hashSecret(secret) };
39
+ }
40
+ /**
41
+ * The indexed half of a presented secret, or null if it is not shaped like
42
+ * one under this host's `prefix`.
43
+ *
44
+ * Returning null rather than throwing keeps the caller's refusal uniform: a
45
+ * malformed secret and an unknown one must be indistinguishable, or refusal
46
+ * becomes an oracle for which prefixes exist.
47
+ */
48
+ export function keyPrefixOf(presented, prefix) {
49
+ if (!presented.startsWith(prefix))
50
+ return null;
51
+ const expected = prefix.length + PUBLIC_ID_LEN + SECRET_BYTES;
52
+ if (presented.length !== expected)
53
+ return null;
54
+ return presented.slice(0, prefix.length + PUBLIC_ID_LEN);
55
+ }
56
+ /**
57
+ * Constant-time comparison of a presented secret against a stored hex hash.
58
+ *
59
+ * `timingSafeEqual` throws on length mismatch, which would itself leak, so
60
+ * lengths are checked first. Both sides are fixed-length hex here (SHA-256),
61
+ * so that branch means corrupt data rather than an attack.
62
+ */
63
+ export function secretMatches(presented, storedHashHex) {
64
+ const a = Buffer.from(hashSecret(presented), 'hex');
65
+ const b = Buffer.from(storedHashHex, 'hex');
66
+ if (a.length !== b.length)
67
+ return false;
68
+ return timingSafeEqual(a, b);
69
+ }