@coffre/vault 0.0.0-stage → 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,2225 @@
1
+ import { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint } from "@coffre/core/vault";
2
+ import { DEK_BYTES, KekBadClaimError, KekCancelledError, KekRegistry, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
+ import { tablesOf } from "@coffre/db";
4
+ import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
5
+ import { allows, assignableToEnvironment, isRole, isSyncPrincipal, mayManageAccess } from "@coffre/core/access";
6
+ import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
+ import { checkContext } from "@coffre/core/envelope";
8
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
9
+ import { appendEntries, lockLogHead } from "@coffre/db/log";
10
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
11
+ //#region src/config.ts
12
+ /**
13
+ * 1000 keys in 15 minutes: twenty `coffre run`s of a 50-key environment
14
+ * back to back, which no person or deploy pipeline does, while a script
15
+ * pulling every secret it can reach stops within a few seconds.
16
+ */
17
+ const DEFAULT_BULK_LIMIT = {
18
+ count: 1e3,
19
+ windowMs: 9e5
20
+ };
21
+ function key32(value, what) {
22
+ const raw = Buffer.from(value, "base64");
23
+ if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
24
+ return raw;
25
+ }
26
+ const KEK_ID = /^[A-Za-z0-9._-]{1,64}$/;
27
+ const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
28
+ /** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
29
+ const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
30
+ function kek(entry) {
31
+ if (!("wrap" in entry)) {
32
+ const { id, key } = entry;
33
+ if (!KEK_ID.test(id)) throw new Error(`KEK id "${id}" must be 1-64 letters, digits, dots, dashes or underscores`);
34
+ return new LocalKekProvider(key32(key, `KEK ${id}`), id);
35
+ }
36
+ const { provider, keyId, keyVersion } = entry;
37
+ if (typeof provider !== "string" || !KEK_PROVIDER.test(provider)) throw new Error(`a KEK provider's name must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
38
+ if (typeof keyId !== "string" || !KEK_NAME.test(keyId) || typeof keyVersion !== "string" || !KEK_NAME.test(keyVersion)) throw new Error(`KEK ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
39
+ if (typeof entry.wrap !== "function" || typeof entry.unwrap !== "function") throw new Error(`KEK ${provider}:${keyId} needs wrap() and unwrap()`);
40
+ return entry;
41
+ }
42
+ /** Check a deployment's vault configuration, failing on the first problem. */
43
+ function resolveVaultConfig(config) {
44
+ const [current, ...previous] = [config.kek, ...config.previousKeks ?? []].map(kek);
45
+ const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
46
+ const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
47
+ if (twice !== void 0) throw new Error(`two KEKs share an id: ${twice}`);
48
+ return {
49
+ keks: new KekRegistry(current, previous),
50
+ rootAdmins: checkRootAdmins(config.rootAdmins),
51
+ signingKey: key32(config.signingKey, "the signing key"),
52
+ bulkLimit: config.bulkLimit === void 0 ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit)
53
+ };
54
+ }
55
+ function checkBulkLimit({ count, windowMinutes }) {
56
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) throw new Error("bulkLimit needs a whole count of at least 1 and a window above 0 minutes");
57
+ return {
58
+ count,
59
+ windowMs: windowMinutes * 6e4
60
+ };
61
+ }
62
+ function isHumanEmail(value) {
63
+ if (value.length > 254) return false;
64
+ const parts = value.split("@");
65
+ if (parts.length !== 2) return false;
66
+ const [local, domain] = parts;
67
+ if (local.length === 0 || local.length > 64 || local.startsWith(".") || local.endsWith(".") || local.includes("..") || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local)) return false;
68
+ const labels = domain.split(".");
69
+ if (labels.length < 2) return false;
70
+ return labels.every((label) => label.length > 0 && label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
71
+ }
72
+ /**
73
+ * Root admins, lowercased the way they are compared. At least one, in every
74
+ * mode: they are the only way into a fresh instance, and the only members
75
+ * nobody can remove.
76
+ */
77
+ function checkRootAdmins(emails) {
78
+ const rootAdmins = [...new Set(emails.map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
79
+ if (rootAdmins.length === 0) throw new Error("rootAdmins must name at least one email, or nobody can manage coffre");
80
+ const invalid = rootAdmins.find((entry) => !isHumanEmail(entry));
81
+ if (invalid) throw new Error(`rootAdmins must be human email identities; invalid: ${invalid}`);
82
+ return rootAdmins.map((entry) => entry.toLowerCase());
83
+ }
84
+ //#endregion
85
+ //#region src/store.ts
86
+ /**
87
+ * On Postgres, fail a lock wait in this transaction after `ms`, rather than
88
+ * wait for ever behind a transaction that never ends.
89
+ */
90
+ async function boundLockWaits(tx, ms) {
91
+ if (engineOf(tx) === "postgres") await tx.execute(sql.raw(`SET LOCAL lock_timeout = ${Math.trunc(ms)}`));
92
+ }
93
+ /** The database's clock, now. */
94
+ async function now(db) {
95
+ const { auditChainHead } = tablesOf(db);
96
+ const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
97
+ return at;
98
+ }
99
+ function memberColumns(db) {
100
+ const { vaultMembers } = tablesOf(db);
101
+ return {
102
+ principal: vaultMembers.principal,
103
+ status: vaultMembers.status,
104
+ owner: vaultMembers.owner,
105
+ generation: vaultMembers.generation,
106
+ createdAt: vaultMembers.createdAt,
107
+ createdBy: vaultMembers.createdBy,
108
+ statusChangedAt: vaultMembers.statusChangedAt,
109
+ statusChangedBy: vaultMembers.statusChangedBy,
110
+ accessSeq: vaultMembers.accessSeq,
111
+ mac: vaultMembers.mac
112
+ };
113
+ }
114
+ async function member(db, principal) {
115
+ const { vaultMembers } = tablesOf(db);
116
+ const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
117
+ return row;
118
+ }
119
+ async function allMembers(db) {
120
+ const { vaultMembers } = tablesOf(db);
121
+ return await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal));
122
+ }
123
+ /**
124
+ * Lock these members' rows for the rest of the transaction, in one order
125
+ * everywhere, and read them: those that have none are not in the map. Every
126
+ * decision about a member takes its row first, so two of them, from any
127
+ * process, never interleave.
128
+ */
129
+ async function lockMembers(tx, principals) {
130
+ const { vaultMembers } = tablesOf(tx);
131
+ const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
132
+ return new Map(rows.map((row) => [row.principal, row]));
133
+ }
134
+ /**
135
+ * Add `row`. A principal has one row: when two decisions admit the same
136
+ * one at once, the second fails here, and rolls back with its entries.
137
+ */
138
+ async function insertMember(tx, row) {
139
+ const { vaultMembers } = tablesOf(tx);
140
+ await tx.insert(vaultMembers).values(row);
141
+ }
142
+ async function updateMember(tx, principal, change) {
143
+ const { vaultMembers } = tablesOf(tx);
144
+ await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
145
+ }
146
+ /** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
147
+ async function grants(db, principal) {
148
+ const { vaultGrants, environments } = tablesOf(db);
149
+ return db.select({
150
+ principal: vaultGrants.principal,
151
+ projectId: sql`coalesce(${vaultGrants.projectId}, ${environments.projectId})`,
152
+ environmentId: vaultGrants.environmentId,
153
+ role: vaultGrants.role,
154
+ expiresAt: vaultGrants.expiresAt,
155
+ grantedAt: vaultGrants.grantedAt,
156
+ grantedBy: vaultGrants.grantedBy
157
+ }).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(principal === void 0 ? void 0 : eq(vaultGrants.principal, principal));
158
+ }
159
+ /** A grant names its environment, or its project when it has none: exactly one. */
160
+ function at(db, place) {
161
+ const { vaultGrants } = tablesOf(db);
162
+ return place.environmentId === null ? and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId)) : eq(vaultGrants.environmentId, place.environmentId);
163
+ }
164
+ async function insertGrant(tx, grant) {
165
+ const { vaultGrants } = tablesOf(tx);
166
+ await tx.insert(vaultGrants).values({
167
+ ...grant,
168
+ projectId: grant.environmentId === null ? grant.projectId : null
169
+ });
170
+ }
171
+ async function deleteGrant(tx, principal, place) {
172
+ const { vaultGrants } = tablesOf(tx);
173
+ await tx.delete(vaultGrants).where(and(eq(vaultGrants.principal, principal), at(tx, place)));
174
+ }
175
+ async function deleteGrants(tx, principal) {
176
+ const { vaultGrants } = tablesOf(tx);
177
+ await tx.delete(vaultGrants).where(eq(vaultGrants.principal, principal));
178
+ }
179
+ /**
180
+ * Up to `limit` of the newest stored data keys wrapped under one KEK, each
181
+ * with the secret it opens for: what proves a KEK is the one the data was
182
+ * wrapped with, before the vault records a check value for it.
183
+ */
184
+ async function wrappedUnder(db, provider, keyId, limit) {
185
+ const { secretVersions, secrets } = tablesOf(db);
186
+ return (await db.select({
187
+ projectId: secrets.projectId,
188
+ environmentId: secrets.environmentId,
189
+ secretId: secretVersions.secretId,
190
+ version: secretVersions.version,
191
+ kekProvider: secretVersions.kekProvider,
192
+ kekId: secretVersions.kekId,
193
+ kekVersion: secretVersions.kekVersion,
194
+ bytes: secretVersions.wrappedDek
195
+ }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
196
+ ...row,
197
+ bytes: Buffer.from(row.bytes)
198
+ }));
199
+ }
200
+ /** Of these projects and environments, the ones that exist, each environment with its project. */
201
+ async function places(db, projectIds, environmentIds) {
202
+ const { projects, environments } = tablesOf(db);
203
+ const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({ id: projects.id }).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
204
+ id: environments.id,
205
+ projectId: environments.projectId
206
+ }).from(environments).where(inArray(environments.id, [...environmentIds]))]);
207
+ return {
208
+ projects: new Set(foundProjects.map((row) => row.id)),
209
+ environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId]))
210
+ };
211
+ }
212
+ /** The action of an entry that releases a key, which the bulk limit counts. */
213
+ const RELEASE = "secret.read";
214
+ /** How many keys the vault has released to `principal` since `after`. */
215
+ async function releasesSince(db, principal, after) {
216
+ const { auditLog } = tablesOf(db);
217
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
218
+ return Number(n);
219
+ }
220
+ function entryColumns(db) {
221
+ const { auditLog } = tablesOf(db);
222
+ return {
223
+ seq: auditLog.seq,
224
+ author: auditLog.author,
225
+ keyId: auditLog.keyId,
226
+ occurredAt: auditLog.occurredAt,
227
+ actor: auditLog.actor,
228
+ action: auditLog.action,
229
+ decision: auditLog.decision,
230
+ code: auditLog.code,
231
+ subjectPrincipal: auditLog.subjectPrincipal,
232
+ projectId: auditLog.projectId,
233
+ environmentId: auditLog.environmentId,
234
+ secretId: auditLog.secretId,
235
+ secretVersionId: auditLog.secretVersionId,
236
+ operationId: auditLog.operationId,
237
+ requestId: auditLog.requestId,
238
+ sourceIp: auditLog.sourceIp,
239
+ relatedSeq: auditLog.relatedSeq,
240
+ metadata: auditLog.metadata,
241
+ prevHash: auditLog.prevHash,
242
+ mac: auditLog.mac,
243
+ hash: auditLog.hash
244
+ };
245
+ }
246
+ /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
247
+ function stored(rows) {
248
+ return rows;
249
+ }
250
+ /** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
251
+ async function entriesFrom(db, fromSeq, limit) {
252
+ const { auditLog } = tablesOf(db);
253
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
254
+ }
255
+ /** The entry at `seq`'s hash, or undefined when there is none. */
256
+ async function hashAt(db, seq) {
257
+ const { auditLog } = tablesOf(db);
258
+ const [row] = await db.select({ hash: auditLog.hash }).from(auditLog).where(eq(auditLog.seq, seq));
259
+ return row?.hash;
260
+ }
261
+ /** The vault's newest entry of any of these actions, allowed, or undefined. */
262
+ async function latestVaultEntry(db, actions) {
263
+ const { auditLog } = tablesOf(db);
264
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
265
+ return row === void 0 ? void 0 : stored([row])[0];
266
+ }
267
+ /** Up to `limit` of the vault's allowed access entries about `principal`, newest first. */
268
+ async function accessEntriesAbout(db, principal, limit) {
269
+ const { auditLog } = tablesOf(db);
270
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, principal), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(limit));
271
+ }
272
+ /** The vault's newest allowed access entry about each member who has one. */
273
+ async function newestAccessEntries(db) {
274
+ const { auditLog } = tablesOf(db);
275
+ const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
276
+ const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
277
+ return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
278
+ }
279
+ /** Up to `limit` of the vault's allowed entries of these actions after `afterSeq`, oldest first. */
280
+ async function vaultEntriesOf(db, actions, afterSeq, limit) {
281
+ const { auditLog } = tablesOf(db);
282
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
283
+ }
284
+ async function versions(db, ids) {
285
+ if (ids.length === 0) return [];
286
+ const { secretVersions, secrets, environments, projects } = tablesOf(db);
287
+ return (await db.select({
288
+ id: secretVersions.id,
289
+ version: secretVersions.version,
290
+ secretId: secrets.id,
291
+ projectId: secrets.projectId,
292
+ environmentId: secrets.environmentId,
293
+ project: projects.slug,
294
+ environment: environments.slug,
295
+ key: secrets.key,
296
+ kekProvider: secretVersions.kekProvider,
297
+ kekId: secretVersions.kekId,
298
+ kekVersion: secretVersions.kekVersion,
299
+ wrappedDek: secretVersions.wrappedDek
300
+ }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(inArray(secretVersions.id, [...new Set(ids)]))).map((row) => ({
301
+ id: row.id,
302
+ secret: {
303
+ projectId: row.projectId,
304
+ environmentId: row.environmentId,
305
+ secretId: row.secretId,
306
+ version: row.version,
307
+ path: `${row.project}/${row.environment}/${row.key}`
308
+ },
309
+ wrapped: {
310
+ kekProvider: row.kekProvider,
311
+ kekId: row.kekId,
312
+ kekVersion: row.kekVersion,
313
+ bytes: row.wrappedDek.toString("base64")
314
+ }
315
+ }));
316
+ }
317
+ //#endregion
318
+ //#region src/log.ts
319
+ /**
320
+ * The vault's half of the one audit log: its entries are the ones it
321
+ * writes, `author = 'vault'`, each under its MAC, in the chain the app's
322
+ * entries share (@coffre/core/audit says what an entry covers). The app
323
+ * checks its own entries by its key; this checks the vault's by the vault's,
324
+ * and the app's only for their place in the chain.
325
+ */
326
+ /**
327
+ * The vault's log key, derived from its signing key, so that it is one more
328
+ * secret to hold, not one more to keep. Whoever can write the database but
329
+ * does not hold the vault's configuration cannot write an entry it accepts.
330
+ */
331
+ function vaultLogKey(signingKey) {
332
+ return deriveLogKey("vault", signingKey);
333
+ }
334
+ /** Before the first entry: nothing verified yet. */
335
+ const UNVERIFIED = {
336
+ nextSeq: 0n,
337
+ hash: GENESIS_HASH,
338
+ vaultEntries: 0
339
+ };
340
+ /** The further of two anchors, when two calls verified at once. */
341
+ function further(a, b) {
342
+ return b.nextSeq > a.nextSeq ? b : a;
343
+ }
344
+ const VERIFY_BATCH = 1e3;
345
+ /**
346
+ * Check the chain, a batch in memory at a time, and return where it is now
347
+ * verified to. Three parts:
348
+ *
349
+ * - `shown`, the page a reader is looking at: each entry against its own
350
+ * hash and MAC;
351
+ * - `anchor`, the head at the last check: still there, unchanged. A rewrite
352
+ * of anything before it, chained again to hide, changes its hash;
353
+ * - every entry after the anchor, or from the first.
354
+ *
355
+ * So a view rehashes only what is new since the last one. What it leaves
356
+ * out is an entry before the anchor edited in place, not chained again, and
357
+ * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
358
+ * as does the first view after a start, which has no anchor.
359
+ */
360
+ async function verifyChain(db, key, shown, anchor) {
361
+ const broken = (failedAtSeq, reason) => ({
362
+ verification: {
363
+ ok: false,
364
+ failedAtSeq: Number(failedAtSeq),
365
+ reason
366
+ },
367
+ anchor
368
+ });
369
+ const keys = {
370
+ keys: [key],
371
+ chainOnly: ["app"]
372
+ };
373
+ for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
374
+ const result = verifyEntries([row], {
375
+ ...keys,
376
+ startSeq: row.seq,
377
+ startPrevHash: row.prevHash
378
+ });
379
+ if (!result.ok) return broken(result.failedAtSeq, result.reason);
380
+ }
381
+ if (anchor.nextSeq > 0n && !(await hashAt(db, anchor.nextSeq - 1n))?.equals(anchor.hash)) return broken(anchor.nextSeq - 1n, "changed since the vault last verified it");
382
+ let verified = anchor;
383
+ for (;;) {
384
+ const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
385
+ if (batch.length === 0) break;
386
+ const result = verifyEntries(batch, {
387
+ ...keys,
388
+ startSeq: verified.nextSeq,
389
+ startPrevHash: verified.hash
390
+ });
391
+ if (!result.ok) return broken(result.failedAtSeq, result.reason);
392
+ verified = {
393
+ nextSeq: result.nextSeq,
394
+ hash: result.head,
395
+ vaultEntries: verified.vaultEntries + result.authenticated
396
+ };
397
+ if (batch.length < 1e3) break;
398
+ }
399
+ return {
400
+ verification: {
401
+ ok: true,
402
+ entries: verified.vaultEntries
403
+ },
404
+ anchor: verified
405
+ };
406
+ }
407
+ /**
408
+ * Whether the log still holds `head` where it was: not rewritten, nor cut
409
+ * back before it. A head of 64 zeros is before the first entry, which any
410
+ * log holds.
411
+ */
412
+ async function carries(db, head) {
413
+ if (head.hash === GENESIS_HASH.toString("hex")) return true;
414
+ return (await hashAt(db, BigInt(head.seq)))?.toString("hex") === head.hash;
415
+ }
416
+ //#endregion
417
+ //#region src/accounting.ts
418
+ const KEY_ACTIONS = /* @__PURE__ */ new Set([
419
+ "secret.read",
420
+ "key.wrap",
421
+ "key.rewrap"
422
+ ]);
423
+ /** The chain can hold while a process died between an intent and its outcomes. */
424
+ async function verifyAccounting(db, at) {
425
+ const intents = /* @__PURE__ */ new Map();
426
+ const ids = /* @__PURE__ */ new Set();
427
+ const broken = (entry, reason) => ({
428
+ ok: false,
429
+ failedAtSeq: Number(entry.seq),
430
+ reason
431
+ });
432
+ let next = 0n;
433
+ for (;;) {
434
+ const batch = await entriesFrom(db, next, VERIFY_BATCH);
435
+ if (batch.length === 0) break;
436
+ for (const entry of batch) {
437
+ if (entry.author !== "vault") continue;
438
+ if (entry.action === "key.intent") {
439
+ const parsed = parseIntent(entry);
440
+ if (typeof parsed === "string") return broken(entry, parsed);
441
+ if (ids.has(parsed.intentId)) return broken(entry, "key intent repeats an operation identity");
442
+ ids.add(parsed.intentId);
443
+ if (parsed.keys.length > 0) intents.set(entry.seq, {
444
+ ...parsed,
445
+ entry,
446
+ seen: /* @__PURE__ */ new Set()
447
+ });
448
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
449
+ const parsed = parseOutcome(entry);
450
+ if (typeof parsed === "string") return broken(entry, parsed);
451
+ const intent = intents.get(parsed.relatedSeq);
452
+ const key = intent?.keys[parsed.item];
453
+ if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken(entry, "key outcome does not identify one item of its intent");
454
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken(entry, "key outcome belongs to another operation");
455
+ if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken(entry, "key outcome does not match its intended secret");
456
+ intent.seen.add(parsed.item);
457
+ if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
458
+ }
459
+ }
460
+ next = batch[batch.length - 1].seq + 1n;
461
+ if (batch.length < 1e3) break;
462
+ }
463
+ const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
464
+ if (overdue.length === 0) return {
465
+ ok: true,
466
+ pending: intents.size
467
+ };
468
+ const reason = overdue.map((intent) => {
469
+ const missing = intent.keys.length - intent.seen.size;
470
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
471
+ }).join("; ");
472
+ return broken(overdue[0].entry, reason);
473
+ }
474
+ function parseIntent(entry) {
475
+ const detail = payload(entry.metadata);
476
+ if (detail === null) return "key intent has no valid accounting payload";
477
+ const { intent, operation, expiresAt } = detail;
478
+ if (typeof intent !== "string") return "key intent has no operation identity";
479
+ if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
480
+ if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
481
+ if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
482
+ const keys = [];
483
+ for (const [index, value] of detail.keys.entries()) {
484
+ const key = record(value);
485
+ if (key === null || key.item !== index) return "key intent items are not numbered in order";
486
+ const { secretId, version, subject } = key;
487
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
488
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
489
+ keys.push({
490
+ item: index,
491
+ secretId,
492
+ version,
493
+ subject
494
+ });
495
+ }
496
+ return {
497
+ intentId: intent,
498
+ operation,
499
+ expiresAt,
500
+ keys
501
+ };
502
+ }
503
+ function parseOutcome(entry) {
504
+ const detail = payload(entry.metadata);
505
+ if (detail === null) return "key outcome has no valid accounting payload";
506
+ const { intent, item, version, subject } = detail;
507
+ if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
508
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
509
+ const secretId = entry.secretId ?? detail.secretId;
510
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
511
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
512
+ return {
513
+ intentId: intent,
514
+ relatedSeq: entry.relatedSeq,
515
+ item,
516
+ secretId,
517
+ version,
518
+ subject
519
+ };
520
+ }
521
+ function payload(text) {
522
+ try {
523
+ return record(JSON.parse(text));
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+ function record(value) {
529
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
530
+ }
531
+ //#endregion
532
+ //#region src/checkpoint.ts
533
+ const PKCS8_ED25519 = [
534
+ 48,
535
+ 46,
536
+ 2,
537
+ 1,
538
+ 0,
539
+ 48,
540
+ 5,
541
+ 6,
542
+ 3,
543
+ 43,
544
+ 101,
545
+ 112,
546
+ 4,
547
+ 34,
548
+ 4,
549
+ 32
550
+ ];
551
+ /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
552
+ async function signer(seed) {
553
+ const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
554
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
555
+ const { x } = await crypto.subtle.exportKey("jwk", privateKey);
556
+ const publicKey = Buffer.from(x, "base64url");
557
+ const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
558
+ return {
559
+ publicKey: publicKey.toString("base64"),
560
+ keyId: digest.toString("hex").slice(0, 16),
561
+ sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
562
+ };
563
+ }
564
+ //#endregion
565
+ //#region src/replay.ts
566
+ const BATCH = 1e3;
567
+ /**
568
+ * Why the members and grants in the database do not follow from the
569
+ * vault's entries, or null when they do; `describeAccessFault` words it.
570
+ * Every change to either is logged in the transaction that makes it, with
571
+ * the row's times taken from its entry, so replaying the allowed ones from
572
+ * the first gives the tables back, and a row the log does not explain was
573
+ * written around the vault: a grant inserted with the database's own login,
574
+ * a removal undone.
575
+ *
576
+ * Call it after the chain is verified, in the same snapshot, so every entry
577
+ * it replays carries the vault's MAC.
578
+ *
579
+ * Grants are compared as they are live at `at`. Clearing one that has
580
+ * lapsed changes nothing anyone holds, so it is not logged.
581
+ */
582
+ async function replay(db, at) {
583
+ const state = {
584
+ members: /* @__PURE__ */ new Map(),
585
+ held: /* @__PURE__ */ new Map()
586
+ };
587
+ for (let after = -1n;;) {
588
+ const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
589
+ for (const row of batch) {
590
+ const fault = apply(state, row);
591
+ if (fault !== null) return fault;
592
+ }
593
+ if (batch.length < BATCH) break;
594
+ after = batch[batch.length - 1].seq;
595
+ }
596
+ const { members, held } = state;
597
+ const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
598
+ for (const principal of [.../* @__PURE__ */ new Set([...stored.keys(), ...members.keys()])].sort()) {
599
+ const [inStore, inLog] = [stored.get(principal), members.get(principal)];
600
+ if (inLog === void 0) return {
601
+ kind: "unlogged-member",
602
+ principal
603
+ };
604
+ if (inStore === void 0) return {
605
+ kind: "missing-member",
606
+ principal
607
+ };
608
+ const fields = MEMBER_FIELDS.filter(([field]) => inStore[field] !== inLog[field]).map(([, column]) => column);
609
+ if (fields.length > 0) return {
610
+ kind: "member-differs",
611
+ principal,
612
+ fields
613
+ };
614
+ }
615
+ const live = (grant) => grant.expiresAt === null || grant.expiresAt > at;
616
+ const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
617
+ const inStore = new Set((await grants(db)).filter(live).map(grantKey));
618
+ const extra = [...inStore].sort().find((grant) => !logged.has(grant));
619
+ if (extra !== void 0) return {
620
+ kind: "unlogged-grant",
621
+ grant: faultGrant(extra)
622
+ };
623
+ const missing = [...logged].sort().find((grant) => !inStore.has(grant));
624
+ if (missing !== void 0) return {
625
+ kind: "missing-grant",
626
+ grant: faultGrant(missing)
627
+ };
628
+ return null;
629
+ }
630
+ /** One access entry, authenticated, applied to `state`; a fault when it changes someone never admitted. */
631
+ function apply(state, row) {
632
+ const principal = row.subjectPrincipal;
633
+ const detail = JSON.parse(row.metadata);
634
+ const place = {
635
+ projectId: row.projectId,
636
+ environmentId: row.environmentId
637
+ };
638
+ const grantsOf = state.held.get(principal) ?? /* @__PURE__ */ new Map();
639
+ state.held.set(principal, grantsOf);
640
+ const before = state.members.get(principal);
641
+ const changed = {
642
+ statusChangedAt: row.occurredAt,
643
+ statusChangedBy: row.actor
644
+ };
645
+ switch (row.action) {
646
+ case "member.add":
647
+ case "member.restore":
648
+ state.members.set(principal, {
649
+ principal,
650
+ status: "active",
651
+ owner: detail.owner === true,
652
+ generation: before?.generation ?? 0,
653
+ createdAt: before?.createdAt ?? row.occurredAt,
654
+ createdBy: before?.createdBy ?? row.actor,
655
+ accessSeq: row.seq,
656
+ ...changed
657
+ });
658
+ return null;
659
+ case "member.owner":
660
+ if (before === void 0) return {
661
+ kind: "unadmitted-change",
662
+ seq: Number(row.seq),
663
+ principal
664
+ };
665
+ state.members.set(principal, {
666
+ ...before,
667
+ owner: detail.owner === true,
668
+ accessSeq: row.seq
669
+ });
670
+ return null;
671
+ case "member.remove": {
672
+ if (before === void 0) return {
673
+ kind: "unadmitted-change",
674
+ seq: Number(row.seq),
675
+ principal
676
+ };
677
+ const generation = typeof detail.generation === "number" ? detail.generation : before.generation + 1;
678
+ state.members.set(principal, {
679
+ ...before,
680
+ status: "removed",
681
+ owner: false,
682
+ generation,
683
+ accessSeq: row.seq,
684
+ ...changed
685
+ });
686
+ grantsOf.clear();
687
+ return null;
688
+ }
689
+ case "access.grant":
690
+ grantsOf.set(placeKey(place), {
691
+ principal,
692
+ ...place,
693
+ role: detail.role,
694
+ expiresAt: detail.expiresAt === null ? null : Date.parse(detail.expiresAt),
695
+ grantedAt: row.occurredAt,
696
+ grantedBy: row.actor
697
+ });
698
+ if (before !== void 0) state.members.set(principal, {
699
+ ...before,
700
+ accessSeq: row.seq
701
+ });
702
+ return null;
703
+ case "access.revoke":
704
+ grantsOf.delete(placeKey(place));
705
+ if (before !== void 0) state.members.set(principal, {
706
+ ...before,
707
+ accessSeq: row.seq
708
+ });
709
+ return null;
710
+ }
711
+ return null;
712
+ }
713
+ /** A member's fields, and the columns a fault names them by. */
714
+ const MEMBER_FIELDS = [
715
+ ["status", "status"],
716
+ ["owner", "owner"],
717
+ ["generation", "generation"],
718
+ ["createdAt", "created_at"],
719
+ ["createdBy", "created_by"],
720
+ ["statusChangedAt", "status_changed_at"],
721
+ ["statusChangedBy", "status_changed_by"],
722
+ ["accessSeq", "access_seq"]
723
+ ];
724
+ /** A grant's place: its environment, or its project when it has none. */
725
+ function placeKey(place) {
726
+ return place.environmentId ?? place.projectId;
727
+ }
728
+ function grantKey(grant) {
729
+ return JSON.stringify([
730
+ grant.principal,
731
+ grant.projectId,
732
+ grant.environmentId,
733
+ grant.role,
734
+ grant.expiresAt,
735
+ grant.grantedAt,
736
+ grant.grantedBy
737
+ ]);
738
+ }
739
+ function faultGrant(key) {
740
+ const [principal, projectId, environmentId, role] = JSON.parse(key);
741
+ return {
742
+ principal,
743
+ projectId,
744
+ environmentId,
745
+ role
746
+ };
747
+ }
748
+ //#endregion
749
+ //#region src/rows.ts
750
+ /**
751
+ * The MAC over each member's row and grants: what makes a row written
752
+ * around the vault, by anyone who can write the database but does not hold
753
+ * its signing key, one the vault refuses rather than trusts.
754
+ *
755
+ * One MAC per member, over the row and the member's grants as a sorted set,
756
+ * lapsed ones included, rather than one per row: a grant deleted fails it as
757
+ * surely as one added or edited. `access_seq` is in it too, so a genuine row
758
+ * put back from before a later change still names the older entry, which the
759
+ * log has moved past (vault.ts, `#integrity`).
760
+ *
761
+ * A change to what is covered is a new version of the tuple, never an edit.
762
+ */
763
+ /** The key member rows are sealed under, derived from the signing key: one more secret to hold, not one more to keep. */
764
+ function rowKey(signingKey) {
765
+ return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
766
+ }
767
+ /** A member's grants as the MAC covers them: each one's place, role, end and grant, as a sorted set. */
768
+ function grantSet(grants) {
769
+ return grants.map((grant) => [
770
+ grant.environmentId === null ? "project" : "environment",
771
+ grant.environmentId ?? grant.projectId,
772
+ grant.role,
773
+ grant.expiresAt,
774
+ grant.grantedAt,
775
+ grant.grantedBy
776
+ ]).map((tuple) => JSON.stringify(tuple)).sort();
777
+ }
778
+ /** Whether two reads of a member's grants are the same set, as the MAC sees them. */
779
+ function sameGrants(a, b) {
780
+ const [x, y] = [grantSet(a), grantSet(b)];
781
+ return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
782
+ }
783
+ function memberMac(key, member, grants) {
784
+ const held = grantSet(grants);
785
+ const tuple = [
786
+ "coffre.vault.member.v1",
787
+ member.principal,
788
+ member.status,
789
+ member.owner,
790
+ member.generation,
791
+ member.accessSeq.toString(),
792
+ member.createdAt,
793
+ member.createdBy,
794
+ member.statusChangedAt,
795
+ member.statusChangedBy,
796
+ held
797
+ ];
798
+ return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
799
+ }
800
+ /** Whether `member`'s MAC is the vault's, over this row and these grants. */
801
+ function sealed(key, member, grants) {
802
+ const expected = memberMac(key, member, grants);
803
+ return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
804
+ }
805
+ //#endregion
806
+ //#region src/vault.ts
807
+ async function prepareVault(config, options = {}) {
808
+ return {
809
+ config,
810
+ signer: await signer(config.signingKey),
811
+ logKey: vaultLogKey(config.signingKey),
812
+ options: {
813
+ keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
814
+ clockOffset: options.clockOffset ?? (() => 0)
815
+ },
816
+ verified: UNVERIFIED,
817
+ rooted: /* @__PURE__ */ new Set(),
818
+ rowKey: rowKey(config.signingKey),
819
+ reported: /* @__PURE__ */ new Set(),
820
+ kekCheck: null,
821
+ wrongKek: null
822
+ };
823
+ }
824
+ /** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
825
+ function openVault(db, prepared) {
826
+ return new VaultService(db, prepared);
827
+ }
828
+ /** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
829
+ const KEY_BUDGET_MS = 5e3;
830
+ /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
831
+ const LOCK_TIMEOUT_MS = 15e3;
832
+ const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
833
+ /** Who acts for the vault itself, as when it gives a root admin a member row. */
834
+ const VAULT_ACTOR = "system:vault";
835
+ /**
836
+ * A KEK's check: a known value, the size of a data key, wrapped under it in
837
+ * a context no secret has (the nil UUID), and kept in a `key.check` entry.
838
+ * Opening it again tells the vault its KEK is the one that wrapped the
839
+ * data, without opening any data.
840
+ */
841
+ const KEY_CHECK = "key.check";
842
+ const KEY_CHECK_VALUE = createHash("sha256").update("coffre.kek.check.v1").digest();
843
+ const NIL = "00000000-0000-0000-0000-000000000000";
844
+ const KEY_CHECK_CONTEXT = {
845
+ projectId: NIL,
846
+ environmentId: NIL,
847
+ secretId: NIL
848
+ };
849
+ /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
850
+ const KEY_CHECK_SAMPLE = 3;
851
+ /** A new member row, before the decision seals it (`#seal`). */
852
+ const UNSEALED = {
853
+ accessSeq: 0n,
854
+ mac: Buffer.alloc(32)
855
+ };
856
+ /** Why a change to a tampered member is refused. */
857
+ const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
858
+ /** The action of the vault's entry that signs a prefix of the log. */
859
+ const CHECKPOINT = "audit.checkpoint";
860
+ /** Who asks for checkpoints: the app's scheduled job. */
861
+ const SCHEDULER = "system:coffre-scheduler";
862
+ /** A refusal and the entries that record it. */
863
+ var Refused = class {
864
+ refusal;
865
+ entries;
866
+ constructor(refusal, entries) {
867
+ this.refusal = refusal;
868
+ this.entries = entries;
869
+ }
870
+ };
871
+ /** A decision that read a member as absent who has been admitted since: it is made again. */
872
+ var Retry = class {};
873
+ /** What each `vault.tampered` entry reports, to mark it logged once committed. */
874
+ const REPORTED = /* @__PURE__ */ new WeakMap();
875
+ /** A key service that did not answer, and the entries that record what it did do. */
876
+ var Outage = class {
877
+ error;
878
+ entries;
879
+ constructor(error, entries) {
880
+ this.error = error;
881
+ this.entries = entries;
882
+ }
883
+ };
884
+ function refusal(code, message) {
885
+ return {
886
+ code,
887
+ message
888
+ };
889
+ }
890
+ const MESSAGES = {
891
+ removed: "this member was removed",
892
+ not_a_member: "not a member",
893
+ no_grant: "no grant covers this",
894
+ expired: "the grant that covered this has expired",
895
+ bulk_limit: "too many secrets read in too short a time",
896
+ bad_claim: "the key does not belong to this secret",
897
+ not_allowed: "not allowed to change this",
898
+ root_admin: "root admins are set in the vault configuration",
899
+ invalid: "not something the rules allow",
900
+ log_broken: "the vault log does not hold from the last checkpoint",
901
+ wrong_kek: "this vault's KEK does not open the data it holds",
902
+ tampered: "this member's record failed the vault's integrity check"
903
+ };
904
+ /**
905
+ * The one implementation of `Vault`. Every decision is one transaction on
906
+ * the shared database: it locks the rows of the members it is about, reads
907
+ * what it needs, decides, appends its entries under the log's lock, and
908
+ * only then changes members and grants. Locks come in one order
909
+ * everywhere, a member row, then the log's head, then the app's rows, so
910
+ * any number of vault instances and app servers decide side by side
911
+ * without a cycle (docs/design/single-database.md, question 2).
912
+ *
913
+ * Key operations run inside the decision, after the check, for a call the
914
+ * rules allow: a KMS logs each one, and should never show a key opened for
915
+ * a read coffre refused. With a key service, the call's intent is logged
916
+ * first, in its own transaction, so a vault that dies at KMS leaves a
917
+ * record that pairs with what KMS logged; and every key's outcome is
918
+ * logged, a partial outage included.
919
+ */
920
+ var VaultService = class {
921
+ #db;
922
+ #prepared;
923
+ #config;
924
+ constructor(db, prepared) {
925
+ this.#db = db;
926
+ this.#prepared = prepared;
927
+ this.#config = prepared.config;
928
+ }
929
+ async #now(db) {
930
+ return await now(db) + this.#prepared.options.clockOffset();
931
+ }
932
+ /**
933
+ * Decide in one transaction, with `principals`' rows locked first. A
934
+ * `Refused` or an `Outage` rolls back all but its own entries, which
935
+ * commit on their own: then the refusal is the answer, and the outage
936
+ * fails the call.
937
+ */
938
+ async #decide(principals, decide) {
939
+ const reports = [];
940
+ for (let attempt = 1;; attempt += 1) try {
941
+ return await this.#decideOnce(principals, decide, reports);
942
+ } catch (error) {
943
+ if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
944
+ throw error;
945
+ }
946
+ }
947
+ async #decideOnce(principals, decide, reports) {
948
+ try {
949
+ const result = await this.#db.transaction(async (tx) => {
950
+ await boundLockWaits(tx, LOCK_TIMEOUT_MS);
951
+ const d = {
952
+ tx,
953
+ members: principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals),
954
+ at: await this.#now(tx),
955
+ log: [],
956
+ writes: [],
957
+ touched: /* @__PURE__ */ new Set(),
958
+ grants: /* @__PURE__ */ new Map(),
959
+ reports,
960
+ after: []
961
+ };
962
+ const result = await decide(d);
963
+ const entries = [...reports, ...d.log];
964
+ let at = d.at;
965
+ const accessSeq = /* @__PURE__ */ new Map();
966
+ if (entries.length > 0) {
967
+ const appended = await appendEntries(tx, this.#prepared.logKey, entries);
968
+ at = appended.occurredAt;
969
+ entries.forEach((entry, i) => {
970
+ if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
971
+ });
972
+ const seqs = new Map(entries.map((entry, i) => [entry, Number(appended.seqStart) + i]));
973
+ for (const then of d.after) then((entry) => seqs.get(entry));
974
+ }
975
+ for (const write of d.writes) await write(at);
976
+ for (const principal of /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()])) await this.#seal(d, principal, accessSeq.get(principal));
977
+ return result;
978
+ });
979
+ this.#reported(reports);
980
+ return {
981
+ ok: true,
982
+ ...result
983
+ };
984
+ } catch (error) {
985
+ if (!(error instanceof Refused || error instanceof Outage)) throw error;
986
+ const entries = [...reports, ...error.entries];
987
+ if (entries.length > 0) await this.#db.transaction(async (tx) => {
988
+ await boundLockWaits(tx, LOCK_TIMEOUT_MS);
989
+ await appendEntries(tx, this.#prepared.logKey, entries);
990
+ });
991
+ this.#reported(reports);
992
+ if (error instanceof Outage) throw error.error;
993
+ return {
994
+ ok: false,
995
+ refusal: error.refusal
996
+ };
997
+ }
998
+ }
999
+ /**
1000
+ * Seal `principal`'s row again over what this decision left them holding,
1001
+ * naming `accessSeq`, their newest access entry, when it wrote one. The
1002
+ * table must hold exactly that: a grant written around the vault while it
1003
+ * decided (its row lock does not stop one) is refused, with the decision,
1004
+ * rather than sealed in.
1005
+ */
1006
+ async #seal(d, principal, accessSeq) {
1007
+ const row = await member(d.tx, principal);
1008
+ if (row === void 0) return;
1009
+ const decided = d.grants.get(principal);
1010
+ if (decided === void 0) throw new Error(`sealing ${principal} without the grants this decision verified`);
1011
+ if (!sameGrants(await grants(d.tx, principal), decided)) {
1012
+ this.#report(d.reports, principal, "mac", row.mac.toString("hex"));
1013
+ throw new Refused(refusal("tampered", MESSAGES.tampered), []);
1014
+ }
1015
+ const next = {
1016
+ ...row,
1017
+ accessSeq: accessSeq ?? row.accessSeq
1018
+ };
1019
+ await updateMember(d.tx, principal, {
1020
+ accessSeq: next.accessSeq,
1021
+ mac: memberMac(this.#prepared.rowKey, next, decided)
1022
+ });
1023
+ }
1024
+ /**
1025
+ * Whether `row`, with these grants, is the row the vault last wrote: its
1026
+ * MAC holds, and it names the newest access entry the log has about the
1027
+ * member. A genuine row put back from before a later change passes the
1028
+ * first and fails the second. Entries in the vault's name that fail their
1029
+ * MAC are passed over, and reported: otherwise whoever can insert a row
1030
+ * could lock any member out.
1031
+ */
1032
+ async #integrity(db, principal, row, grants, reports) {
1033
+ if (row !== void 0 && !sealed(this.#prepared.rowKey, row, grants)) {
1034
+ this.#report(reports, principal, "mac", row.mac.toString("hex"));
1035
+ return "mac";
1036
+ }
1037
+ const newest = await this.#newestAccessSeq(db, principal, reports);
1038
+ if (row === void 0 ? newest === null : newest === row.accessSeq) return null;
1039
+ if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
1040
+ this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${newest}`);
1041
+ return "stale";
1042
+ }
1043
+ /** The seq of the newest access entry about `principal` that carries the vault's MAC, or null. */
1044
+ async #newestAccessSeq(db, principal, reports) {
1045
+ for (const entry of await accessEntriesAbout(db, principal, 32)) {
1046
+ if (this.#authentic(entry)) return entry.seq;
1047
+ this.#report(reports, principal, "forged_entry", String(entry.seq), entry.seq);
1048
+ }
1049
+ return null;
1050
+ }
1051
+ #authentic(entry) {
1052
+ return verifyEntries([entry], {
1053
+ startSeq: entry.seq,
1054
+ startPrevHash: entry.prevHash,
1055
+ keys: [this.#prepared.logKey]
1056
+ }).ok;
1057
+ }
1058
+ /** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
1059
+ #report(reports, principal, code, detail, relatedSeq = null) {
1060
+ const key = `${principal}|${code}|${detail}`;
1061
+ if (this.#prepared.reported.has(key) || reports.some((entry) => REPORTED.get(entry) === key)) return;
1062
+ const entry = {
1063
+ actor: VAULT_ACTOR,
1064
+ action: "vault.tampered",
1065
+ decision: "deny",
1066
+ code,
1067
+ subjectPrincipal: principal,
1068
+ relatedSeq,
1069
+ metadata: "{}"
1070
+ };
1071
+ REPORTED.set(entry, key);
1072
+ reports.push(entry);
1073
+ }
1074
+ /** Mark these reports as logged, once their transaction has committed. */
1075
+ #reported(reports) {
1076
+ for (const entry of reports) {
1077
+ const key = REPORTED.get(entry);
1078
+ if (key !== void 0) this.#prepared.reported.add(key);
1079
+ }
1080
+ }
1081
+ /** Reports found outside a decision, committed on their own. */
1082
+ async #record(reports) {
1083
+ if (reports.length === 0) return;
1084
+ await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, reports));
1085
+ this.#reported(reports);
1086
+ }
1087
+ async unwrap(input) {
1088
+ const { principal } = input;
1089
+ validateText(input.purpose);
1090
+ const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
1091
+ if (!loaded.ok) return loaded;
1092
+ const items = loaded.versions;
1093
+ validateItems(items, "wrapped");
1094
+ const versionIds = new Map(items.map((item) => [item.secret, item.id]));
1095
+ const entry = (secret, decision, code) => ({
1096
+ ...keyEntry("secret.read", principal, secret, decision, code, input, { purpose: input.purpose }),
1097
+ secretVersionId: versionIds.get(secret)
1098
+ });
1099
+ const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
1100
+ return this.#keys({
1101
+ action: "secret.read",
1102
+ principal,
1103
+ permission: "secret.read",
1104
+ secrets: items.map((item) => item.secret),
1105
+ remote,
1106
+ entry,
1107
+ input
1108
+ }, () => items.map(({ secret, wrapped }) => async (operation) => {
1109
+ const key = await this.#open(wrapped, secret, operation);
1110
+ return key && {
1111
+ key,
1112
+ wipe: () => key.fill(0)
1113
+ };
1114
+ }), (opened) => ({ keys: opened.map(({ key }) => {
1115
+ try {
1116
+ return base64(key);
1117
+ } finally {
1118
+ key.fill(0);
1119
+ }
1120
+ }) }));
1121
+ }
1122
+ async wrap(input) {
1123
+ const { principal, items } = input;
1124
+ validateItems(items, "key");
1125
+ validateText(principal);
1126
+ validateCorrelation(input);
1127
+ const entry = (secret, decision, code) => keyEntry("key.wrap", principal, secret, decision, code, input);
1128
+ return this.#keys({
1129
+ action: "key.wrap",
1130
+ principal,
1131
+ permission: "secret.write",
1132
+ secrets: items.map((item) => item.secret),
1133
+ remote: this.#remote(this.#config.keks.primary),
1134
+ entry,
1135
+ input
1136
+ }, () => items.map(({ secret, key }) => async (operation) => {
1137
+ const dek = Buffer.from(key, "base64");
1138
+ try {
1139
+ return {
1140
+ wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)),
1141
+ wipe: () => {}
1142
+ };
1143
+ } finally {
1144
+ dek.fill(0);
1145
+ }
1146
+ }), (done) => ({
1147
+ wrapped: done.map(({ wrapped }) => wrapped),
1148
+ seqs: []
1149
+ }));
1150
+ }
1151
+ async rewrap(input) {
1152
+ const { principal } = input;
1153
+ const loaded = await this.#versions(input, "key.rewrap");
1154
+ if (!loaded.ok) return loaded;
1155
+ const items = input.items.map((item, i) => ({
1156
+ secret: { ...item.secret },
1157
+ wrapped: loaded.versions[i].wrapped
1158
+ }));
1159
+ validateItems(items, "wrapped");
1160
+ const sameSecret = (secret, source) => secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
1161
+ if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#badVersions(principal, loaded.versions.map((source) => ({
1162
+ ...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
1163
+ secretVersionId: source.id
1164
+ })));
1165
+ const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
1166
+ const entry = (secret, decision, code) => ({
1167
+ ...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
1168
+ secretVersionId: sources.get(secret).id
1169
+ });
1170
+ const remote = this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
1171
+ return this.#keys({
1172
+ action: "key.rewrap",
1173
+ principal,
1174
+ permission: "secret.write",
1175
+ secrets: items.map((item) => item.secret),
1176
+ remote,
1177
+ entry,
1178
+ input
1179
+ }, () => items.map(({ secret, wrapped }) => async (operation) => {
1180
+ const key = await this.#open(wrapped, secret, operation);
1181
+ if (key === null) return null;
1182
+ try {
1183
+ return {
1184
+ wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)),
1185
+ wipe: () => {}
1186
+ };
1187
+ } finally {
1188
+ key.fill(0);
1189
+ }
1190
+ }), (done) => ({
1191
+ wrapped: done.map(({ wrapped }) => wrapped),
1192
+ seqs: []
1193
+ }));
1194
+ }
1195
+ /** Immutable versions need no lock; their ids determine the whole batch before any key call. */
1196
+ async #versions(input, action, detail = {}) {
1197
+ validateText(input.principal);
1198
+ validateCorrelation(input);
1199
+ const ids = input.items.map((item) => item.secretVersionId);
1200
+ for (const id of ids) if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error("secret version id must be a lowercase UUID");
1201
+ const found = new Map((await versions(this.#db, ids)).map((version) => [version.id, version]));
1202
+ if (ids.some((id) => !found.has(id))) return this.#badVersions(input.principal, ids.map((id) => {
1203
+ const version = found.get(id);
1204
+ if (version === void 0) return {
1205
+ actor: input.principal,
1206
+ action,
1207
+ decision: "deny",
1208
+ code: "bad_claim",
1209
+ operationId: input.operationId,
1210
+ requestId: input.requestId,
1211
+ metadata: JSON.stringify({
1212
+ ...detail,
1213
+ secretVersionId: id
1214
+ })
1215
+ };
1216
+ return {
1217
+ ...keyEntry(action, input.principal, version.secret, "deny", "bad_claim", input, detail),
1218
+ secretVersionId: id
1219
+ };
1220
+ }));
1221
+ return {
1222
+ ok: true,
1223
+ versions: ids.map((id) => found.get(id))
1224
+ };
1225
+ }
1226
+ async #badVersions(principal, entries) {
1227
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1228
+ return this.#decide([principal], async () => {
1229
+ throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries);
1230
+ });
1231
+ }
1232
+ /**
1233
+ * The data key, or null when it does not open as this secret's: a claim
1234
+ * that is not what it says. A key service that cannot answer is an outage,
1235
+ * not a verdict on the claim, and throws.
1236
+ */
1237
+ async #open(wrapped, secret, operation) {
1238
+ try {
1239
+ return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
1240
+ } catch (error) {
1241
+ if (error instanceof KekBadClaimError) return null;
1242
+ throw error;
1243
+ }
1244
+ }
1245
+ /** Whether a key operation under `kek` leaves the process: a key service, not a key in memory. */
1246
+ #remote(kek) {
1247
+ return kek !== void 0 && !(kek instanceof LocalKekProvider);
1248
+ }
1249
+ /**
1250
+ * One call's key operations, decided as one. With a key service, the
1251
+ * intent commits first; then, under the reader's row, the check, every
1252
+ * operation within the budget, and each key's outcome. With a key in
1253
+ * memory there is nothing to pair with outside, and it is all one short
1254
+ * transaction.
1255
+ */
1256
+ async #keys(call, operations, result) {
1257
+ const { action, principal, secrets, entry } = call;
1258
+ for (const secret of secrets) entry(secret, "allow", null);
1259
+ const wrongKek = await this.#kekMismatch();
1260
+ if (wrongKek !== null) return this.#decide([], async () => {
1261
+ throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
1262
+ });
1263
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1264
+ const check = async (d) => {
1265
+ const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
1266
+ const codes = secrets.map((secret) => refuses(reader, call.permission, secret));
1267
+ let first = codes.find((code) => code !== null) ?? null;
1268
+ if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
1269
+ if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
1270
+ };
1271
+ const intentId = call.remote ? randomUUID() : null;
1272
+ let intentSeq = null;
1273
+ const outcomeEntry = (secret, item, decision, code) => {
1274
+ const outcome = entry(secret, decision, code);
1275
+ return intentSeq === null ? outcome : {
1276
+ ...outcome,
1277
+ relatedSeq: intentSeq,
1278
+ metadata: JSON.stringify({
1279
+ ...JSON.parse(outcome.metadata ?? "{}"),
1280
+ intent: intentId,
1281
+ item,
1282
+ ...["key_error", "kms_uncertain"].includes(code ?? "") ? { uncertain: true } : {}
1283
+ })
1284
+ };
1285
+ };
1286
+ if (call.remote) {
1287
+ const intent = await this.#decide([principal], async (d) => {
1288
+ await check(d);
1289
+ await lockLogHead(d.tx);
1290
+ const at = await this.#now(d.tx);
1291
+ return { seq: (await appendEntries(d.tx, this.#prepared.logKey, [{
1292
+ actor: principal,
1293
+ action: "key.intent",
1294
+ decision: "allow",
1295
+ operationId: call.input.operationId ?? null,
1296
+ requestId: call.input.requestId ?? null,
1297
+ metadata: JSON.stringify({
1298
+ intent: intentId,
1299
+ operation: action,
1300
+ expiresAt: at + this.#prepared.options.keyBudgetMs + 2 * LOCK_TIMEOUT_MS,
1301
+ ...call.input.purpose === void 0 ? {} : { purpose: call.input.purpose },
1302
+ keys: secrets.map((secret, item) => ({
1303
+ item,
1304
+ subject: secret.path,
1305
+ secretId: secret.secretId,
1306
+ version: secret.version
1307
+ }))
1308
+ })
1309
+ }])).seqStart };
1310
+ });
1311
+ if (!intent.ok) return intent;
1312
+ intentSeq = intent.seq;
1313
+ }
1314
+ return this.#decide([principal], async (d) => {
1315
+ try {
1316
+ await check(d);
1317
+ } catch (error) {
1318
+ if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1319
+ throw error;
1320
+ }
1321
+ const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
1322
+ const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
1323
+ try {
1324
+ const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
1325
+ const fault = outcomes.find((outcome) => !outcome.ok && outcome.code === "key_error");
1326
+ if (fault !== void 0 && !fault.ok) throw new Outage(fault.error, entries());
1327
+ const unanswered = outcomes.filter((outcome) => !outcome.ok && outcome.code !== "bad_claim").length;
1328
+ if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
1329
+ if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1330
+ if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1331
+ const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
1332
+ d.log.push(...released);
1333
+ const answer = result(done);
1334
+ if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
1335
+ return answer;
1336
+ } finally {
1337
+ for (const value of done) value.wipe();
1338
+ }
1339
+ });
1340
+ }
1341
+ /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1342
+ async #overBulkLimit(d, principal, n) {
1343
+ const { count, windowMs } = this.#config.bulkLimit;
1344
+ return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
1345
+ }
1346
+ #isRootAdmin(principal) {
1347
+ return principal.startsWith("user:") && this.#config.rootAdmins.includes(principal.slice(5));
1348
+ }
1349
+ /**
1350
+ * Give a root admin a member row the first time anyone asks about them:
1351
+ * the app's sign-ins and sessions point at it, and their reads queue on
1352
+ * it like anyone's. Logged, and made in a transaction of its own, under
1353
+ * the log's lock: a decision that held the head and then waited for a
1354
+ * member row would take the locks out of order. The row never makes
1355
+ * anyone a root admin; the configuration does.
1356
+ */
1357
+ async #rootRow(principal) {
1358
+ if (this.#prepared.rooted.has(principal)) return;
1359
+ await this.#db.transaction(async (tx) => {
1360
+ await lockLogHead(tx);
1361
+ if (await member(tx, principal) !== void 0) return;
1362
+ const { occurredAt: at, seqStart } = await appendEntries(tx, this.#prepared.logKey, [{
1363
+ actor: VAULT_ACTOR,
1364
+ action: "member.add",
1365
+ decision: "allow",
1366
+ subjectPrincipal: principal,
1367
+ metadata: JSON.stringify({
1368
+ owner: false,
1369
+ rootAdmin: true
1370
+ })
1371
+ }]);
1372
+ const row = {
1373
+ principal,
1374
+ status: "active",
1375
+ owner: false,
1376
+ generation: 0,
1377
+ createdAt: at,
1378
+ createdBy: VAULT_ACTOR,
1379
+ statusChangedAt: at,
1380
+ statusChangedBy: VAULT_ACTOR,
1381
+ accessSeq: seqStart
1382
+ };
1383
+ await insertMember(tx, {
1384
+ ...row,
1385
+ mac: memberMac(this.#prepared.rowKey, row, [])
1386
+ });
1387
+ });
1388
+ this.#prepared.rooted.add(principal);
1389
+ }
1390
+ /**
1391
+ * What `principal` holds, their row checked first (`#integrity`): a row
1392
+ * that fails holds nothing, and is `tampered`. A root admin's come from the
1393
+ * configuration, which no row can change.
1394
+ */
1395
+ async #standing(db, principal, row, at, reports) {
1396
+ const none = {
1397
+ isRootAdmin: false,
1398
+ isOwner: false,
1399
+ grants: []
1400
+ };
1401
+ if (this.#isRootAdmin(principal)) {
1402
+ const root = {
1403
+ isRootAdmin: true,
1404
+ isOwner: true,
1405
+ grants: []
1406
+ };
1407
+ return {
1408
+ principal,
1409
+ status: "active",
1410
+ live: root,
1411
+ all: root,
1412
+ fault: null,
1413
+ stored: []
1414
+ };
1415
+ }
1416
+ const grants$1 = row === void 0 ? [] : await grants(db, principal);
1417
+ const fault = await this.#integrity(db, principal, row, grants$1, reports);
1418
+ if (fault !== null) return {
1419
+ principal,
1420
+ status: "tampered",
1421
+ live: none,
1422
+ all: none,
1423
+ fault,
1424
+ stored: grants$1
1425
+ };
1426
+ if (row?.status !== "active") return {
1427
+ principal,
1428
+ status: row?.status ?? "unknown",
1429
+ live: none,
1430
+ all: none,
1431
+ fault,
1432
+ stored: grants$1
1433
+ };
1434
+ const held = grants$1.map((grant) => ({
1435
+ ...grant,
1436
+ role: grant.role
1437
+ }));
1438
+ const isOwner = row.owner && principal.startsWith("user:");
1439
+ return {
1440
+ principal,
1441
+ status: "active",
1442
+ live: {
1443
+ isRootAdmin: false,
1444
+ isOwner,
1445
+ grants: held.filter((grant) => live(grant, at))
1446
+ },
1447
+ all: {
1448
+ isRootAdmin: false,
1449
+ isOwner,
1450
+ grants: held
1451
+ },
1452
+ fault,
1453
+ stored: grants$1
1454
+ };
1455
+ }
1456
+ #access(principal, row, held, at, tampered) {
1457
+ if (this.#isRootAdmin(principal)) return {
1458
+ principal,
1459
+ status: "active",
1460
+ generation: row?.generation ?? 0,
1461
+ isRootAdmin: true,
1462
+ isOwner: true,
1463
+ grants: [],
1464
+ since: null,
1465
+ by: null
1466
+ };
1467
+ const active = !tampered && row?.status === "active";
1468
+ return {
1469
+ principal,
1470
+ status: tampered ? "tampered" : row?.status ?? "unknown",
1471
+ generation: row?.generation ?? 0,
1472
+ isRootAdmin: false,
1473
+ isOwner: active && row.owner && principal.startsWith("user:"),
1474
+ grants: active ? held.filter((grant) => live(grant, at)).map(view) : [],
1475
+ since: row ? iso(row.statusChangedAt) : null,
1476
+ by: row?.statusChangedBy ?? null
1477
+ };
1478
+ }
1479
+ /**
1480
+ * What `principal` holds now. Read without locks, the fast way; a row that
1481
+ * seems to fail its check is read again in one snapshot before anyone is
1482
+ * called tampered, since a change committed between two of the reads
1483
+ * looks like one.
1484
+ */
1485
+ async access(principal) {
1486
+ if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
1487
+ const read = async (db, reports) => {
1488
+ const [row, held, at] = await Promise.all([
1489
+ member(db, principal),
1490
+ grants(db, principal),
1491
+ this.#now(db)
1492
+ ]);
1493
+ const fault = this.#isRootAdmin(principal) ? null : await this.#integrity(db, principal, row, held, reports);
1494
+ return this.#access(principal, row, held, at, fault !== null);
1495
+ };
1496
+ const found = [];
1497
+ const quick = await read(this.#db, found).catch((error) => {
1498
+ if (error instanceof Retry) return null;
1499
+ throw error;
1500
+ });
1501
+ if (quick !== null && quick.status !== "tampered") {
1502
+ await this.#record(found);
1503
+ return quick;
1504
+ }
1505
+ const reports = [];
1506
+ const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
1507
+ await this.#record(reports);
1508
+ return access;
1509
+ }
1510
+ /**
1511
+ * Every member's row checked as `access` checks it, each finding
1512
+ * reported: their newest access entries read in one query, and the slow
1513
+ * way only for a row that does not match. Lists of members read the rows
1514
+ * without the vault, so this is what finds a row changed around it before
1515
+ * its member next asks for anything. The checkpoint runs it in one
1516
+ * snapshot: a change committed between two of its reads, such as a lapsed
1517
+ * grant cleared, would otherwise pair a row with grants it was never
1518
+ * sealed over.
1519
+ */
1520
+ async #sweep(db, reports) {
1521
+ const rows = await allMembers(db);
1522
+ const held = await grants(db);
1523
+ const newest = await newestAccessEntries(db);
1524
+ for (const row of rows) {
1525
+ if (this.#isRootAdmin(row.principal)) continue;
1526
+ const grants = held.filter((grant) => grant.principal === row.principal);
1527
+ const entry = newest.get(row.principal);
1528
+ if (!(entry !== void 0 && this.#authentic(entry) && sealed(this.#prepared.rowKey, row, grants) && entry.seq === row.accessSeq)) await this.#integrity(db, row.principal, row, grants, reports);
1529
+ }
1530
+ }
1531
+ setAccess(input) {
1532
+ const { actor, principal } = input;
1533
+ const action = input.changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
1534
+ const [only] = input.changes.length === 1 ? input.changes : [];
1535
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
1536
+ ...accessEntry(actor, action, principal, "deny", input, { changes: input.changes }, code),
1537
+ ...only === void 0 || code === "invalid" ? {} : {
1538
+ projectId: only.projectId,
1539
+ environmentId: only.environmentId
1540
+ }
1541
+ }]);
1542
+ return this.#decide([actor, principal], async (d) => {
1543
+ validateCorrelation(input);
1544
+ if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
1545
+ if (this.#isRootAdmin(principal)) throw refused("root_admin");
1546
+ const places$1 = /* @__PURE__ */ new Set();
1547
+ for (const change of input.changes) {
1548
+ const key = `${change.projectId}/${change.environmentId ?? ""}`;
1549
+ if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
1550
+ places$1.add(key);
1551
+ if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
1552
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
1553
+ const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1554
+ if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
1555
+ }
1556
+ const known = await places(d.tx, input.changes.map((change) => change.projectId), input.changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
1557
+ for (const { projectId, environmentId } of input.changes) if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
1558
+ const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1559
+ if (acting.status === "tampered") throw refused("tampered");
1560
+ if (!input.changes.every((change) => mayManageAccess(acting.live, principal, change))) throw refused("not_allowed");
1561
+ const row = d.members.get(principal);
1562
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1563
+ if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
1564
+ if (row?.status === "removed") throw refused("removed");
1565
+ if (row === void 0) {
1566
+ if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) throw refused("not_a_member");
1567
+ d.log.push(accessEntry(actor, "member.add", principal, "allow", input, { owner: false }));
1568
+ d.writes.push(async (at) => {
1569
+ await insertMember(d.tx, {
1570
+ principal,
1571
+ status: "active",
1572
+ owner: false,
1573
+ generation: 0,
1574
+ createdAt: at,
1575
+ createdBy: actor,
1576
+ statusChangedAt: at,
1577
+ statusChangedBy: actor,
1578
+ ...UNSEALED
1579
+ });
1580
+ });
1581
+ }
1582
+ const held = subject.stored;
1583
+ d.grants.set(principal, [...held]);
1584
+ const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
1585
+ if (d.writes.length > 0) d.touched.add(principal);
1586
+ return { changes };
1587
+ });
1588
+ }
1589
+ /** One place's change, logged when it changes what is live. */
1590
+ #apply(d, actor, principal, held, change, correlation) {
1591
+ const existing = held.find((grant) => grant.projectId === change.projectId && grant.environmentId === change.environmentId);
1592
+ const current = existing !== void 0 && live(existing, d.at) ? existing : void 0;
1593
+ const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1594
+ const place = {
1595
+ projectId: change.projectId,
1596
+ environmentId: change.environmentId
1597
+ };
1598
+ const entry = (action, role) => d.log.push({
1599
+ ...accessEntry(actor, action, principal, "allow", correlation, {
1600
+ role,
1601
+ expiresAt: expiresAt === null ? null : iso(expiresAt),
1602
+ previousRole: current?.role ?? null
1603
+ }),
1604
+ ...place
1605
+ });
1606
+ const clear = () => {
1607
+ if (existing === void 0) return;
1608
+ d.writes.push(async () => {
1609
+ await deleteGrant(d.tx, principal, place);
1610
+ d.grants.set(principal, d.grants.get(principal).filter((grant) => grant !== existing));
1611
+ });
1612
+ };
1613
+ if (change.role === null) {
1614
+ clear();
1615
+ if (current === void 0) return "unchanged";
1616
+ entry("access.revoke", null);
1617
+ return "revoked";
1618
+ }
1619
+ if (current !== void 0 && current.role === change.role && current.expiresAt === expiresAt) return "unchanged";
1620
+ const role = change.role;
1621
+ clear();
1622
+ d.writes.push(async (at) => {
1623
+ const grant = {
1624
+ principal,
1625
+ ...place,
1626
+ role,
1627
+ expiresAt,
1628
+ grantedAt: at,
1629
+ grantedBy: actor
1630
+ };
1631
+ await insertGrant(d.tx, grant);
1632
+ d.grants.get(principal).push(grant);
1633
+ });
1634
+ entry("access.grant", role);
1635
+ return current === void 0 ? "created" : "updated";
1636
+ }
1637
+ admit(input) {
1638
+ const { actor, principal } = input;
1639
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, { owner: input.owner ?? null }, code)]);
1640
+ return this.#decide([actor, principal], async (d) => {
1641
+ validateCorrelation(input);
1642
+ const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1643
+ if (acting.status === "tampered") throw refused("tampered");
1644
+ if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
1645
+ if (!PRINCIPAL.test(principal) || isSyncPrincipal(principal)) throw refused("invalid", `not a member: ${principal}`);
1646
+ if (this.#isRootAdmin(principal)) throw refused("root_admin");
1647
+ if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
1648
+ const row = d.members.get(principal);
1649
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1650
+ if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
1651
+ d.touched.add(principal);
1652
+ d.grants.set(principal, [...subject.stored]);
1653
+ const entry = (action, owner) => d.log.push(accessEntry(actor, action, principal, "allow", input, { owner }));
1654
+ if (row === void 0 || row.status === "removed") {
1655
+ const owner = input.owner ?? false;
1656
+ entry(row === void 0 ? "member.add" : "member.restore", owner);
1657
+ d.writes.push(async (at) => {
1658
+ if (row === void 0) await insertMember(d.tx, {
1659
+ principal,
1660
+ status: "active",
1661
+ owner,
1662
+ generation: 0,
1663
+ createdAt: at,
1664
+ createdBy: actor,
1665
+ statusChangedAt: at,
1666
+ statusChangedBy: actor,
1667
+ ...UNSEALED
1668
+ });
1669
+ else await updateMember(d.tx, principal, {
1670
+ status: "active",
1671
+ owner,
1672
+ statusChangedAt: at,
1673
+ statusChangedBy: actor
1674
+ });
1675
+ });
1676
+ return {
1677
+ created: true,
1678
+ owner,
1679
+ generation: row?.generation ?? 0
1680
+ };
1681
+ }
1682
+ const owner = input.owner ?? row.owner;
1683
+ if (owner !== row.owner) {
1684
+ entry("member.owner", owner);
1685
+ d.writes.push(() => updateMember(d.tx, principal, { owner }));
1686
+ }
1687
+ return {
1688
+ created: false,
1689
+ owner,
1690
+ generation: row.generation
1691
+ };
1692
+ });
1693
+ }
1694
+ remove(input) {
1695
+ const { actor, principal } = input;
1696
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
1697
+ return this.#decide([actor, principal], async (d) => {
1698
+ validateCorrelation(input);
1699
+ if (this.#isRootAdmin(principal)) throw refused("root_admin");
1700
+ const row = d.members.get(principal);
1701
+ const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1702
+ if (acting.status === "tampered") throw refused("tampered");
1703
+ const holder = acting.live;
1704
+ const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1705
+ const held = subject.stored;
1706
+ if (subject.status === "tampered") {
1707
+ if (!holder.isOwner) throw refused("not_allowed", "only owners may remove a member whose record failed its check");
1708
+ return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
1709
+ }
1710
+ const places = input.source === void 0 ? held : [...held, input.source];
1711
+ if (!(holder.isOwner || isSyncPrincipal(principal) && (held.length === 0 || places.some((place) => mayManageAccess(holder, principal, {
1712
+ ...place,
1713
+ role: null
1714
+ }))))) throw refused("not_allowed", "only owners may remove members");
1715
+ if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
1716
+ const revoked = held.filter((grant) => live(grant, d.at));
1717
+ for (const grant of revoked) d.log.push({
1718
+ ...accessEntry(actor, "access.revoke", principal, "allow", input, {
1719
+ role: null,
1720
+ expiresAt: null,
1721
+ previousRole: grant.role
1722
+ }),
1723
+ projectId: grant.projectId,
1724
+ environmentId: grant.environmentId
1725
+ });
1726
+ const generation = row.generation + 1;
1727
+ d.log.push(accessEntry(actor, "member.remove", principal, "allow", input, {
1728
+ revoked: revoked.length,
1729
+ generation
1730
+ }));
1731
+ d.touched.add(principal);
1732
+ d.writes.push(async (at) => {
1733
+ await deleteGrants(d.tx, principal);
1734
+ d.grants.set(principal, []);
1735
+ await updateMember(d.tx, principal, {
1736
+ status: "removed",
1737
+ owner: false,
1738
+ generation,
1739
+ statusChangedAt: at,
1740
+ statusChangedBy: actor
1741
+ });
1742
+ });
1743
+ return {
1744
+ revoked: revoked.map(view),
1745
+ generation
1746
+ };
1747
+ });
1748
+ }
1749
+ /**
1750
+ * Remove a member whose row failed its check, from what the log says of
1751
+ * them rather than what the row does: their grants go, whatever they were,
1752
+ * and their generation moves past both the row's and the log's, so no
1753
+ * session or token from any earlier membership comes back with a row put
1754
+ * back. Admitted again, they start from nothing, as any removed member.
1755
+ */
1756
+ async #startOver(d, actor, principal, row, fault, correlation, refused) {
1757
+ const logged = await this.#logged(d.tx, principal);
1758
+ if (logged === void 0) throw refused("not_a_member", "the log never admitted them: their row was written around the vault");
1759
+ const generation = Math.max(row?.generation ?? 0, logged.generation) + 1;
1760
+ d.log.push(accessEntry(actor, "member.remove", principal, "allow", correlation, {
1761
+ revoked: 0,
1762
+ generation,
1763
+ tampered: fault
1764
+ }));
1765
+ d.touched.add(principal);
1766
+ d.writes.push(async (at) => {
1767
+ await deleteGrants(d.tx, principal);
1768
+ d.grants.set(principal, []);
1769
+ const fresh = {
1770
+ status: "removed",
1771
+ owner: false,
1772
+ generation,
1773
+ createdAt: logged.createdAt,
1774
+ createdBy: logged.createdBy,
1775
+ statusChangedAt: at,
1776
+ statusChangedBy: actor
1777
+ };
1778
+ if (row === void 0) await insertMember(d.tx, {
1779
+ principal,
1780
+ ...fresh,
1781
+ ...UNSEALED
1782
+ });
1783
+ else await updateMember(d.tx, principal, fresh);
1784
+ });
1785
+ return {
1786
+ revoked: [],
1787
+ generation
1788
+ };
1789
+ }
1790
+ /** `principal` as the log says they are: their authenticated access entries, replayed. */
1791
+ async #logged(db, principal) {
1792
+ const state = {
1793
+ members: /* @__PURE__ */ new Map(),
1794
+ held: /* @__PURE__ */ new Map()
1795
+ };
1796
+ const entries = (await accessEntriesAbout(db, principal, 1e5)).filter((entry) => this.#authentic(entry));
1797
+ for (const entry of entries.reverse()) apply(state, entry);
1798
+ return state.members.get(principal);
1799
+ }
1800
+ /**
1801
+ * Null when every KEK the vault is given opens what it wrapped; otherwise
1802
+ * why not, naming the KEK, never its key. Decided once per process, before
1803
+ * its first key operation or checkpoint. A key service that cannot answer
1804
+ * leaves it undecided: the error is thrown, and the next call asks again.
1805
+ */
1806
+ #kekMismatch() {
1807
+ const prepared = this.#prepared;
1808
+ if (prepared.kekCheck === null) {
1809
+ const check = this.#checkKeks();
1810
+ prepared.kekCheck = check;
1811
+ check.then((wrong) => void (prepared.wrongKek = wrong), () => {
1812
+ if (prepared.kekCheck === check) prepared.kekCheck = null;
1813
+ });
1814
+ }
1815
+ return prepared.kekCheck;
1816
+ }
1817
+ /**
1818
+ * Each KEK opens its check value, or, with none recorded yet (a fresh
1819
+ * database, a new KEK, data from before checks), opens one of the newest
1820
+ * keys it wrapped, if there are any; then its check value is recorded.
1821
+ * A KEK that opens neither is not the one that wrapped the data.
1822
+ */
1823
+ async #checkKeks() {
1824
+ const checks = /* @__PURE__ */ new Map();
1825
+ for (const entry of await vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1826
+ if (!this.#authentic(entry)) continue;
1827
+ const wrapped = JSON.parse(entry.metadata);
1828
+ checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
1829
+ }
1830
+ const budget = this.#prepared.options.keyBudgetMs;
1831
+ for (const kek of this.#config.keks.all) {
1832
+ const operation = {
1833
+ deadline: Date.now() + budget,
1834
+ signal: AbortSignal.timeout(budget)
1835
+ };
1836
+ const opens = async (wrapped, context, expected) => {
1837
+ try {
1838
+ const key = await kek.unwrap(wrapped, context, operation);
1839
+ const right = expected === void 0 || key.length === expected.length && timingSafeEqual(key, expected);
1840
+ key.fill(0);
1841
+ return right;
1842
+ } catch (error) {
1843
+ if (error instanceof KekBadClaimError) return false;
1844
+ throw error;
1845
+ }
1846
+ };
1847
+ const mismatch = `this vault's ${kek.provider} KEK ${kek.keyId} does not open the data it holds: it is not the key that wrapped it`;
1848
+ const check = checks.get(`${kek.provider}:${kek.keyId}`);
1849
+ if (check !== void 0) {
1850
+ if (!await opens(check, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE)) return mismatch;
1851
+ continue;
1852
+ }
1853
+ const samples = await wrappedUnder(this.#db, kek.provider, kek.keyId, KEY_CHECK_SAMPLE);
1854
+ let proof = null;
1855
+ for (const { projectId, environmentId, secretId, version, ...wrapped } of samples) if (await opens(wrapped, {
1856
+ projectId,
1857
+ environmentId,
1858
+ secretId
1859
+ })) {
1860
+ proof = {
1861
+ secretId,
1862
+ version
1863
+ };
1864
+ break;
1865
+ }
1866
+ if (samples.length > 0 && proof === null) return mismatch;
1867
+ const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
1868
+ await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, [{
1869
+ actor: VAULT_ACTOR,
1870
+ action: KEY_CHECK,
1871
+ decision: "allow",
1872
+ metadata: JSON.stringify({
1873
+ ...serialisable(wrapped),
1874
+ proof
1875
+ })
1876
+ }]));
1877
+ }
1878
+ return null;
1879
+ }
1880
+ /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
1881
+ async #latest(db) {
1882
+ const row = await latestVaultEntry(db, [CHECKPOINT]);
1883
+ return row === void 0 ? null : {
1884
+ checkpoint: JSON.parse(row.metadata),
1885
+ seq: row.seq
1886
+ };
1887
+ }
1888
+ async checkpoint() {
1889
+ const { wrongKek } = this.#prepared;
1890
+ const found = [];
1891
+ const whole = await this.#db.transaction(async (tx) => {
1892
+ await this.#sweep(tx, found);
1893
+ return verifyChain(tx, this.#prepared.logKey, [], UNVERIFIED);
1894
+ }, SNAPSHOT);
1895
+ return this.#decide([], async (d) => {
1896
+ for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
1897
+ const head = await lockLogHead(d.tx);
1898
+ const latest = await this.#latest(d.tx);
1899
+ const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
1900
+ actor: SCHEDULER,
1901
+ action: CHECKPOINT,
1902
+ decision: "deny",
1903
+ code,
1904
+ metadata: JSON.stringify(detail)
1905
+ }]);
1906
+ if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
1907
+ if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
1908
+ if (!whole.verification.ok) throw refused("log_broken", {
1909
+ failedAtSeq: whole.verification.failedAtSeq,
1910
+ reason: whole.verification.reason
1911
+ });
1912
+ if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
1913
+ if (latest !== null && !await carries(d.tx, latest.checkpoint)) throw refused("log_broken", { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
1914
+ const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKey, [], whole.anchor);
1915
+ if (!held.ok) throw refused("log_broken", {
1916
+ failedAtSeq: held.failedAtSeq,
1917
+ reason: held.reason
1918
+ });
1919
+ if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
1920
+ const signed = {
1921
+ seq: Number(verified.nextSeq - 1n),
1922
+ hash: verified.hash.toString("hex"),
1923
+ signedAt: iso(d.at)
1924
+ };
1925
+ const checkpoint = {
1926
+ ...signed,
1927
+ keyId: this.#prepared.signer.keyId,
1928
+ signature: await this.#prepared.signer.sign(checkpointMessage(signed))
1929
+ };
1930
+ d.log.push({
1931
+ actor: SCHEDULER,
1932
+ action: CHECKPOINT,
1933
+ decision: "allow",
1934
+ metadata: JSON.stringify(checkpoint)
1935
+ });
1936
+ return { checkpoint };
1937
+ });
1938
+ }
1939
+ async about() {
1940
+ return {
1941
+ publicKey: this.#prepared.signer.publicKey,
1942
+ rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`)
1943
+ };
1944
+ }
1945
+ verifyLog(input) {
1946
+ return this.#verifyAll([], input.upTo ?? null);
1947
+ }
1948
+ /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
1949
+ async #verify(db, shown, anchor) {
1950
+ const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKey, shown, anchor);
1951
+ if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
1952
+ return verification;
1953
+ }
1954
+ /**
1955
+ * `shown`, and the chain from its first entry, in one snapshot: every
1956
+ * link and hash, the vault's MACs; then the heads that must still be
1957
+ * there: the one the app verified up to (`upTo`), so both authors are
1958
+ * checked over the same entries, the one the app last recorded from a
1959
+ * checkpoint (`through`), and the last checkpoint's; and the members and
1960
+ * grants replayed from it.
1961
+ */
1962
+ #verifyAll(shown, upTo) {
1963
+ return this.#db.transaction(async (tx) => {
1964
+ const remembered = this.#prepared.verified;
1965
+ const verification = await this.#verify(tx, shown, UNVERIFIED);
1966
+ if (!verification.ok) return verification;
1967
+ if (upTo !== null && !await carries(tx, upTo)) return {
1968
+ ok: false,
1969
+ failedAtSeq: upTo.seq,
1970
+ reason: "not the entry the app verified up to: the log changed between the two checks"
1971
+ };
1972
+ const unsigned = await this.#checkpointFault(tx);
1973
+ if (unsigned !== null) return unsigned;
1974
+ if (remembered.nextSeq > 0n) {
1975
+ const seq = remembered.nextSeq - 1n;
1976
+ if (!await carries(tx, {
1977
+ seq: Number(seq),
1978
+ hash: remembered.hash.toString("hex")
1979
+ })) return {
1980
+ ok: false,
1981
+ failedAtSeq: Number(seq),
1982
+ reason: "changed since the vault last verified it"
1983
+ };
1984
+ }
1985
+ const at = await this.#now(tx);
1986
+ const accounting = await verifyAccounting(tx, at);
1987
+ if (!accounting.ok) return accounting;
1988
+ const fault = await this.#unsealed(tx) ?? await replay(tx, at);
1989
+ if (fault !== null) return {
1990
+ ok: false,
1991
+ failedAtSeq: null,
1992
+ reason: describeAccessFault(fault),
1993
+ fault
1994
+ };
1995
+ return accounting.pending === 0 ? verification : {
1996
+ ...verification,
1997
+ pending: accounting.pending
1998
+ };
1999
+ }, SNAPSHOT);
2000
+ }
2001
+ /**
2002
+ * Every checkpoint, not only the newest: each signed by the vault's key,
2003
+ * over a prefix the log still holds, entry for entry. The chain is
2004
+ * verified by now, so each checkpoint entry is the vault's.
2005
+ */
2006
+ async #checkpointFault(db) {
2007
+ for (let after = -1n;;) {
2008
+ const batch = await vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
2009
+ for (const entry of batch) {
2010
+ const checkpoint = JSON.parse(entry.metadata);
2011
+ const broken = (reason) => ({
2012
+ ok: false,
2013
+ failedAtSeq: Number(entry.seq),
2014
+ reason
2015
+ });
2016
+ if (!await verifyCheckpoint(checkpoint, this.#prepared.signer.publicKey)) return broken("a checkpoint the vault did not sign");
2017
+ if (BigInt(checkpoint.seq) >= entry.seq || !await carries(db, checkpoint)) return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
2018
+ }
2019
+ if (batch.length < 1e3) return null;
2020
+ after = batch[batch.length - 1].seq;
2021
+ }
2022
+ }
2023
+ /**
2024
+ * The first member whose row fails its MAC, or names an older access
2025
+ * entry than the log's newest about them. The chain is verified by now,
2026
+ * so every entry read here carries the vault's MAC.
2027
+ */
2028
+ async #unsealed(db) {
2029
+ const [rows, held, newest] = await Promise.all([
2030
+ allMembers(db),
2031
+ grants(db),
2032
+ newestAccessEntries(db)
2033
+ ]);
2034
+ for (const row of rows) {
2035
+ const grants = held.filter((grant) => grant.principal === row.principal);
2036
+ if (!sealed(this.#prepared.rowKey, row, grants)) return {
2037
+ kind: "tampered-member",
2038
+ principal: row.principal,
2039
+ why: "mac"
2040
+ };
2041
+ if (newest.get(row.principal)?.seq !== row.accessSeq) return {
2042
+ kind: "tampered-member",
2043
+ principal: row.principal,
2044
+ why: "stale"
2045
+ };
2046
+ }
2047
+ return null;
2048
+ }
2049
+ };
2050
+ /** Settle every operation before releasing the member lock, including cancelled requests. */
2051
+ async function settle(operations, budgetMs) {
2052
+ const controller = new AbortController();
2053
+ const operation = {
2054
+ deadline: Date.now() + budgetMs,
2055
+ signal: controller.signal
2056
+ };
2057
+ const timer = setTimeout(() => controller.abort(), budgetMs);
2058
+ try {
2059
+ return {
2060
+ outcomes: await Promise.all(operations.map(async (work) => {
2061
+ try {
2062
+ if (operation.signal.aborted || Date.now() >= operation.deadline) throw new KekCancelledError();
2063
+ const value = await work(operation);
2064
+ return value === null ? {
2065
+ ok: false,
2066
+ code: "bad_claim"
2067
+ } : {
2068
+ ok: true,
2069
+ value
2070
+ };
2071
+ } catch (error) {
2072
+ if (error instanceof KekUnavailableError) return {
2073
+ ok: false,
2074
+ code: error.uncertain ? "kms_uncertain" : error instanceof KekCancelledError ? "cancelled" : "kms_unavailable"
2075
+ };
2076
+ return {
2077
+ ok: false,
2078
+ code: "key_error",
2079
+ error
2080
+ };
2081
+ }
2082
+ })),
2083
+ expired: controller.signal.aborted || Date.now() >= operation.deadline
2084
+ };
2085
+ } finally {
2086
+ clearTimeout(timer);
2087
+ }
2088
+ }
2089
+ function validateText(value) {
2090
+ if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
2091
+ }
2092
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2093
+ /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
2094
+ function validateCorrelation(input) {
2095
+ if (input.requestId != null) validateText(input.requestId);
2096
+ if (input.operationId != null && (typeof input.operationId !== "string" || !UUID.test(input.operationId))) throw new Error(`operationId must be a lowercase UUID, got: ${String(input.operationId)}`);
2097
+ }
2098
+ /** Validate the entire batch before any provider sees a key. */
2099
+ function validateItems(items, field) {
2100
+ for (const { secret, key, wrapped } of items) {
2101
+ if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
2102
+ if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
2103
+ checkContext(context(secret));
2104
+ if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
2105
+ if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
2106
+ if (key !== void 0) {
2107
+ const dek = Buffer.from(key, "base64");
2108
+ try {
2109
+ if (dek.length !== DEK_BYTES) throw new Error(`DEK must be ${DEK_BYTES} bytes, got ${dek.length}`);
2110
+ if (base64(dek) !== key) throw new Error("DEK must be canonical base64");
2111
+ } finally {
2112
+ dek.fill(0);
2113
+ }
2114
+ }
2115
+ if (wrapped !== void 0) {
2116
+ for (const field of [
2117
+ wrapped.kekProvider,
2118
+ wrapped.kekId,
2119
+ wrapped.kekVersion
2120
+ ]) if (typeof field !== "string" || field.length === 0 || /[\uD800-\uDFFF]/u.test(field)) throw new Error("wrapped key metadata must be nonempty well-formed strings");
2121
+ const bytes = Buffer.from(wrapped.bytes, "base64");
2122
+ if (bytes.length === 0 || base64(bytes) !== wrapped.bytes) throw new Error("wrapped key must be nonempty canonical base64");
2123
+ }
2124
+ }
2125
+ }
2126
+ /** Why `reader` may not do `permission` on `secret`, or null if they may. */
2127
+ function refuses(reader, permission, secret) {
2128
+ if (reader.status === "tampered") return "tampered";
2129
+ if (reader.status === "removed") return "removed";
2130
+ if (reader.status === "unknown") return "not_a_member";
2131
+ const where = {
2132
+ projectId: secret.projectId,
2133
+ environmentId: secret.environmentId
2134
+ };
2135
+ if (allows(reader.live, permission, where)) return null;
2136
+ return allows(reader.all, permission, where) ? "expired" : "no_grant";
2137
+ }
2138
+ /**
2139
+ * An entry about a key. A new secret's row is not committed when its key is
2140
+ * wrapped, so a wrap names the secret in its payload; the others name it.
2141
+ */
2142
+ function keyEntry(action, principal, secret, decision, code, correlation, detail = {}) {
2143
+ return {
2144
+ actor: principal,
2145
+ action,
2146
+ decision,
2147
+ code,
2148
+ projectId: secret.projectId,
2149
+ environmentId: secret.environmentId,
2150
+ secretId: action === "key.wrap" ? null : secret.secretId,
2151
+ operationId: correlation.operationId ?? null,
2152
+ requestId: correlation.requestId ?? null,
2153
+ metadata: JSON.stringify({
2154
+ subject: secret.path,
2155
+ ...action === "key.wrap" ? { secretId: secret.secretId } : {},
2156
+ version: secret.version,
2157
+ ...detail
2158
+ })
2159
+ };
2160
+ }
2161
+ /** Whether `entry` changes a member's access: what their row's `access_seq` names. */
2162
+ function isAccessEntry(entry) {
2163
+ return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
2164
+ }
2165
+ /** An entry about a member's access. */
2166
+ function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
2167
+ return {
2168
+ actor,
2169
+ action,
2170
+ decision,
2171
+ code,
2172
+ subjectPrincipal: PRINCIPAL.test(principal) ? principal : null,
2173
+ operationId: correlation.operationId ?? null,
2174
+ requestId: correlation.requestId ?? null,
2175
+ metadata: JSON.stringify(PRINCIPAL.test(principal) ? detail : {
2176
+ subject: principal,
2177
+ ...detail
2178
+ })
2179
+ };
2180
+ }
2181
+ function live(grant, at) {
2182
+ return grant.expiresAt === null || grant.expiresAt > at;
2183
+ }
2184
+ function view(grant) {
2185
+ return {
2186
+ projectId: grant.projectId,
2187
+ environmentId: grant.environmentId,
2188
+ role: grant.role,
2189
+ expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
2190
+ grantedAt: iso(grant.grantedAt),
2191
+ grantedBy: grant.grantedBy
2192
+ };
2193
+ }
2194
+ function iso(ms) {
2195
+ return new Date(ms).toISOString();
2196
+ }
2197
+ function context(secret) {
2198
+ return {
2199
+ projectId: secret.projectId,
2200
+ environmentId: secret.environmentId,
2201
+ secretId: secret.secretId
2202
+ };
2203
+ }
2204
+ function unwrappable(wrapped) {
2205
+ return {
2206
+ ...wrapped,
2207
+ bytes: Buffer.from(wrapped.bytes, "base64")
2208
+ };
2209
+ }
2210
+ function serialisable(wrapped) {
2211
+ return {
2212
+ ...wrapped,
2213
+ bytes: base64(wrapped.bytes)
2214
+ };
2215
+ }
2216
+ /**
2217
+ * Bytes as base64. Through `Buffer.from`, a view of the same memory, because
2218
+ * Workers' types declare their own `Buffer` and a bare one loses its
2219
+ * `toString(encoding)` to them.
2220
+ */
2221
+ function base64(bytes) {
2222
+ return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
2223
+ }
2224
+ //#endregion
2225
+ export { prepareVault as n, resolveVaultConfig as r, openVault as t };