@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.
- package/README.md +21 -0
- package/dist/bin/copy.d.ts +31 -0
- package/dist/bin/copy.js +61 -0
- package/dist/bin/migrations.d.ts +2 -0
- package/dist/bin/migrations.js +18 -0
- package/dist/held/aad.d.ts +13 -0
- package/dist/held/aad.js +25 -0
- package/dist/held/index.d.ts +134 -0
- package/dist/held/index.js +308 -0
- package/dist/held/schema.d.ts +278 -0
- package/dist/held/schema.js +43 -0
- package/dist/held/shred.d.ts +34 -0
- package/dist/held/shred.js +76 -0
- package/dist/issued/codec.d.ts +3 -0
- package/dist/issued/codec.js +7 -0
- package/dist/issued/encoding.d.ts +48 -0
- package/dist/issued/encoding.js +132 -0
- package/dist/issued/index.d.ts +88 -0
- package/dist/issued/index.js +273 -0
- package/dist/issued/secret.d.ts +30 -0
- package/dist/issued/secret.js +69 -0
- package/dist/issued/tables.d.ts +250 -0
- package/dist/issued/tables.js +35 -0
- package/dist/issued/verify.d.ts +8 -0
- package/dist/issued/verify.js +31 -0
- package/dist/migrations/0001_keys_issued.sql +80 -0
- package/dist/migrations/0002_keys_held.sql +60 -0
- package/dist/migrations/0003_keys_shred.sql +361 -0
- package/dist/worker-contract.d.ts +360 -0
- package/dist/worker-contract.js +94 -0
- package/package.json +52 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between `@wtfalch/keys` and the unwrap Worker.
|
|
3
|
+
*
|
|
4
|
+
* Types, constants and comments only. `./issued` (#230), `./held` (#231) and
|
|
5
|
+
* the Worker (#233) build against this one shape. The byte encodings are
|
|
6
|
+
* specified here and implemented once each: `encodeSigned` in
|
|
7
|
+
* `src/issued/encoding.ts` (#230) and `encodeAad` in `src/held/aad.ts` (#231).
|
|
8
|
+
* The Worker imports those two implementations rather than writing its own.
|
|
9
|
+
*
|
|
10
|
+
* Who does what:
|
|
11
|
+
* - The Worker implements every endpoint in `WORKER_HTTP`, holds the Ed25519
|
|
12
|
+
* signing keys and each host's wrapping-key keyring, and owns two D1
|
|
13
|
+
* tables: the mint/unwrap log and `revoked_credentials`.
|
|
14
|
+
* - `./issued` calls `signRow` from `issue()` and `rotate()`, `resign` from a
|
|
15
|
+
* signing-key rotation sweep, and `recordEviction` from the incident
|
|
16
|
+
* procedure after a local cascade `revoke()`. `check()`, `revoke()` and
|
|
17
|
+
* `lineageOf()` never call the Worker, so revoking works during an outage.
|
|
18
|
+
* - `./held` calls `wrap` to seal a new version, `unwrap` on a cache-miss
|
|
19
|
+
* `open()`, and `rewrap` from a wrapping-key rotation sweep. Encrypting
|
|
20
|
+
* and decrypting the value with the data key, and the 60-second data-key
|
|
21
|
+
* cache, are local.
|
|
22
|
+
*
|
|
23
|
+
* Sources: `.plans/2026-09-13-keys.md` in app-template, and the revised
|
|
24
|
+
* answers on app-template #216, #217, #218, #224, #225 and #228.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Every id in this contract (host, tenant, entry, credential, row, key
|
|
28
|
+
* generation) matches this pattern. Both sides refuse anything else before
|
|
29
|
+
* encoding it, so no id can smuggle a delimiter into signed or
|
|
30
|
+
* authenticated bytes.
|
|
31
|
+
*/
|
|
32
|
+
export declare const ID_PATTERN: RegExp;
|
|
33
|
+
/**
|
|
34
|
+
* What a signature is over. The Worker's one Ed25519 key signs two kinds of
|
|
35
|
+
* message, so every signed message starts with its kind. Without the tag, a
|
|
36
|
+
* host could ask `signRow` for a row whose bytes also parse as a caller
|
|
37
|
+
* credential, and use the signature as a fresh, unrevoked credential.
|
|
38
|
+
*/
|
|
39
|
+
export type SignedKind = 'issued-row' | 'caller-credential';
|
|
40
|
+
/**
|
|
41
|
+
* `encodeSigned(kind, value)` is the UTF-8 bytes of
|
|
42
|
+
*
|
|
43
|
+
* `wtfalch-keys:${kind}:v1\n` + canonicalJson(value)
|
|
44
|
+
*
|
|
45
|
+
* where `canonicalJson` is RFC 8785 (JCS): object keys sorted by UTF-16 code
|
|
46
|
+
* unit, no insignificant whitespace, numbers in their shortest round-trip
|
|
47
|
+
* form, `undefined` refused. JCS rather than `JSON.stringify` because a host
|
|
48
|
+
* stores `grants` as `jsonb`, which does not keep key order. `check()`
|
|
49
|
+
* re-encodes from the stored row, so the bytes must not depend on how the
|
|
50
|
+
* row came back from Postgres.
|
|
51
|
+
*
|
|
52
|
+
* Timestamps inside a signed value are integer Unix milliseconds, never ISO
|
|
53
|
+
* strings, for the same reason: `timestamptz` does not round-trip an ISO
|
|
54
|
+
* string byte for byte.
|
|
55
|
+
*/
|
|
56
|
+
export declare const SIGNED_PREFIX: "wtfalch-keys:";
|
|
57
|
+
/** One held-key row and version. */
|
|
58
|
+
export interface KeyBinding {
|
|
59
|
+
readonly tenantId: string;
|
|
60
|
+
readonly entryId: string;
|
|
61
|
+
/** The version's sequence number, an integer from 1. */
|
|
62
|
+
readonly version: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* `encodeAad(binding)` is the UTF-8 bytes of
|
|
66
|
+
*
|
|
67
|
+
* `wtfalch-keys:aad:v1\n` + JSON.stringify([tenantId, entryId, version])
|
|
68
|
+
*
|
|
69
|
+
* Ids match `ID_PATTERN` and `version` is an integer, so `JSON.stringify`
|
|
70
|
+
* is already canonical here. The wrapped data key (checked by the Worker)
|
|
71
|
+
* and the value's own ciphertext (checked by the host) use the same AAD, so
|
|
72
|
+
* a row copied to another tenant or relabelled to another version fails the
|
|
73
|
+
* GCM tag on both layers.
|
|
74
|
+
*
|
|
75
|
+
* This is a new format. It is not app-template's tier-1 `keystore.v1|...`
|
|
76
|
+
* string, whose tables this package does not read.
|
|
77
|
+
*/
|
|
78
|
+
export declare const AAD_PREFIX: "wtfalch-keys:aad:v1\n";
|
|
79
|
+
/** What a caller credential may ask the Worker for. A closed set, not `@wtfalch/authz` scopes. */
|
|
80
|
+
export type CallerGrant = 'wrap' | 'unwrap' | 'rewrap' | 'sign' | 'resign' | 'evict';
|
|
81
|
+
/**
|
|
82
|
+
* What the Worker signs, as `encodeSigned('caller-credential', …)`, when it
|
|
83
|
+
* mints a host's service credential. `./issued` and `./held` never parse it:
|
|
84
|
+
* they read the serialized form from host config and forward it.
|
|
85
|
+
*/
|
|
86
|
+
export interface CallerCredential {
|
|
87
|
+
readonly id: string;
|
|
88
|
+
/**
|
|
89
|
+
* The host this credential belongs to. The Worker resolves every `kekId`
|
|
90
|
+
* inside this host's keyring only, and records `hostId` on every log row.
|
|
91
|
+
* A `kekId` from another host's keyring is `unknown_generation`, so a
|
|
92
|
+
* host holding another host's ciphertext still cannot open it.
|
|
93
|
+
*/
|
|
94
|
+
readonly hostId: string;
|
|
95
|
+
readonly grants: readonly CallerGrant[];
|
|
96
|
+
/** Unix milliseconds. Required; checked on every call. */
|
|
97
|
+
readonly expiresAt: number;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `base64url(encodeSigned('caller-credential', credential)) + '.' +
|
|
101
|
+
* base64url(signature)`. Sent as `Authorization: Bearer <this>`. Opaque to
|
|
102
|
+
* the package.
|
|
103
|
+
*/
|
|
104
|
+
export type SerializedCredential = string;
|
|
105
|
+
/**
|
|
106
|
+
* The verify keys a host holds for the Worker's signing key. `check()` tries
|
|
107
|
+
* `current`, then `previous`; never a network call. Two slots at most:
|
|
108
|
+
* rotation re-signs every live row, so the tuple never grows (#217, #228).
|
|
109
|
+
*/
|
|
110
|
+
export type VerifyKeys = readonly [current: Uint8Array, previous?: Uint8Array];
|
|
111
|
+
/**
|
|
112
|
+
* Every field the Worker signs for one issued credential, as
|
|
113
|
+
* `encodeSigned('issued-row', row)`. A field outside this object is not
|
|
114
|
+
* protected by the signature, so anything that decides whether a secret
|
|
115
|
+
* authenticates is in here.
|
|
116
|
+
*
|
|
117
|
+
* `check()` builds the value to verify by picking exactly these eight fields
|
|
118
|
+
* from the stored row, never by passing the database row itself: extra
|
|
119
|
+
* columns would change the bytes and fail every check, or worse, be ignored
|
|
120
|
+
* by a lenient encoder.
|
|
121
|
+
*/
|
|
122
|
+
export interface SignableRow<TGrant> {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
/** The parent credential's id, or `null` for a root mint. */
|
|
125
|
+
readonly issuedById: string | null;
|
|
126
|
+
/** The split-secret public id, stored in the clear and indexed. */
|
|
127
|
+
readonly keyPrefix: string;
|
|
128
|
+
/** Hex SHA-256 of the whole secret. Never the secret. */
|
|
129
|
+
readonly secretHash: string;
|
|
130
|
+
/**
|
|
131
|
+
* During a rotation's grace window, the hash of the secret being replaced,
|
|
132
|
+
* with `previousValidUntil`. Both `null` outside a rotation. Signed,
|
|
133
|
+
* because an unsigned grace pair would let a database writer add a secret
|
|
134
|
+
* of their own to a genuinely signed row.
|
|
135
|
+
*/
|
|
136
|
+
readonly previousSecretHash: string | null;
|
|
137
|
+
/** Unix milliseconds, or `null`. Must be `null` exactly when `previousSecretHash` is. */
|
|
138
|
+
readonly previousValidUntil: number | null;
|
|
139
|
+
/** Opaque to the Worker and to this package; `@wtfalch/authz` interprets it. Must be JSON. */
|
|
140
|
+
readonly grants: readonly TGrant[];
|
|
141
|
+
/** Unix milliseconds. Required: a missing expiry is refused, never clamped. */
|
|
142
|
+
readonly expiresAt: number;
|
|
143
|
+
}
|
|
144
|
+
/** A `SignableRow` plus the Worker's signature over it. What `./issued` stores. */
|
|
145
|
+
export interface SignedRow<TGrant> extends SignableRow<TGrant> {
|
|
146
|
+
readonly signature: Uint8Array;
|
|
147
|
+
}
|
|
148
|
+
export type WorkerErrorCode =
|
|
149
|
+
/** The bearer credential does not parse, or its signature does not verify. */
|
|
150
|
+
'credential_invalid'
|
|
151
|
+
/** The bearer credential's `expiresAt` has passed. */
|
|
152
|
+
| 'credential_expired'
|
|
153
|
+
/** The bearer credential's id is in `revoked_credentials`. */
|
|
154
|
+
| 'credential_revoked'
|
|
155
|
+
/** The bearer credential lacks the `CallerGrant` this call needs. */
|
|
156
|
+
| 'grant_missing'
|
|
157
|
+
/** Per-credential rate limit exceeded; see `retryAfterMs`. */
|
|
158
|
+
| 'rate_limited'
|
|
159
|
+
/** The request body is malformed, or an id fails `ID_PATTERN`. */
|
|
160
|
+
| 'bad_request'
|
|
161
|
+
/** `unwrap`/`rewrap`: the GCM tag check failed for this binding and wrapped key. */
|
|
162
|
+
| 'aad_mismatch'
|
|
163
|
+
/** `unwrap`/`rewrap`/`resign`: the named key generation is not in this host's keyring. */
|
|
164
|
+
| 'unknown_generation'
|
|
165
|
+
/** `signRow`: the minter row's signature does not verify. */
|
|
166
|
+
| 'bad_minter_signature'
|
|
167
|
+
/** `signRow`: the minter row's `expiresAt` has passed. */
|
|
168
|
+
| 'minter_expired'
|
|
169
|
+
/** `signRow`: the minter row's id is in `revoked_credentials`. */
|
|
170
|
+
| 'minter_revoked'
|
|
171
|
+
/**
|
|
172
|
+
* `signRow`: the candidate's `issuedById` is not the minter row's `id`, a
|
|
173
|
+
* root mint names a parent, or the minter row was not minted by this host.
|
|
174
|
+
*/
|
|
175
|
+
| 'lineage_mismatch'
|
|
176
|
+
/** `resign`: `oldSignature` does not verify under `oldGenerationId`. */
|
|
177
|
+
| 'bad_old_signature'
|
|
178
|
+
/** `resign`: the signature verifies but the sign log has no such mint. Treat as forged: force-revoke, do not retry. */
|
|
179
|
+
| 'unlogged_row'
|
|
180
|
+
/**
|
|
181
|
+
* `resign`: the mint log does not record `row.id` as minted under the
|
|
182
|
+
* calling credential's `hostId`. `recordEviction`: `credentialId` does not
|
|
183
|
+
* trace back, through the mint log's parent ids, to a root minted under
|
|
184
|
+
* the calling credential's `hostId`.
|
|
185
|
+
*/
|
|
186
|
+
| 'not_in_lineage'
|
|
187
|
+
/** Never sent by the Worker. The client reports it for a network failure, a timeout, or a response the Worker did not send. */
|
|
188
|
+
| 'unreachable';
|
|
189
|
+
export interface WorkerError {
|
|
190
|
+
readonly code: WorkerErrorCode;
|
|
191
|
+
/** Only with `rate_limited`; mirrors `Retry-After`, in milliseconds. */
|
|
192
|
+
readonly retryAfterMs?: number;
|
|
193
|
+
readonly message?: string;
|
|
194
|
+
}
|
|
195
|
+
export interface WorkerFailure {
|
|
196
|
+
readonly ok: false;
|
|
197
|
+
readonly error: WorkerError;
|
|
198
|
+
}
|
|
199
|
+
export interface RequestMeta {
|
|
200
|
+
/**
|
|
201
|
+
* One per call, chosen by the caller, matching `ID_PATTERN`. The Worker
|
|
202
|
+
* writes it on the log row, and the host writes the same id on its own
|
|
203
|
+
* `key.used` / `credential.minted` row, so reconciliation (#236) can pair
|
|
204
|
+
* them.
|
|
205
|
+
*/
|
|
206
|
+
readonly requestId: string;
|
|
207
|
+
/** Sent as `Authorization: Bearer`, never in the body. */
|
|
208
|
+
readonly credential: SerializedCredential;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Seals a new held-key version. The Worker generates a fresh 32-byte data
|
|
212
|
+
* key, wraps it under the calling host's `current` generation with
|
|
213
|
+
* `encodeAad(binding)` as associated data, and returns both. The host
|
|
214
|
+
* encrypts the value locally with `dataKey`, stores `wrappedKey` and
|
|
215
|
+
* `kekId`, and zeroes `dataKey`. The host never chooses a data key and never
|
|
216
|
+
* sees a wrapping key.
|
|
217
|
+
*/
|
|
218
|
+
export interface WrapRequest extends RequestMeta {
|
|
219
|
+
readonly binding: KeyBinding;
|
|
220
|
+
}
|
|
221
|
+
export interface WrapSuccess {
|
|
222
|
+
readonly ok: true;
|
|
223
|
+
readonly dataKey: Uint8Array;
|
|
224
|
+
readonly wrappedKey: Uint8Array;
|
|
225
|
+
readonly kekId: string;
|
|
226
|
+
}
|
|
227
|
+
export type WrapResult = WrapSuccess | WorkerFailure;
|
|
228
|
+
export interface UnwrapRequest extends RequestMeta {
|
|
229
|
+
readonly kekId: string;
|
|
230
|
+
readonly wrappedKey: Uint8Array;
|
|
231
|
+
readonly binding: KeyBinding;
|
|
232
|
+
}
|
|
233
|
+
export interface UnwrapSuccess {
|
|
234
|
+
readonly ok: true;
|
|
235
|
+
/** The raw 32-byte data key. The Worker neither caches nor logs it. */
|
|
236
|
+
readonly dataKey: Uint8Array;
|
|
237
|
+
}
|
|
238
|
+
export type UnwrapResult = UnwrapSuccess | WorkerFailure;
|
|
239
|
+
export interface RewrapRequest extends RequestMeta {
|
|
240
|
+
readonly kekIdOld: string;
|
|
241
|
+
readonly kekIdNew: string;
|
|
242
|
+
readonly wrappedKey: Uint8Array;
|
|
243
|
+
readonly binding: KeyBinding;
|
|
244
|
+
}
|
|
245
|
+
export interface RewrapSuccess {
|
|
246
|
+
readonly ok: true;
|
|
247
|
+
/** The same data key wrapped under `kekIdNew`. The raw data key never leaves the Worker. */
|
|
248
|
+
readonly wrappedKey: Uint8Array;
|
|
249
|
+
}
|
|
250
|
+
export type RewrapResult = RewrapSuccess | WorkerFailure;
|
|
251
|
+
export interface SignRowRequest<TGrant> extends RequestMeta {
|
|
252
|
+
readonly candidate: SignableRow<TGrant>;
|
|
253
|
+
/**
|
|
254
|
+
* The minting credential's own signed row, or `null` for a root mint
|
|
255
|
+
* (then `candidate.issuedById` must be `null`). The Worker checks: the
|
|
256
|
+
* row's signature verifies, its `expiresAt` has not passed, its id is not
|
|
257
|
+
* revoked, its id was minted by this host (mint log), and
|
|
258
|
+
* `candidate.issuedById === minter.row.id`. It does not judge
|
|
259
|
+
* `candidate.grants`: attenuation runs on the host, through
|
|
260
|
+
* `@wtfalch/authz`, before `issue()`.
|
|
261
|
+
*/
|
|
262
|
+
readonly minter: {
|
|
263
|
+
readonly row: SignableRow<TGrant>;
|
|
264
|
+
readonly signature: Uint8Array;
|
|
265
|
+
} | null;
|
|
266
|
+
}
|
|
267
|
+
export interface SignRowSuccess {
|
|
268
|
+
readonly ok: true;
|
|
269
|
+
readonly signature: Uint8Array;
|
|
270
|
+
/** The signing generation used, so a later `resign` can name it. */
|
|
271
|
+
readonly generationId: string;
|
|
272
|
+
}
|
|
273
|
+
export type SignRowResult = SignRowSuccess | WorkerFailure;
|
|
274
|
+
/**
|
|
275
|
+
* The Worker checks, in order: `oldGenerationId` is in the calling host's
|
|
276
|
+
* signing keyring (`unknown_generation`); `oldSignature` verifies under it
|
|
277
|
+
* (`bad_old_signature`); the mint log records `row.id` as minted under the
|
|
278
|
+
* calling host (`not_in_lineage`); and that log entry was signed under
|
|
279
|
+
* `oldGenerationId` (`unlogged_row`). Only then does it sign `row` under the
|
|
280
|
+
* current generation.
|
|
281
|
+
*/
|
|
282
|
+
export interface ResignRequest<TGrant> extends RequestMeta {
|
|
283
|
+
readonly row: SignableRow<TGrant>;
|
|
284
|
+
readonly oldSignature: Uint8Array;
|
|
285
|
+
readonly oldGenerationId: string;
|
|
286
|
+
}
|
|
287
|
+
export interface ResignSuccess {
|
|
288
|
+
readonly ok: true;
|
|
289
|
+
readonly signature: Uint8Array;
|
|
290
|
+
readonly generationId: string;
|
|
291
|
+
}
|
|
292
|
+
export type ResignResult = ResignSuccess | WorkerFailure;
|
|
293
|
+
export interface RecordEvictionRequest extends RequestMeta {
|
|
294
|
+
/** A credential id the caller has just cascade-revoked in its own store. */
|
|
295
|
+
readonly credentialId: string;
|
|
296
|
+
/** Unix milliseconds. */
|
|
297
|
+
readonly revokedAt: number;
|
|
298
|
+
}
|
|
299
|
+
export interface RecordEvictionSuccess {
|
|
300
|
+
readonly ok: true;
|
|
301
|
+
readonly receiptId: string;
|
|
302
|
+
}
|
|
303
|
+
export type RecordEvictionResult = RecordEvictionSuccess | WorkerFailure;
|
|
304
|
+
/** What `./issued` and `./held` are given. Tests pass a stub; hosts pass the HTTP client. */
|
|
305
|
+
export interface WorkerClient<TGrant = unknown> {
|
|
306
|
+
wrap(req: WrapRequest): Promise<WrapResult>;
|
|
307
|
+
unwrap(req: UnwrapRequest): Promise<UnwrapResult>;
|
|
308
|
+
rewrap(req: RewrapRequest): Promise<RewrapResult>;
|
|
309
|
+
signRow(req: SignRowRequest<TGrant>): Promise<SignRowResult>;
|
|
310
|
+
resign(req: ResignRequest<TGrant>): Promise<ResignResult>;
|
|
311
|
+
recordEviction(req: RecordEvictionRequest): Promise<RecordEvictionResult>;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* One endpoint per call. Bodies are JSON; every `Uint8Array` is base64url on
|
|
315
|
+
* the wire; `requestId` is in the body; the credential is the bearer token.
|
|
316
|
+
*
|
|
317
|
+
* 200 carries the `*Success` body without `ok`. Any other status carries
|
|
318
|
+
* `{ error: WorkerError }`:
|
|
319
|
+
* - 400 `bad_request`.
|
|
320
|
+
* - 401 is about the caller: `credential_invalid`, `credential_expired`,
|
|
321
|
+
* `credential_revoked`.
|
|
322
|
+
* - 403 is about this request: `grant_missing`, `aad_mismatch`,
|
|
323
|
+
* `unknown_generation` (also `resign`), `bad_minter_signature`, `minter_expired`,
|
|
324
|
+
* `minter_revoked`, `lineage_mismatch`, `bad_old_signature`,
|
|
325
|
+
* `unlogged_row`, `not_in_lineage`.
|
|
326
|
+
* - 429 `rate_limited`, with `Retry-After` in seconds.
|
|
327
|
+
* Any other status, or none, is reported by the client as `unreachable`.
|
|
328
|
+
*/
|
|
329
|
+
export declare const WORKER_HTTP: {
|
|
330
|
+
readonly wrap: {
|
|
331
|
+
readonly method: "POST";
|
|
332
|
+
readonly path: "/v1/held/wrap";
|
|
333
|
+
readonly grant: "wrap";
|
|
334
|
+
};
|
|
335
|
+
readonly unwrap: {
|
|
336
|
+
readonly method: "POST";
|
|
337
|
+
readonly path: "/v1/held/unwrap";
|
|
338
|
+
readonly grant: "unwrap";
|
|
339
|
+
};
|
|
340
|
+
readonly rewrap: {
|
|
341
|
+
readonly method: "POST";
|
|
342
|
+
readonly path: "/v1/held/rewrap";
|
|
343
|
+
readonly grant: "rewrap";
|
|
344
|
+
};
|
|
345
|
+
readonly signRow: {
|
|
346
|
+
readonly method: "POST";
|
|
347
|
+
readonly path: "/v1/issued/sign";
|
|
348
|
+
readonly grant: "sign";
|
|
349
|
+
};
|
|
350
|
+
readonly resign: {
|
|
351
|
+
readonly method: "POST";
|
|
352
|
+
readonly path: "/v1/issued/resign";
|
|
353
|
+
readonly grant: "resign";
|
|
354
|
+
};
|
|
355
|
+
readonly recordEviction: {
|
|
356
|
+
readonly method: "POST";
|
|
357
|
+
readonly path: "/v1/issued/evictions";
|
|
358
|
+
readonly grant: "evict";
|
|
359
|
+
};
|
|
360
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract between `@wtfalch/keys` and the unwrap Worker.
|
|
3
|
+
*
|
|
4
|
+
* Types, constants and comments only. `./issued` (#230), `./held` (#231) and
|
|
5
|
+
* the Worker (#233) build against this one shape. The byte encodings are
|
|
6
|
+
* specified here and implemented once each: `encodeSigned` in
|
|
7
|
+
* `src/issued/encoding.ts` (#230) and `encodeAad` in `src/held/aad.ts` (#231).
|
|
8
|
+
* The Worker imports those two implementations rather than writing its own.
|
|
9
|
+
*
|
|
10
|
+
* Who does what:
|
|
11
|
+
* - The Worker implements every endpoint in `WORKER_HTTP`, holds the Ed25519
|
|
12
|
+
* signing keys and each host's wrapping-key keyring, and owns two D1
|
|
13
|
+
* tables: the mint/unwrap log and `revoked_credentials`.
|
|
14
|
+
* - `./issued` calls `signRow` from `issue()` and `rotate()`, `resign` from a
|
|
15
|
+
* signing-key rotation sweep, and `recordEviction` from the incident
|
|
16
|
+
* procedure after a local cascade `revoke()`. `check()`, `revoke()` and
|
|
17
|
+
* `lineageOf()` never call the Worker, so revoking works during an outage.
|
|
18
|
+
* - `./held` calls `wrap` to seal a new version, `unwrap` on a cache-miss
|
|
19
|
+
* `open()`, and `rewrap` from a wrapping-key rotation sweep. Encrypting
|
|
20
|
+
* and decrypting the value with the data key, and the 60-second data-key
|
|
21
|
+
* cache, are local.
|
|
22
|
+
*
|
|
23
|
+
* Sources: `.plans/2026-09-13-keys.md` in app-template, and the revised
|
|
24
|
+
* answers on app-template #216, #217, #218, #224, #225 and #228.
|
|
25
|
+
*/
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Identifiers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Every id in this contract (host, tenant, entry, credential, row, key
|
|
31
|
+
* generation) matches this pattern. Both sides refuse anything else before
|
|
32
|
+
* encoding it, so no id can smuggle a delimiter into signed or
|
|
33
|
+
* authenticated bytes.
|
|
34
|
+
*/
|
|
35
|
+
export const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
36
|
+
/**
|
|
37
|
+
* `encodeSigned(kind, value)` is the UTF-8 bytes of
|
|
38
|
+
*
|
|
39
|
+
* `wtfalch-keys:${kind}:v1\n` + canonicalJson(value)
|
|
40
|
+
*
|
|
41
|
+
* where `canonicalJson` is RFC 8785 (JCS): object keys sorted by UTF-16 code
|
|
42
|
+
* unit, no insignificant whitespace, numbers in their shortest round-trip
|
|
43
|
+
* form, `undefined` refused. JCS rather than `JSON.stringify` because a host
|
|
44
|
+
* stores `grants` as `jsonb`, which does not keep key order. `check()`
|
|
45
|
+
* re-encodes from the stored row, so the bytes must not depend on how the
|
|
46
|
+
* row came back from Postgres.
|
|
47
|
+
*
|
|
48
|
+
* Timestamps inside a signed value are integer Unix milliseconds, never ISO
|
|
49
|
+
* strings, for the same reason: `timestamptz` does not round-trip an ISO
|
|
50
|
+
* string byte for byte.
|
|
51
|
+
*/
|
|
52
|
+
export const SIGNED_PREFIX = 'wtfalch-keys:';
|
|
53
|
+
/**
|
|
54
|
+
* `encodeAad(binding)` is the UTF-8 bytes of
|
|
55
|
+
*
|
|
56
|
+
* `wtfalch-keys:aad:v1\n` + JSON.stringify([tenantId, entryId, version])
|
|
57
|
+
*
|
|
58
|
+
* Ids match `ID_PATTERN` and `version` is an integer, so `JSON.stringify`
|
|
59
|
+
* is already canonical here. The wrapped data key (checked by the Worker)
|
|
60
|
+
* and the value's own ciphertext (checked by the host) use the same AAD, so
|
|
61
|
+
* a row copied to another tenant or relabelled to another version fails the
|
|
62
|
+
* GCM tag on both layers.
|
|
63
|
+
*
|
|
64
|
+
* This is a new format. It is not app-template's tier-1 `keystore.v1|...`
|
|
65
|
+
* string, whose tables this package does not read.
|
|
66
|
+
*/
|
|
67
|
+
export const AAD_PREFIX = 'wtfalch-keys:aad:v1\n';
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// HTTP mapping
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/**
|
|
72
|
+
* One endpoint per call. Bodies are JSON; every `Uint8Array` is base64url on
|
|
73
|
+
* the wire; `requestId` is in the body; the credential is the bearer token.
|
|
74
|
+
*
|
|
75
|
+
* 200 carries the `*Success` body without `ok`. Any other status carries
|
|
76
|
+
* `{ error: WorkerError }`:
|
|
77
|
+
* - 400 `bad_request`.
|
|
78
|
+
* - 401 is about the caller: `credential_invalid`, `credential_expired`,
|
|
79
|
+
* `credential_revoked`.
|
|
80
|
+
* - 403 is about this request: `grant_missing`, `aad_mismatch`,
|
|
81
|
+
* `unknown_generation` (also `resign`), `bad_minter_signature`, `minter_expired`,
|
|
82
|
+
* `minter_revoked`, `lineage_mismatch`, `bad_old_signature`,
|
|
83
|
+
* `unlogged_row`, `not_in_lineage`.
|
|
84
|
+
* - 429 `rate_limited`, with `Retry-After` in seconds.
|
|
85
|
+
* Any other status, or none, is reported by the client as `unreachable`.
|
|
86
|
+
*/
|
|
87
|
+
export const WORKER_HTTP = {
|
|
88
|
+
wrap: { method: 'POST', path: '/v1/held/wrap', grant: 'wrap' },
|
|
89
|
+
unwrap: { method: 'POST', path: '/v1/held/unwrap', grant: 'unwrap' },
|
|
90
|
+
rewrap: { method: 'POST', path: '/v1/held/rewrap', grant: 'rewrap' },
|
|
91
|
+
signRow: { method: 'POST', path: '/v1/issued/sign', grant: 'sign' },
|
|
92
|
+
resign: { method: 'POST', path: '/v1/issued/resign', grant: 'resign' },
|
|
93
|
+
recordEviction: { method: 'POST', path: '/v1/issued/evictions', grant: 'evict' },
|
|
94
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wtfalch/keys",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/wtfalch/keys",
|
|
8
|
+
"directory": "packages/keys"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"files": ["dist"],
|
|
13
|
+
"bin": {
|
|
14
|
+
"keys-migrations": "dist/bin/migrations.js"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
"./issued": {
|
|
18
|
+
"types": "./dist/issued/index.d.ts",
|
|
19
|
+
"default": "./dist/issued/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./held": {
|
|
22
|
+
"types": "./dist/held/index.d.ts",
|
|
23
|
+
"default": "./dist/held/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./migrations/*.sql": "./dist/migrations/*.sql",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.0.0"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && mkdir -p dist/migrations && find src/migrations -maxdepth 1 -name '*.sql' -exec cp {} dist/migrations/ \\;",
|
|
37
|
+
"prepack": "pnpm build",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "vitest run"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"drizzle-orm": ">=0.39.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@electric-sql/pglite": "^0.5.8",
|
|
46
|
+
"@types/node": "^22",
|
|
47
|
+
"drizzle-orm": "^0.39.3",
|
|
48
|
+
"postgres": "^3.4.5",
|
|
49
|
+
"typescript": "^5.9.0",
|
|
50
|
+
"vitest": "^4.1.6"
|
|
51
|
+
}
|
|
52
|
+
}
|