@coffre/vault 0.14.5 → 0.15.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.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
@@ -0,0 +1,630 @@
1
+ import { randomUUID } from 'node:crypto';
2
+
3
+ import {
4
+ DEFAULT_POLICY,
5
+ definitionOf,
6
+ delegable,
7
+ HANDLE_POLICY,
8
+ heldColumns,
9
+ issuesCredential,
10
+ MAX_PERSONAL_TOKEN_MINUTES,
11
+ MAX_SERVICE_TOKEN_DAYS,
12
+ ownsProject,
13
+ readScope,
14
+ scopeDefinition,
15
+ sealedSessionScope,
16
+ type CredentialScope,
17
+ type Holdings,
18
+ type Place,
19
+ type Policy,
20
+ type ScopeGrant,
21
+ type ServiceAccount,
22
+ type ServiceSetting,
23
+ } from '@coffre/core/access';
24
+ import { EXCHANGED_CREDENTIAL_MS, POLICY_DEFAULTS, POLICY_FIELDS, SESSION_MS, type BindingPolicy, type WorkloadProfile } from '@coffre/core/identity';
25
+ import type { BindingColumns, ChangeBindingsInput, CredentialColumns, IssueTokenInput, MadeBinding, IssueTokenRefusal, RequestTrace, RevokeCredentialInput, SignInCredential, SignInInput, SignInRefusal } from '@coffre/core/vault';
26
+ import type { Database } from '@coffre/db';
27
+ import { engineOf } from '@coffre/db/dialect';
28
+
29
+ import { NotLogged, payloadOf, type ChangeKeys } from './changes.ts';
30
+ import { applyOp, canonical, objectKey, sealed, sealOf, type Op, type Ref } from './inputs.ts';
31
+ import { bindingsOnPostgres, issueOnPostgres, revokeOnPostgres, signInOnPostgres } from './credentials.postgres.ts';
32
+ import { bindingsOnSqlite, issueOnSqlite, revokeOnSqlite, signInOnSqlite } from './credentials.sqlite.ts';
33
+ import { entryMac } from './log-entry.ts';
34
+ import { bindingObject, credentialObject } from './objects.ts';
35
+
36
+ /**
37
+ * The vault makes and ends credentials itself, each in one statement
38
+ * (T149, D146; docs/design/credential-lifecycle.md; formal/Lifecycle.tla):
39
+ * a sign-in through a trust binding (`sign_in`, migration 0034) and a
40
+ * revoke (`revoke_credential`). It decides here, on the columns the app
41
+ * names, and seals what it decides; the statement goes ahead only while
42
+ * those columns are the rows', under their locks, and numbers the change.
43
+ * Then the caller waits for the seal (R51).
44
+ *
45
+ * The app still verifies the proof (the ID token's signature and claims,
46
+ * against the binding it read): the vault's own check of it is D148's,
47
+ * next (Lifecycle.tla's VaultVerifiesToken, BindingSealed).
48
+ */
49
+
50
+ /** How many credentials a binding makes a window, and the window: `EXCHANGES_PER_BINDING_MINUTE`, as 0.14 counts them. */
51
+ export const SIGN_INS_PER_WINDOW = 60;
52
+ export const SIGN_IN_WINDOW_MS = 60_000;
53
+
54
+
55
+ /** The longest a session's hard end may be, past its sign-in: the most `sessionHours` takes (workloads.ts's policy check). */
56
+ const LONGEST_SESSION_HOURS = 168;
57
+
58
+ /** What the change of a sign-in records besides its op (D147): its binding, proof, issuer, the run's claims, and a session's key and hard end. */
59
+ type SignInRecord = { binding: string; proof: string; issuer: string; run: Record<string, string | number>; jkt?: string; notAfter?: number };
60
+
61
+ /** A sign-in as the vault decided it: the statement's arguments. */
62
+ export type SignInCall = {
63
+ requestId: string; at: number; actor: string; payload: string; mac: Buffer; seal: Buffer;
64
+ /** The binding's columns as the vault checked its seal (`bindingSeal`): the statement holds the row to both. */
65
+ binding: BindingColumns; bindingSeal: Buffer; principal: string; appKey: string; spent: Buffer | null; validUntil: number | null; frozen: boolean; longest: number;
66
+ /** At most `rate` credentials a binding makes in `window` ms. */
67
+ rate: number; window: number;
68
+ credential: SignInCredential & { createdBy: string; tokenHashBytes: Buffer; authMacBytes: Buffer };
69
+ };
70
+
71
+ /** The statement's answer: its change's number, or why it made nothing. */
72
+ export type Answer = { number?: string | null; state?: SignInRefusal | IssueTokenRefusal | 'moved' | 'stale' | 'policy' | 'unbound' | 'full' };
73
+
74
+ /** A lock not had within the functions' `lock_timeout`, or a statement cancelled: overloaded, refused at once (R44). */
75
+ const BUSY_CODES = new Set(['55P03', '57014']);
76
+
77
+ function isBusy(error: unknown): boolean {
78
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
79
+ if (BUSY_CODES.has(String((cause as { code?: unknown }).code))) return true;
80
+ }
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * A sign-in, decided and made: the credential's object from the binding's
86
+ * columns as named (its scope, a workload's reads with it), its seal, its
87
+ * change, then one statement. Throws on an input no app of this release
88
+ * sends.
89
+ */
90
+ export async function signIn(db: Database, keys: ChangeKeys, at: number, input: SignInInput): Promise<Answer> {
91
+ let call: SignInCall;
92
+ try {
93
+ call = decideSignIn(keys, at, input);
94
+ } catch (error) {
95
+ if (error instanceof SignInRefused) return { state: error.state };
96
+ throw error;
97
+ }
98
+ if (engineOf(db) === 'sqlite') return db.transaction((tx) => signInOnSqlite(tx, call));
99
+ try {
100
+ return await signInOnPostgres(db, call);
101
+ } catch (error) {
102
+ if (isBusy(error)) return { state: 'busy' };
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ function decideSignIn(keys: ChangeKeys, at: number, input: SignInInput): SignInCall {
108
+ const { principal, credential, appKey, spent, validUntil, record } = input ?? ({} as SignInInput);
109
+ if (typeof principal !== 'string' || !/^token:[a-z0-9][a-z0-9._-]{0,99}$/.test(principal)) throw new Error('a sign-in is a service account\'s, token:<id>');
110
+ const binding = checkBinding(input?.binding?.columns);
111
+ if (input.binding.seal !== null && !isBase64(input.binding.seal, 16)) throw new Error('a binding\'s seal is 16 bytes, in base64, or none');
112
+ // Only a binding the vault sealed, live, of this account (BindingSealed): one 0.14 made, or a row not its object, signs nobody in.
113
+ const bindingSeal = input.binding.seal === null ? null : Buffer.from(input.binding.seal, 'base64');
114
+ if (bindingSeal === null || binding.revoked || binding.principal !== principal || !sealed(keys.seals, { kind: 'binding', id: binding.id }, bindingObject(binding), bindingSeal)) {
115
+ throw new SignInRefused('unsealed');
116
+ }
117
+ checkCredential(credential);
118
+ if (typeof appKey !== 'string' || !/^app:[A-Za-z0-9_-]{1,64}$/.test(appKey)) throw new Error('a sign-in names the app\'s log key it signed the row under');
119
+ if (spent !== null && !isBase64(spent, 32)) throw new Error('a sign-in spends its token by its signing input\'s hash, 32 bytes, or nothing');
120
+ if (validUntil !== null && (typeof validUntil !== 'number' || !Number.isSafeInteger(validUntil))) throw new Error('a proof holds until a time, in ms, or its credential\'s end bounds it');
121
+ if (typeof record?.proof !== 'string' || typeof record.issuer !== 'string' || !isRun(record.run)) throw new Error('a sign-in records its proof, issuer and run');
122
+ // What the credential reaches, holds and lives, by its kind: the binding's own scope, or a session's with its binding's reads.
123
+ let scope: string | null;
124
+ let longest: number;
125
+ if (credential.kind === 'service') {
126
+ if (binding.policy !== null || spent === null || credential.jkt !== null || credential.notAfter !== null || credential.places !== null) {
127
+ throw new Error('a CI run\'s credential comes of a binding with no policy, spends its token, and is bound to no key');
128
+ }
129
+ scope = binding.scope;
130
+ longest = EXCHANGED_CREDENTIAL_MS;
131
+ } else {
132
+ if (binding.policy === null || credential.jkt === null || credential.notAfter === null) throw new Error('a workload\'s session comes of a binding with a policy, bound to a key, with a hard end');
133
+ const policy = JSON.parse(binding.policy) as BindingPolicy;
134
+ scope = sealedSessionScope(null, binding, policy);
135
+ longest = SESSION_MS;
136
+ const fields: readonly string[] = POLICY_FIELDS[binding.profile as WorkloadProfile] ?? [];
137
+ const hours = fields.includes('sessionHours') ? policy.sessionHours ?? POLICY_DEFAULTS.sessionHours : LONGEST_SESSION_HOURS;
138
+ if (credential.notAfter > at + hours * 3_600_000 + 60_000) throw new SignInRefused('expired');
139
+ }
140
+ if (credential.scope !== scope) throw new Error('a credential a binding makes reaches what its binding does: its scope, as the vault makes it from the binding');
141
+ const id = credential.id;
142
+ const ref: Ref = { kind: 'credential', id };
143
+ const columns: CredentialColumns = {
144
+ id, principal, kind: credential.kind, scope, revoked: false, generation: binding.generation,
145
+ expiresAt: Math.min(credential.expiresAt, credential.notAfter ?? Infinity),
146
+ };
147
+ const object = credentialObject(columns);
148
+ const op: Op = { op: 'credential.issue', principal, caller: object.caller, scope: object.scope, expiresAt: object.expiresAt, generation: object.generation };
149
+ // A workload's session's key and hard end too: what its proofs are signed by, and when it ends whatever renews it.
150
+ const signedIn: SignInRecord = {
151
+ binding: binding.id, proof: record.proof, issuer: record.issuer, run: record.run,
152
+ ...(credential.jkt === null ? {} : { jkt: credential.jkt }), ...(credential.notAfter === null ? {} : { notAfter: credential.notAfter }),
153
+ };
154
+ const payload = canonical({ ...JSON.parse(payloadOf(ref, op)), signIn: signedIn });
155
+ const seal = sealOf(keys.seals[0]!, ref, object);
156
+ const row = { at, requestId: randomUUID(), object: objectKey(ref), actor: principal, payload, seal };
157
+ return {
158
+ ...row, mac: entryMac(keys.mac, { tree: 'changes', row }),
159
+ binding, bindingSeal, principal, appKey, spent: spent === null ? null : Buffer.from(spent, 'base64'), validUntil, frozen: credential.kind === 'workload', longest, rate: SIGN_INS_PER_WINDOW, window: SIGN_IN_WINDOW_MS,
160
+ credential: { ...credential, createdBy: `binding:${binding.id}`, tokenHashBytes: Buffer.from(credential.tokenHash, 'base64'), authMacBytes: Buffer.from(credential.authMac, 'base64') },
161
+ };
162
+ }
163
+
164
+ /** A binding's columns as the app names them; throws on a shape no app of this release sends. */
165
+ export function checkBinding(columns: BindingColumns | undefined): BindingColumns {
166
+ const c = columns ?? ({} as BindingColumns);
167
+ const text = (value: unknown) => typeof value === 'string';
168
+ if (!text(c.id) || !UUID.test(c.id) || !text(c.principal) || !Number.isInteger(c.generation) || !text(c.profile) || !text(c.issuer) || !text(c.origin) || !webUrl(c.origin) || !text(c.jwksUri)
169
+ || !text(c.claims) || (c.scope !== null && !text(c.scope)) || (c.policy !== null && !text(c.policy)) || typeof c.revoked !== 'boolean') {
170
+ throw new Error('a binding is named by its columns as its row keeps them: id, account, generation, profile, issuer, origin (an http(s) URL), keys, claims, scope, policy, revoked');
171
+ }
172
+ return c;
173
+ }
174
+
175
+ /** An instance's public URL, http or https, as a binding's `origin` must be: checked where it enters, so a sign-in never parses a bad one (Fable on PR 3b). */
176
+ function webUrl(value: string): boolean {
177
+ try {
178
+ const { protocol } = new URL(value);
179
+ return protocol === 'https:' || protocol === 'http:';
180
+ } catch {
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /** A sign-in the vault refuses before any statement: its own check of the times. */
186
+ class SignInRefused extends Error {
187
+ readonly state: SignInRefusal;
188
+ constructor(state: SignInRefusal) {
189
+ super(`sign-in refused: ${state}`);
190
+ this.state = state;
191
+ }
192
+ }
193
+
194
+ function checkCredential(c: SignInCredential): void {
195
+ const text = (value: unknown, max: number) => typeof value === 'string' && value.length > 0 && value.length <= max;
196
+ const optional = (value: unknown, max: number) => value === null || text(value, max);
197
+ const time = (value: unknown) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
198
+ if (typeof c?.id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(c.id)
199
+ || (c.kind !== 'service' && c.kind !== 'workload') || !isBase64(c.tokenHash, 32) || !isBase64(c.authMac, 32) || !text(c.tokenHint, 64)
200
+ || !optional(c.label, 120) || (c.scope !== null && typeof c.scope !== 'string') || (c.places !== null && typeof c.places !== 'string')
201
+ || !time(c.expiresAt) || (c.notAfter !== null && !time(c.notAfter)) || !optional(c.jkt, 128)) {
202
+ throw new Error('a sign-in\'s credential is its row: an id, its kind, its token\'s hash and hint, its times, and the app\'s MAC');
203
+ }
204
+ }
205
+
206
+ function isBase64(value: unknown, bytes: number): value is string {
207
+ return typeof value === 'string' && /^[A-Za-z0-9+/]+={0,2}$/.test(value) && Buffer.from(value, 'base64').length === bytes;
208
+ }
209
+
210
+ /** A run's claims as the change records them: short strings and numbers, at most as many as `runClaims` keeps (23 today). */
211
+ function isRun(run: unknown): run is Record<string, string | number> {
212
+ if (typeof run !== 'object' || run === null || Array.isArray(run)) return false;
213
+ const entries = Object.entries(run);
214
+ return entries.length <= 32 && entries.every(([key, value]) => key.length <= 64 && ((typeof value === 'string' && value.length <= 256) || (typeof value === 'number' && Number.isFinite(value))));
215
+ }
216
+
217
+ /**
218
+ * A revoke, decided and made: the object as the app read it, checked
219
+ * against its seal (`NotLogged`, tampered, if the vault never made it), the
220
+ * revoke applied and sealed, then one statement. A row 0.14 made and the
221
+ * log never held is revoked with no change, as `prepare` leaves one.
222
+ */
223
+ export async function revokeCredential(db: Database, keys: ChangeKeys, at: number, input: RevokeCredentialInput): Promise<Answer> {
224
+ const { actor, before, authMacFrom, authMac, revokedAt, revokedBy, reason, via, trace, policy } = input ?? ({} as RevokeCredentialInput);
225
+ const columns = before?.columns;
226
+ if (typeof actor !== 'string' || actor.length === 0 || typeof columns?.id !== 'string' || typeof columns.principal !== 'string' || columns.revoked !== false
227
+ || !isBase64(before.seal, 16) || !isBase64(authMacFrom, 32) || !isBase64(authMac, 32)
228
+ || typeof revokedAt !== 'number' || !Number.isSafeInteger(revokedAt) || typeof revokedBy !== 'string' || (reason !== 'revoke' && reason !== 'sign_out')
229
+ || (via !== null && typeof via !== 'string') || !isTrace(trace) || !isPolicy(policy)) {
230
+ throw new Error('a revoke names the credential as read, live, its seal, the app\'s MACs before and after, and the policy it was weighed on');
231
+ }
232
+ const ref: Ref = { kind: 'credential', id: columns.id };
233
+ const now = credentialObject(columns);
234
+ const sealFrom = Buffer.from(before.seal, 'base64');
235
+ if (!sealed(keys.seals, ref, now, sealFrom)) throw new NotLogged(ref, 'tampered');
236
+ const after = applyOp(now, ref, { op: 'credential.revoke' })!;
237
+ const seal = sealOf(keys.seals[0]!, ref, after);
238
+ // What a revoke's record says besides (D147): why, the credential the request came in on, whose act it was (T164), and
239
+ // whose credential of what kind it ends.
240
+ const payload = canonical({ ...JSON.parse(payloadOf(ref, { op: 'credential.revoke' })), revoke: { reason, via, trace: trace ?? undefined, kind: columns.kind, principal: columns.principal } });
241
+ const row = { at, requestId: randomUUID(), object: objectKey(ref), actor, payload, seal };
242
+ const call: RevokeCall = {
243
+ ref, macFrom: Buffer.from(authMacFrom, 'base64'), mac: Buffer.from(authMac, 'base64'), revokedAt, revokedBy, policy,
244
+ change: { ...row, sealFrom, mac: entryMac(keys.mac, { tree: 'changes', row }) },
245
+ };
246
+ if (engineOf(db) === 'sqlite') return db.transaction((tx) => revokeOnSqlite(tx, call));
247
+ try {
248
+ return await revokeOnPostgres(db, call);
249
+ } catch (error) {
250
+ if (isBusy(error)) return { state: 'busy' };
251
+ throw error;
252
+ }
253
+ }
254
+
255
+ /** A policy a revoke was weighed on, as the app names it: the newest change's sequence number, the protected environments' slugs. */
256
+ function isPolicy(policy: unknown): policy is RevokeCredentialInput['policy'] {
257
+ if (policy === null) return true;
258
+ const { seq, protected: guarded } = (policy ?? {}) as { seq?: unknown; protected?: unknown };
259
+ return (seq === null || (typeof seq === 'string' && /^-?[0-9]{1,19}$/.test(seq)))
260
+ && Array.isArray(guarded) && guarded.every((place) => typeof place?.environmentId === 'string' && (place.slug === null || typeof place.slug === 'string'));
261
+ }
262
+
263
+ /** A revoke as the vault decided it: the statement's arguments. */
264
+ export type RevokeCall = {
265
+ ref: Ref; macFrom: Buffer; mac: Buffer; revokedAt: number; revokedBy: string; policy: RevokeCredentialInput['policy'];
266
+ change: { at: number; actor: string; requestId: string; object: string; payload: string; seal: Buffer; sealFrom: Buffer; mac: Buffer };
267
+ };
268
+
269
+ // --- a token's issue (PR 2) -----------------------------------------------------
270
+
271
+ /** What an issue was weighed on, read by the vault before its statement: the rows the statement then holds to. */
272
+ export type IssueRead = {
273
+ at: number;
274
+ actor: { generation: number; accessSeq: bigint };
275
+ principal: { generation: number; accessSeq: bigint };
276
+ /** The credential the request came in on, as read, and its seal; null for none. */
277
+ via: { columns: CredentialColumns; seal: Buffer | null } | null;
278
+ /**
279
+ * The environments whose slugs it was weighed on, which the statement holds under the issue lock: the policy's
280
+ * protected ones, as the app named them, and the scope's, as the vault read them (a scoped role matches by slug).
281
+ */
282
+ slugs: { environmentId: string; slug: string | null }[];
283
+ };
284
+
285
+ /** An issue as the vault decided it: the statement's arguments. */
286
+ export type IssueCall = {
287
+ change: { at: number; actor: string; requestId: string; object: string; payload: string; seal: Buffer; mac: Buffer };
288
+ input: IssueTokenInput;
289
+ read: IssueRead;
290
+ tokenHash: Buffer;
291
+ authMac: Buffer;
292
+ };
293
+
294
+ /** The shape of an issue as the app sends it; throws on one no app of this release sends. */
295
+ export function checkIssue(input: IssueTokenInput): CredentialScope {
296
+ const { actor, via, principal, generation, credential: c, policy, appKey } = input ?? ({} as IssueTokenInput);
297
+ const member = /^(user:[^\s]{1,320}|token:[a-z0-9][a-z0-9._-]{0,99})$/;
298
+ if (typeof actor !== 'string' || !member.test(actor) || typeof principal !== 'string' || !member.test(principal) || !Number.isInteger(generation)
299
+ || (via !== null && (typeof via !== 'string' || !UUID.test(via))) || !isPolicy(policy)
300
+ || (input.actorAccess !== null && (typeof input.actorAccess !== 'string' || !/^-?[0-9]{1,19}$/.test(input.actorAccess))) || typeof appKey !== 'string' || !/^app:[A-Za-z0-9_-]{1,64}$/.test(appKey)
301
+ || !isTrace(input.trace)) {
302
+ throw new Error('an issue names its issuer, the credential it came in on, whom it acts as at which generation, the policy weighed, and the app\'s key');
303
+ }
304
+ if (typeof c?.id !== 'string' || !UUID.test(c.id) || (c.kind !== 'service' && c.kind !== 'personal' && c.kind !== 'handle') || !isBase64(c.tokenHash, 32)
305
+ || !isBase64(c.authMac, 32) || typeof c.tokenHint !== 'string' || c.tokenHint.length === 0 || c.tokenHint.length > 64
306
+ || (c.label !== null && (typeof c.label !== 'string' || c.label.length > 120)) || typeof c.scope !== 'string' || (c.places !== null && typeof c.places !== 'string')
307
+ || typeof c.expiresAt !== 'number' || !Number.isSafeInteger(c.expiresAt) || typeof c.createdBy !== 'string') {
308
+ throw new Error('an issue\'s token is its row: an id, its kind, its token\'s hash and hint, its scope as stored, its end, its maker, and the app\'s MAC');
309
+ }
310
+ if ((c.kind === 'handle') !== (c.places !== null)) throw new Error('a handle, and only a handle, names the places it stands in for');
311
+ const scope = readScope(c.scope);
312
+ if (scope === null || scope.length === 0) throw new Error('an issued token reaches what its stored scope names, at least one place');
313
+ return scope;
314
+ }
315
+
316
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
317
+
318
+ /** A request's trace as the app names it (`RequestTrace`), or none: recorded, never weighed, so held to its shape alone. */
319
+ function isTrace(trace: unknown): trace is RequestTrace | null | undefined {
320
+ if (trace === null || trace === undefined) return true;
321
+ const { credentialId, mcp } = trace as { credentialId?: unknown; mcp?: unknown };
322
+ const text = (value: unknown, longest: number) => typeof value === 'string' && value.length > 0 && value.length <= longest;
323
+ if (!text(credentialId, 100)) return false;
324
+ if (mcp === null) return true;
325
+ const { connectionId, clientId, clientName, approvalId } = (mcp ?? {}) as Record<string, unknown>;
326
+ return text(connectionId, 100) && text(clientId, 2048) && text(clientName, 1000) && (approvalId === null || text(approvalId, 100));
327
+ }
328
+
329
+ /**
330
+ * How far past its longest a token's end may be: the app sets it by its own
331
+ * clock, the vault weighs it by the database's, and a host a minute or two
332
+ * ahead should not have every longest token refused (Fable on PR 2).
333
+ */
334
+ const CLOCK_SLACK_MS = 5 * 60_000;
335
+
336
+ /**
337
+ * Whether the issuer, as the vault read them (`holder`, its credential's
338
+ * scope and kind through it), may give `principal` a token of this kind,
339
+ * scope and end: null if so, or why not. The app's own checks, made
340
+ * again on the vault's reads, with the same rules (`@coffre/core/access`):
341
+ * - a service account's (R15, R32): its issuer, a person, manages it, and could give
342
+ * it each grant, which the account holds (`issuesCredential`); sensitive
343
+ * reads only where the issuer is an Owner of the project (`ownsProject`);
344
+ * each grant's columns within its role; at most `MAX_SERVICE_TOKEN_DAYS`;
345
+ * - a personal token: its person's own, made on a session, each role one
346
+ * that manages no access (`delegable`), no column past what that role
347
+ * holds as the policy defines it nor what they hold there
348
+ * (`issuedGrant`'s bound); at most `MAX_PERSONAL_TOKEN_MINUTES`;
349
+ * - a handle: its person's own, each place a `viewer` or a `contributor`,
350
+ * no column past reading and using (`HANDLE_POLICY`) nor what they hold
351
+ * there through the credential it came in on; never past that
352
+ * credential's end when it is a scoped one, a personal token's.
353
+ * A grant's columns are what a use decides on (`scopeDefinition`), its role
354
+ * only a name: they are held to the role, never the name alone.
355
+ */
356
+ export function weighIssue(
357
+ input: IssueTokenInput,
358
+ scope: CredentialScope,
359
+ holder: Holdings,
360
+ places: readonly (Place | null)[],
361
+ via: CredentialColumns | null,
362
+ at: number,
363
+ service: { setting: ServiceSetting | null; account: ServiceAccount } | null,
364
+ ): string | null {
365
+ const { actor, principal, credential } = input;
366
+ if (credential.createdBy !== actor.slice(actor.indexOf(':') + 1)) return 'a token names who made it, its issuer';
367
+ if (places.some((place) => place === null)) return 'a place the token names is no longer there';
368
+ const at_ = places as readonly Place[];
369
+ const policy = holder.policy ?? DEFAULT_POLICY;
370
+ // Each column within the role, and, for a person's own, within what the holder holds at the place, through the credential it came in on.
371
+ const held = (grant: ScopeGrant, place: Place, roles: Policy) => {
372
+ const columns = heldColumns(holder, place);
373
+ const { permissions, protected: guarded } = scopeDefinition(grant);
374
+ return ofRole(grant, roles) && permissions.every((p) => columns.normal.includes(p)) && guarded.every((p) => columns.protected.includes(p));
375
+ };
376
+ // Whether it came in on a scoped credential (a personal token's, a handle's, a connected app's) or a session: the
377
+ // credential's object seals its scope, never its row's kind, so the vault decides on that (R53).
378
+ const scoped = holder.credentialScope != null;
379
+ switch (credential.kind) {
380
+ case 'service':
381
+ if (credential.expiresAt > at + MAX_SERVICE_TOKEN_DAYS * 86_400_000 + CLOCK_SLACK_MS) return `a service token lasts at most ${MAX_SERVICE_TOKEN_DAYS} days`;
382
+ return weighService(actor, principal, scope, holder, at_, service);
383
+ case 'personal': {
384
+ if (principal !== actor || !actor.startsWith('user:')) return 'a personal token is its person\'s own';
385
+ if (scoped) return 'a personal token is made on a session: a token never makes another';
386
+ if (credential.expiresAt > at + MAX_PERSONAL_TOKEN_MINUTES * 60_000 + CLOCK_SLACK_MS) return 'a personal token lasts at most a week';
387
+ if (scope.some((grant) => !delegable(grant.role, policy))) return 'a personal token never manages access';
388
+ if (scope.some((grant, i) => !held(grant, at_[i]!, policy))) return 'a personal token reaches at most what its role and its person hold';
389
+ return null;
390
+ }
391
+ case 'handle': {
392
+ if (principal !== actor || !actor.startsWith('user:')) return 'a handle is its person\'s own';
393
+ // A scoped credential's end bounds it (the app makes one on a personal token alone); a session's does not: a
394
+ // handle a run holds outlives the sign-in that made it.
395
+ if (credential.expiresAt > at + MAX_PERSONAL_TOKEN_MINUTES * 60_000 + CLOCK_SLACK_MS || (scoped && via !== null && via.expiresAt !== null && credential.expiresAt > via.expiresAt)) {
396
+ return 'a handle lasts at most a week, and never past the token that made it';
397
+ }
398
+ if (scope.some((grant) => grant.role !== 'viewer' && grant.role !== 'contributor')) return 'a handle reads or uses, as a viewer or a contributor';
399
+ // Made for environments, each named: never a project's, which would reach the environments it gains later.
400
+ if (scope.some((grant) => grant.environmentId == null)) return 'a handle reaches environments, each by name';
401
+ const reached = new Set(scope.map((grant) => grant.environmentId));
402
+ const places = handlePlaces(credential.places);
403
+ if (places === null || places.some(({ environmentId }) => !reached.has(environmentId))) return 'a handle stands in for environments its scope reaches';
404
+ if (scope.some((grant, i) => !held(grant, at_[i]!, HANDLE_POLICY))) return 'a handle reads and uses at most what its person holds, through what it was made on';
405
+ return null;
406
+ }
407
+ }
408
+ }
409
+
410
+ /** Each column of `grant` within its role as `roles` defines it: what `issuedGrant` keeps of it at most. */
411
+ function ofRole(grant: ScopeGrant, roles: Policy): boolean {
412
+ const role = definitionOf(roles, grant.role);
413
+ const { permissions, protected: guarded } = scopeDefinition(grant);
414
+ return role !== undefined && permissions.every((p) => role.permissions.includes(p)) && guarded.every((p) => role.protected.includes(p));
415
+ }
416
+
417
+ /**
418
+ * Whether `actor`, as the vault read them (`holder`), may give service
419
+ * account `principal` a grant of `scope` (`places`, by its environments'
420
+ * slugs): a service token's issue, or a trust binding's, whose sign-ins
421
+ * reach the same (PR 3a). Null if so, or why not. A person gives it, as the
422
+ * app's `managedAccount` has it, who manages the account and could give it
423
+ * each grant, which the account holds (`issuesCredential`; R15); each
424
+ * grant's columns within its role; sensitive reads only where they are an
425
+ * Owner of the project (`ownsProject`, R32).
426
+ */
427
+ export function weighService(
428
+ actor: string,
429
+ principal: string,
430
+ scope: CredentialScope,
431
+ holder: Holdings,
432
+ places: readonly Place[],
433
+ service: { setting: ServiceSetting | null; account: ServiceAccount } | null,
434
+ ): string | null {
435
+ if (!principal.startsWith('token:') || service === null) return 'a service account\'s access is a service account\'s';
436
+ // No service account or workload sets one up.
437
+ if (!actor.startsWith('user:')) return 'only a person gives a service account its access';
438
+ const policy = holder.policy ?? DEFAULT_POLICY;
439
+ if (scope.some((grant) => !ofRole(grant, policy))) return 'a service account\'s grant keeps at most what each of its roles holds';
440
+ const grants = scope.map((grant, i) => ({ role: grant.role, place: places[i]!, kept: scopeDefinition(grant) }));
441
+ if (!issuesCredential(holder, service.setting, actor, service.account, grants)) return 'a credential reaches at most what its account holds and what its issuer could give it';
442
+ if (scope.some((grant) => grant.sensitive === true && !ownsProject(holder, grant.projectId!))) return 'only an Owner of its project gives a service account sensitive reads (R32)';
443
+ return null;
444
+ }
445
+
446
+ /** A handle's places as the app writes them (`HandlePlace[]`: each environment it was made for, by id, and whether as `@me`), or null for none such. */
447
+ function handlePlaces(stored: string | null): { environmentId: string; personal: boolean }[] | null {
448
+ let parsed: unknown = null;
449
+ try {
450
+ parsed = stored === null ? null : JSON.parse(stored);
451
+ } catch {}
452
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
453
+ const places = parsed as { environmentId?: unknown; personal?: unknown }[];
454
+ return places.every((place) => typeof place === 'object' && place !== null && typeof place.environmentId === 'string' && UUID.test(place.environmentId) && typeof place.personal === 'boolean')
455
+ ? (places as { environmentId: string; personal: boolean }[])
456
+ : null;
457
+ }
458
+
459
+ /** The issue, sealed: the credential's object, its seal, its change's payload and MAC. */
460
+ export function issueCall(keys: ChangeKeys, input: IssueTokenInput, read: IssueRead): IssueCall {
461
+ const { credential: c } = input;
462
+ const ref: Ref = { kind: 'credential', id: c.id };
463
+ const object = credentialObject({ id: c.id, principal: input.principal, kind: c.kind, scope: c.scope, revoked: false, expiresAt: c.expiresAt, generation: input.generation });
464
+ const op: Op = { op: 'credential.issue', principal: input.principal, caller: object.caller, scope: object.scope, expiresAt: object.expiresAt, generation: object.generation };
465
+ // What the change records of the issue besides (D147): who, on which credential, whose act it was (T164), which kind,
466
+ // its label.
467
+ const payload = canonical({ ...JSON.parse(payloadOf(ref, op)), issue: { by: input.actor, via: input.via, trace: input.trace ?? undefined, kind: c.kind, label: c.label } });
468
+ const seal = sealOf(keys.seals[0]!, ref, object);
469
+ const row = { at: read.at, requestId: randomUUID(), object: objectKey(ref), actor: input.actor, payload, seal };
470
+ return {
471
+ change: { ...row, mac: entryMac(keys.mac, { tree: 'changes', row }) }, input, read,
472
+ tokenHash: Buffer.from(c.tokenHash, 'base64'), authMac: Buffer.from(c.authMac, 'base64'),
473
+ };
474
+ }
475
+
476
+ /** The issue's statement, on the engine `db` is. */
477
+ export async function issueToken(db: Database, call: IssueCall): Promise<Answer> {
478
+ if (engineOf(db) === 'sqlite') return db.transaction((tx) => issueOnSqlite(tx, call));
479
+ try {
480
+ return await issueOnPostgres(db, call);
481
+ } catch (error) {
482
+ if (isBusy(error)) return { state: 'busy' };
483
+ throw error;
484
+ }
485
+ }
486
+
487
+ // --- a service's bindings (PR 3a) -----------------------------------------------
488
+
489
+ /** A change of bindings as the vault decided it: the statement's arguments, and each object's change in order. */
490
+ export type BindingsCall = {
491
+ at: number;
492
+ actor: string;
493
+ principal: string;
494
+ /** The binding made, its seal; and its maker's standing as read (`IssueRead`): null for an unbind. */
495
+ make: { row: MadeBinding; authMac: Buffer; seal: Buffer; read: IssueRead; via: string | null; connection: ChangeBindingsInput['connection'] } | null;
496
+ /** A binding with no seal (`sealFrom` null) is ended with no change, its `seal` null. */
497
+ ends: { id: string; macFrom: Buffer; sealFrom: Buffer | null; authMac: Buffer; seal: Buffer | null }[];
498
+ /** A credential with no seal (`sealFrom` null) is ended with no change, its `seal` null. */
499
+ credentials: { id: string; binding: string; macFrom: Buffer; sealFrom: Buffer | null; authMac: Buffer; seal: Buffer | null }[];
500
+ live: string[] | null;
501
+ reads: { id: string; key: string }[];
502
+ revokedAt: number;
503
+ revokedBy: string;
504
+ policy: { seq: string | null; slugs: { environmentId: string; slug: string | null }[] };
505
+ appKey: string;
506
+ changes: { requestId: string; object: string; payload: string; seal: Buffer; mac: Buffer }[];
507
+ };
508
+
509
+ /** The shape of a change of bindings as the app sends it; throws on one no app of this release sends. */
510
+ export function checkChangeBindings(input: ChangeBindingsInput): void {
511
+ const { actor, via, actorAccess, make, live, reads, ends, reason, revokedAt, revokedBy, policy, appKey } = input ?? ({} as ChangeBindingsInput);
512
+ const member = /^(user:[^\s]{1,320}|token:[a-z0-9][a-z0-9._-]{0,99})$/;
513
+ const named = (before: { columns?: unknown; seal?: unknown } | undefined) => before !== undefined && isBase64(before.seal, 16);
514
+ if (typeof actor !== 'string' || !member.test(actor) || (via !== null && (typeof via !== 'string' || !UUID.test(via)))
515
+ || (actorAccess !== null && (typeof actorAccess !== 'string' || !/^-?[0-9]{1,19}$/.test(actorAccess)))
516
+ || !Array.isArray(ends) || (make === null && ends.length === 0) || !['replaced', 'keys_moved', 'unbound'].includes(reason)
517
+ || typeof revokedAt !== 'number' || !Number.isSafeInteger(revokedAt) || typeof revokedBy !== 'string' || !isPolicy(policy) || policy === null
518
+ || typeof appKey !== 'string' || !/^app:[A-Za-z0-9_-]{1,64}$/.test(appKey)
519
+ || (make === null) !== (live === null) || (live !== null && (!Array.isArray(live) || !live.every((id) => typeof id === 'string' && UUID.test(id))))
520
+ || !Array.isArray(reads) || !reads.every((read) => typeof read?.id === 'string' && UUID.test(read.id) && typeof read.key === 'string')
521
+ || (input.connection !== null && (typeof input.connection?.id !== 'string' || !UUID.test(input.connection.id) || !Number.isInteger(input.connection.generation)))) {
522
+ throw new Error('a change of bindings names who makes it, on what, the binding it makes and those beside it, those it ends, why, when, the policy weighed, and the app\'s key');
523
+ }
524
+ // Each object once: a binding or a credential named twice would be two changes of it, the second one the replay refuses.
525
+ const ids = [...(make === null ? [] : [make.id]), ...ends.map((end) => end?.before?.columns?.id), ...ends.flatMap((end) => (end?.credentials ?? []).map((credential) => credential?.before?.columns?.id))];
526
+ if (new Set(ids).size !== ids.length) throw new Error('a change of bindings names each binding and credential once');
527
+ if (make !== null) {
528
+ checkBinding(make);
529
+ if (make.revoked || (make.jwks !== null && typeof make.jwks !== 'string') || (make.label !== null && typeof make.label !== 'string') || typeof make.createdBy !== 'string'
530
+ || !isBase64(make.authMac, 32) || make.scope === null || !Number.isSafeInteger(make.createdAt)) {
531
+ throw new Error('a binding made is live, has a scope, names its keys, label and maker, and carries the app\'s MAC');
532
+ }
533
+ }
534
+ for (const end of ends) {
535
+ if (end?.before === undefined || (end.before.seal !== null && !isBase64(end.before.seal, 16)) || !isBase64(end.authMacFrom, 32) || !isBase64(end.authMac, 32) || !Array.isArray(end.credentials)) {
536
+ throw new Error('a binding ended is named as read, with its seal, the app\'s MACs before and after, and the credentials it issued');
537
+ }
538
+ checkBinding(end.before.columns);
539
+ for (const credential of end.credentials) {
540
+ const before = credential?.before;
541
+ if (before === undefined || (before.seal !== null && !isBase64(before.seal, 16)) || typeof before.columns?.id !== 'string' || !isBase64(credential.authMacFrom, 32) || !isBase64(credential.authMac, 32)) {
542
+ throw new Error('a credential a binding ended issued is named as read, with its seal and the app\'s MACs before and after');
543
+ }
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * The change, sealed: the binding made (`binding.make`), each ended
550
+ * (`binding.revoke`), and each credential they issued (`credential.revoke`),
551
+ * each object's seal over what it becomes and its change's MAC. A binding
552
+ * or credential with no seal (0.14's, which the log never held) is ended
553
+ * with no change, which the statement holds to its having none; one whose
554
+ * seal is not the vault's, or not over its columns, is `tampered`, and so
555
+ * is a credential of another binding, or account.
556
+ */
557
+ export function bindingsCall(
558
+ keys: ChangeKeys, input: ChangeBindingsInput, at: number, read: IssueRead | null,
559
+ ): BindingsCall | { refused: 'tampered' } {
560
+ const { actor, make, ends, reason } = input;
561
+ const principal = make?.principal ?? ends[0]!.before.columns.principal;
562
+ const changes: BindingsCall['changes'] = [];
563
+ const change = (ref: Ref, payload: string, seal: Buffer) => {
564
+ const row = { at, requestId: randomUUID(), object: objectKey(ref), actor, payload, seal };
565
+ changes.push({ requestId: row.requestId, object: row.object, payload, seal, mac: entryMac(keys.mac, { tree: 'changes', row }) });
566
+ };
567
+ let made: BindingsCall['make'] = null;
568
+ if (make !== null) {
569
+ const ref: Ref = { kind: 'binding', id: make.id };
570
+ const value = bindingObject(make);
571
+ const seal = sealOf(keys.seals[0]!, ref, value);
572
+ // What the change records besides (D147): who made it, on which credential, its label, and those it replaces.
573
+ change(ref, canonical({ ...JSON.parse(payloadOf(ref, { op: 'binding.make', value })), bind: { by: actor, via: input.via, label: make.label, replaces: ends.map((end) => end.before.columns.id) } }), seal);
574
+ made = { row: make, authMac: Buffer.from(make.authMac, 'base64'), seal, read: read!, via: input.via, connection: input.connection };
575
+ }
576
+ const ended: BindingsCall['ends'] = [];
577
+ const swept: BindingsCall['credentials'] = [];
578
+ const sweeps: (() => void)[] = [];
579
+ for (const end of ends) {
580
+ const { columns } = end.before;
581
+ const ref: Ref = { kind: 'binding', id: columns.id };
582
+ if (columns.principal !== principal || columns.revoked) return { refused: 'tampered' };
583
+ if (end.before.seal === null) {
584
+ // One 0.14 made, which the log never held: its row ended, with no change; the statement holds it to having none (review r2).
585
+ ended.push({ id: columns.id, macFrom: Buffer.from(end.authMacFrom, 'base64'), sealFrom: null, authMac: Buffer.from(end.authMac, 'base64'), seal: null });
586
+ } else {
587
+ const sealFrom = Buffer.from(end.before.seal, 'base64');
588
+ const before = bindingObject(columns);
589
+ if (!sealed(keys.seals, ref, before, sealFrom)) return { refused: 'tampered' };
590
+ const seal = sealOf(keys.seals[0]!, ref, applyOp(before, ref, { op: 'binding.revoke' })!);
591
+ change(ref, canonical({ ...JSON.parse(payloadOf(ref, { op: 'binding.revoke' })), unbind: { by: actor, reason, replacedBy: make?.id ?? null } }), seal);
592
+ ended.push({ id: columns.id, macFrom: Buffer.from(end.authMacFrom, 'base64'), sealFrom, authMac: Buffer.from(end.authMac, 'base64'), seal });
593
+ }
594
+ for (const credential of end.credentials) {
595
+ const cref: Ref = { kind: 'credential', id: credential.before.columns.id };
596
+ if (credential.before.columns.principal !== principal || credential.before.columns.revoked) return { refused: 'tampered' };
597
+ // One with no seal is none of the log's: ended, its row revoked, with no change (the statement holds it to having none).
598
+ if (credential.before.seal === null) {
599
+ swept.push({ id: cref.id, binding: columns.id, macFrom: Buffer.from(credential.authMacFrom, 'base64'), sealFrom: null, authMac: Buffer.from(credential.authMac, 'base64'), seal: null });
600
+ continue;
601
+ }
602
+ const cfrom = Buffer.from(credential.before.seal, 'base64');
603
+ const now = credentialObject(credential.before.columns);
604
+ if (!sealed(keys.seals, cref, now, cfrom)) return { refused: 'tampered' };
605
+ const cseal = sealOf(keys.seals[0]!, cref, applyOp(now, cref, { op: 'credential.revoke' })!);
606
+ // Each after the bindings', in the order named.
607
+ sweeps.push(() => change(cref, canonical({
608
+ ...JSON.parse(payloadOf(cref, { op: 'credential.revoke' })),
609
+ revoke: { reason: 'unbound', via: null, kind: credential.before.columns.kind, principal, binding: columns.id },
610
+ }), cseal));
611
+ swept.push({ id: cref.id, binding: columns.id, macFrom: Buffer.from(credential.authMacFrom, 'base64'), sealFrom: cfrom, authMac: Buffer.from(credential.authMac, 'base64'), seal: cseal });
612
+ }
613
+ }
614
+ for (const sweep of sweeps) sweep();
615
+ return {
616
+ at, actor, principal, make: made, ends: ended, credentials: swept, live: input.live, reads: input.reads, revokedAt: input.revokedAt, revokedBy: input.revokedBy,
617
+ policy: { seq: input.policy.seq, slugs: read?.slugs ?? input.policy.protected }, appKey: input.appKey, changes,
618
+ };
619
+ }
620
+
621
+ /** The change's statement, on the engine `db` is. */
622
+ export async function changeBindings(db: Database, call: BindingsCall): Promise<Answer> {
623
+ if (engineOf(db) === 'sqlite') return db.transaction((tx) => bindingsOnSqlite(tx, call));
624
+ try {
625
+ return await bindingsOnPostgres(db, call);
626
+ } catch (error) {
627
+ if (isBusy(error)) return { state: 'busy' };
628
+ throw error;
629
+ }
630
+ }