@coffre/vault 0.9.0 → 0.10.1

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.
@@ -1,155 +1,15 @@
1
- import { ACCESS_ACTIONS, HANDED_BACK, checkpointMessage, checkpointVerifier, describeAccessFault } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
- import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
- import { tablesOf } from "@coffre/db";
5
- import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
3
+ import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { prepared, tablesOf } from "@coffre/db";
5
+ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
8
  import { shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
9
+ import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, tombstone, truth } from "@coffre/db/dialect";
10
10
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LOOK_BACK, appendEntries, entryColumns, lockLogHead, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
- import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
- //#region src/accounting.ts
14
- const KEY_ACTIONS = /* @__PURE__ */ new Set([
15
- "secret.read",
16
- "key.wrap",
17
- "key.rewrap"
18
- ]);
19
- /**
20
- * Every key operation's intent and its outcomes, entry by entry, oldest
21
- * first, as the full check reads the vault's entries (`add`); then what is
22
- * still open (`result`). The chain can hold while a process died between an
23
- * intent and its outcomes: only a missed deadline is a fault.
24
- */
25
- var KeyAccounting = class {
26
- #intents = /* @__PURE__ */ new Map();
27
- #ids = /* @__PURE__ */ new Set();
28
- #fault = null;
29
- add(entry) {
30
- if (this.#fault !== null || entry.author !== "vault") return;
31
- const broken = (reason) => {
32
- this.#fault = {
33
- ok: false,
34
- failedAtSeq: Number(entry.seq),
35
- reason
36
- };
37
- };
38
- if (entry.action === "key.intent") {
39
- const parsed = parseIntent(entry);
40
- if (typeof parsed === "string") return broken(parsed);
41
- if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
42
- this.#ids.add(parsed.intentId);
43
- if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
44
- ...parsed,
45
- entry,
46
- seen: /* @__PURE__ */ new Set()
47
- });
48
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
49
- const parsed = parseOutcome(entry);
50
- if (typeof parsed === "string") return broken(parsed);
51
- const intent = this.#intents.get(parsed.relatedSeq);
52
- for (const outcome of parsed.items) {
53
- const key = intent?.keys[outcome.item];
54
- if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
55
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
56
- if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
57
- intent.seen.add(outcome.item);
58
- }
59
- if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
60
- }
61
- }
62
- /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
63
- result(at) {
64
- if (this.#fault !== null) return this.#fault;
65
- const intents = [...this.#intents.values()];
66
- const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
67
- if (overdue.length === 0) return {
68
- ok: true,
69
- pending: intents.length
70
- };
71
- const reason = overdue.map((intent) => {
72
- const missing = intent.keys.length - intent.seen.size;
73
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
74
- }).join("; ");
75
- return {
76
- ok: false,
77
- failedAtSeq: Number(overdue[0].entry.seq),
78
- reason
79
- };
80
- }
81
- };
82
- function parseIntent(entry) {
83
- const detail = payload(entry.metadata);
84
- if (detail === null) return "key intent has no valid accounting payload";
85
- const { intent, operation, expiresAt } = detail;
86
- if (typeof intent !== "string") return "key intent has no operation identity";
87
- if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
88
- if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
89
- if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
90
- const keys = [];
91
- for (const [index, value] of detail.keys.entries()) {
92
- const key = record(value);
93
- if (key === null || key.item !== index) return "key intent items are not numbered in order";
94
- const { secretId, version, subject } = key;
95
- if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
96
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
97
- keys.push({
98
- item: index,
99
- secretId,
100
- version,
101
- subject
102
- });
103
- }
104
- return {
105
- intentId: intent,
106
- operation,
107
- expiresAt,
108
- keys
109
- };
110
- }
111
- function parseOutcome(entry) {
112
- const detail = payload(entry.metadata);
113
- if (detail === null) return "key outcome has no valid accounting payload";
114
- const { intent } = detail;
115
- if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
116
- const many = detail.secrets;
117
- if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
118
- const listed = many === void 0 ? [{
119
- ...detail,
120
- secretId: entry.secretId ?? detail.secretId
121
- }] : many.map(record);
122
- const items = [];
123
- for (const one of listed) {
124
- if (one === null) return "key outcome has no valid item list";
125
- const { item, secretId, version, subject } = one;
126
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
127
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
128
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
129
- items.push({
130
- item,
131
- secretId,
132
- version,
133
- subject
134
- });
135
- }
136
- return {
137
- intentId: intent,
138
- relatedSeq: entry.relatedSeq,
139
- items
140
- };
141
- }
142
- function payload(text) {
143
- try {
144
- return record(JSON.parse(text));
145
- } catch {
146
- return null;
147
- }
148
- }
149
- function record(value) {
150
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
151
- }
152
- //#endregion
11
+ import { appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
12
+ import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
153
13
  //#region src/checkpoint.ts
154
14
  const PKCS8_ED25519 = [
155
15
  48,
@@ -193,10 +53,10 @@ async function boundLockWaits(tx, ms) {
193
53
  }
194
54
  /** The database's clock, now. */
195
55
  async function now(db) {
196
- const { auditChainHead } = tablesOf(db);
197
- const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
56
+ const [{ at }] = await clock(db);
198
57
  return at;
199
58
  }
59
+ const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
200
60
  function memberColumns(db) {
201
61
  const { vaultMembers } = tablesOf(db);
202
62
  return {
@@ -234,10 +94,13 @@ function storedChange(change) {
234
94
  };
235
95
  }
236
96
  async function member(db, principal) {
237
- const { vaultMembers } = tablesOf(db);
238
- const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
97
+ const [row] = await memberRow(db, { principal });
239
98
  return row === void 0 ? void 0 : memberOf(row);
240
99
  }
100
+ const memberRow = prepared((db) => {
101
+ const { vaultMembers } = tablesOf(db);
102
+ return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder("principal")));
103
+ });
241
104
  async function allMembers(db) {
242
105
  const { vaultMembers } = tablesOf(db);
243
106
  return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
@@ -366,11 +229,17 @@ const RELEASE = "secret.read";
366
229
  * several (`secrets`), a read logged once per place.
367
230
  */
368
231
  async function releasesSince(db, principal, after) {
369
- const { auditLog } = tablesOf(db);
370
- const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
371
- const [{ n }] = await db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
232
+ const [{ n }] = await released(db, {
233
+ principal,
234
+ after
235
+ });
372
236
  return Number(n ?? 0);
373
237
  }
238
+ const released = prepared((db) => {
239
+ const { auditLog } = tablesOf(db);
240
+ const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
241
+ return db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, sql.placeholder("principal")), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, sql.placeholder("after"))));
242
+ });
374
243
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
375
244
  function stored(rows) {
376
245
  return rows;
@@ -405,16 +274,6 @@ async function hashesAt(db, seqs) {
405
274
  }
406
275
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
407
276
  const HASHES_AT_ONCE = 5e3;
408
- /**
409
- * Whether every entry from the first through `seq` is there. Seqs are the
410
- * primary key, so the count is `seq + 1` exactly when none is missing: one
411
- * pass over the key's index, never the entries.
412
- */
413
- async function complete(db, seq) {
414
- const { auditLog } = tablesOf(db);
415
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
416
- return BigInt(n) === seq + 1n;
417
- }
418
277
  /** The entry at `seq`'s hash, or undefined when there is none. */
419
278
  async function hashAt(db, seq) {
420
279
  const { auditLog } = tablesOf(db);
@@ -456,6 +315,12 @@ async function endedReferences(db, seqs) {
456
315
  return new Set(rows.map((row) => row.relatedSeq));
457
316
  }
458
317
  /** The project each environment is in: a holder's ids, checked against each other. */
318
+ /** Whether a project of this id is there, and not deleted. */
319
+ async function projectLive(db, id) {
320
+ const { projects } = tablesOf(db);
321
+ const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
322
+ return rows.length > 0 && !rows[0].deleted;
323
+ }
459
324
  async function projectsOfEnvironments(db, ids) {
460
325
  if (ids.length === 0) return /* @__PURE__ */ new Map();
461
326
  const { environments } = tablesOf(db);
@@ -482,12 +347,92 @@ async function secretsByIds(db, ids) {
482
347
  path: `${project}/${environment}/${key}`
483
348
  }]));
484
349
  }
350
+ /**
351
+ * Each secret's nature, as its current version has it: plain where its
352
+ * value is stored as it is, sensitive where it is bound so, and otherwise
353
+ * secret, as is one with no value of its own.
354
+ */
355
+ async function natures(db, secretIds) {
356
+ if (secretIds.length === 0) return /* @__PURE__ */ new Map();
357
+ const { secrets, secretVersions } = tablesOf(db);
358
+ const rows = await db.select({
359
+ id: secrets.id,
360
+ sensitive: secretVersions.sensitive,
361
+ plain: truth(isNotNull(secretVersions.plaintext))
362
+ }).from(secrets).innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId)).where(inArray(secrets.id, [...new Set(secretIds)]));
363
+ return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
364
+ }
365
+ /**
366
+ * For each version a rewrap would bring back into its secret, its nature
367
+ * as stored when it is an old value, not the secret's current one; null
368
+ * for the current one, which a mark wraps again with no value changing.
369
+ */
370
+ async function restoredNatures(db, items) {
371
+ if (items.length === 0) return [];
372
+ const { secrets, secretVersions } = tablesOf(db);
373
+ const [current, versions] = await Promise.all([db.select({
374
+ id: secrets.id,
375
+ version: secrets.currentVersionId
376
+ }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
377
+ id: secretVersions.id,
378
+ sensitive: secretVersions.sensitive,
379
+ plain: truth(isNotNull(secretVersions.plaintext))
380
+ }).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
381
+ const currentOf = new Map(current.map((row) => [row.id, row.version]));
382
+ const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
383
+ return items.map(({ secretId, from }) => currentOf.get(secretId) === from ? null : natureOf.get(from) ?? "secret");
384
+ }
485
385
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
486
386
  async function latestVaultEntry(db, actions) {
487
387
  const { auditLog } = tablesOf(db);
488
388
  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);
489
389
  return row === void 0 ? void 0 : stored([row])[0];
490
390
  }
391
+ /**
392
+ * The vault's newest checkpoint that keeps a tally, allowed, or undefined:
393
+ * one written since 0.10.1, which names the last entry of the prefix it
394
+ * signs as related (`related_seq`); those before name none.
395
+ */
396
+ async function latestTallied(db) {
397
+ const { auditLog } = tablesOf(db);
398
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq))).orderBy(desc(auditLog.seq)).limit(1);
399
+ return row === void 0 ? void 0 : stored([row])[0];
400
+ }
401
+ /** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
402
+ async function newestCheckpointEntry(db) {
403
+ const { auditLog } = tablesOf(db);
404
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"))).orderBy(desc(auditLog.seq)).limit(1);
405
+ return row === void 0 ? void 0 : stored([row])[0];
406
+ }
407
+ /**
408
+ * The vault's checkpoints that keep a tally after entry `after` and through
409
+ * `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
410
+ * holds, but always one. A reader asks again after the last.
411
+ */
412
+ async function talliedWithin(db, after, through, limit) {
413
+ const { auditLog } = tablesOf(db);
414
+ const tallied = and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after), lte(auditLog.seq, through));
415
+ const batch = await withinBytes(db, tallied, limit);
416
+ if (batch === null) return [];
417
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
418
+ }
419
+ /** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
420
+ async function newestRefusal(db, action, code, after) {
421
+ const { auditLog } = tablesOf(db);
422
+ const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, "deny"), eq(auditLog.code, code))).orderBy(desc(auditLog.seq)).limit(1);
423
+ return row === void 0 ? void 0 : stored([row])[0];
424
+ }
425
+ /** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
426
+ async function talliedAfter(db, after, limit) {
427
+ const { auditLog } = tablesOf(db);
428
+ return (await db.select({
429
+ seq: auditLog.seq,
430
+ signs: auditLog.relatedSeq
431
+ }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after))).orderBy(asc(auditLog.seq)).limit(limit)).map(({ seq, signs }) => ({
432
+ seq,
433
+ signs
434
+ }));
435
+ }
491
436
  /** The vault's newest allowed access entry about each member who has one. */
492
437
  async function newestAccessEntries(db) {
493
438
  const { auditLog } = tablesOf(db);
@@ -500,6 +445,19 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
500
445
  const { auditLog } = tablesOf(db);
501
446
  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));
502
447
  }
448
+ /** Which of these secrets' newest versions are sensitive, by the versions' own order. */
449
+ async function newestSensitive(db, secretIds) {
450
+ if (secretIds.length === 0) return /* @__PURE__ */ new Set();
451
+ const { secretVersions } = tablesOf(db);
452
+ const rows = await db.select({ secretId: secretVersions.secretId }).from(secretVersions).where(and(inArray(secretVersions.secretId, [...new Set(secretIds)]), eq(secretVersions.sensitive, true), sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`));
453
+ return new Set(rows.map((row) => row.secretId));
454
+ }
455
+ /** How many grants name `role`: all of them, or those on environments. */
456
+ async function grantsNaming(db, role, filter = {}) {
457
+ const { vaultGrants } = tablesOf(db);
458
+ const rows = await db.select({ count: sql`count(*)` }).from(vaultGrants).where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : void 0));
459
+ return Number(rows[0]?.count ?? 0);
460
+ }
503
461
  /**
504
462
  * A version that still holds its wrapped key. A deleted place's versions are
505
463
  * erased, their keys emptied: there is nothing left to open, or to try a
@@ -526,7 +484,9 @@ async function versions(db, ids) {
526
484
  wrappedDek: secretVersions.wrappedDek,
527
485
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
528
486
  currentVersionId: secrets.currentVersionId,
529
- newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`
487
+ sensitive: secretVersions.sensitive,
488
+ newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
489
+ newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`
530
490
  }).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed$1(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))))).map((row) => ({
531
491
  id: row.id,
532
492
  deleted: row.deleted,
@@ -535,7 +495,8 @@ async function versions(db, ids) {
535
495
  environmentId: row.environmentId,
536
496
  secretId: row.secretId,
537
497
  version: row.version,
538
- path: `${row.project}/${row.environment}/${row.key}`
498
+ path: `${row.project}/${row.environment}/${row.key}`,
499
+ sensitive: row.sensitive
539
500
  },
540
501
  wrapped: {
541
502
  kekProvider: row.kekProvider,
@@ -543,7 +504,8 @@ async function versions(db, ids) {
543
504
  kekVersion: row.kekVersion,
544
505
  bytes: row.wrappedDek.toString("base64")
545
506
  },
546
- current: row.currentVersionId === row.id && Number(row.newest) === row.version
507
+ current: row.currentVersionId === row.id && Number(row.newest) === row.version,
508
+ newestSensitive: Boolean(row.newestSensitive)
547
509
  }));
548
510
  }
549
511
  //#endregion
@@ -563,86 +525,158 @@ async function versions(db, ids) {
563
525
  function vaultLogKey(signingKey) {
564
526
  return deriveLogKey("vault", signingKey);
565
527
  }
566
- /** Before the first entry: nothing verified yet. */
567
- const UNVERIFIED = {
568
- nextSeq: 0n,
569
- hash: GENESIS_HASH,
570
- vaultEntries: 0,
571
- vaultKeys: []
528
+ /** Before the first entry. */
529
+ const START = {
530
+ seq: -1n,
531
+ hash: GENESIS_HASH
572
532
  };
573
- /** The further of two anchors, when two calls verified at once. */
533
+ /** The further of two positions, when two calls walked at once. */
574
534
  function further(a, b) {
575
- return b.nextSeq > a.nextSeq ? b : a;
535
+ return b.seq > a.seq ? b : a;
576
536
  }
577
537
  /** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
578
538
  const VERIFY_BATCH = 5e3;
579
539
  /**
580
- * Check the chain, a batch in memory at a time, and return where it is now
581
- * verified to. Three parts:
582
- *
583
- * - `shown`, the page a reader is looking at: each entry against its own
584
- * hash and MAC;
585
- * - `anchor`, the head at the last check: still there, unchanged. A rewrite
586
- * of anything before it, chained again to hide, changes its hash;
587
- * - every entry after the anchor, or from the first.
540
+ * Walk the chain on from `from`, a batch in memory at a time, through `to`
541
+ * or the log's last entry, and no more than `limit` entries: every link and
542
+ * hash recomputed, each of the vault's entries by its MAC, its keys moving
543
+ * only forward, and each fed to `walk`. Where it got to, and whether that
544
+ * is as far as it was to go: a walk cut short by `limit` goes on from there
545
+ * another time.
588
546
  *
589
- * So a view rehashes only what is new since the last one. What it leaves
590
- * out is an entry before the anchor edited in place, not chained again, and
591
- * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
592
- * as does the first view after a start, which has no anchor. `onBatch` sees
593
- * each batch once it has verified, for checks that read the same entries.
547
+ * It takes `from` as given: whether the log still holds it is the caller's
548
+ * to check, with the other heads it must hold, in one read.
594
549
  */
595
- async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
596
- const broken = (failedAtSeq, reason) => ({
597
- verification: {
598
- ok: false,
599
- failedAtSeq: Number(failedAtSeq),
600
- reason: withCause(reason)
601
- },
602
- anchor
603
- });
604
- const keys = {
605
- keys: logKeys,
606
- chainOnly: ["app"]
607
- };
608
- for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
609
- const result = verifyEntries([row], {
610
- ...keys,
611
- startSeq: row.seq,
612
- startPrevHash: row.prevHash
550
+ async function rehash(db, logKeys, from, walk, { to, limit = Infinity }) {
551
+ let at = from;
552
+ for (let left = limit;;) {
553
+ if (to !== void 0 && at.seq >= to) return {
554
+ ok: true,
555
+ at,
556
+ done: true
557
+ };
558
+ if (left <= 0) return {
559
+ ok: true,
560
+ at,
561
+ done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === void 0 || entry.seq <= to)
562
+ };
563
+ let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
564
+ if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
565
+ if (batch.length === 0) return {
566
+ ok: true,
567
+ at,
568
+ done: to === void 0
569
+ };
570
+ const result = verifyEntries(batch, {
571
+ keys: logKeys,
572
+ chainOnly: ["app"],
573
+ startSeq: at.seq + 1n,
574
+ startPrevHash: at.hash
613
575
  });
614
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
576
+ if (!result.ok) return {
577
+ ok: false,
578
+ failedAtSeq: Number(result.failedAtSeq),
579
+ reason: withCause(result.reason)
580
+ };
581
+ for (const entry of batch) {
582
+ const moved = entry.author === "vault" ? walk.add(entry, logKeys[0].keyId) : null;
583
+ if (moved !== null) return {
584
+ ok: false,
585
+ failedAtSeq: Number(moved.failedAtSeq),
586
+ reason: moved.reason
587
+ };
588
+ }
589
+ at = {
590
+ seq: result.nextSeq - 1n,
591
+ hash: result.head
592
+ };
593
+ left -= batch.length;
615
594
  }
616
- 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");
617
- let verified = anchor;
618
- for (;;) {
619
- const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
595
+ }
596
+ /**
597
+ * The chain again from `from`, through `to` and no more than `limit`
598
+ * entries: each hash recomputed from its entry and each link checked, and
599
+ * nothing else, so a stretch costs a hash an entry. What a checkpoint does
600
+ * in the background to the prefixes signed before (`#scrub` in vault.ts):
601
+ * each entry's MAC was checked when it was signed over, and its hash pins
602
+ * it since, so all that can change behind a signed prefix and keep the
603
+ * chain is an entry edited in place, its stored hash left, which its hash
604
+ * finds.
605
+ */
606
+ async function relink(db, from, to, limit) {
607
+ let at = from;
608
+ while (at.seq < to && limit > 0) {
609
+ const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
620
610
  if (batch.length === 0) break;
621
611
  const result = verifyEntries(batch, {
622
- ...keys,
623
- startSeq: verified.nextSeq,
624
- startPrevHash: verified.hash
612
+ keys: [],
613
+ chainOnly: ["app", "vault"],
614
+ startSeq: at.seq + 1n,
615
+ startPrevHash: at.hash
625
616
  });
626
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
627
- const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
628
- if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
629
- onBatch(batch);
630
- verified = {
631
- nextSeq: result.nextSeq,
632
- hash: result.head,
633
- vaultEntries: verified.vaultEntries + result.authenticated,
634
- vaultKeys: moved.keys
617
+ if (!result.ok) return {
618
+ ok: false,
619
+ failedAtSeq: Number(result.failedAtSeq),
620
+ reason: result.reason
635
621
  };
622
+ at = {
623
+ seq: result.nextSeq - 1n,
624
+ hash: result.head
625
+ };
626
+ limit -= batch.length;
636
627
  }
637
628
  return {
638
- verification: {
639
- ok: true,
640
- entries: verified.vaultEntries
641
- },
642
- anchor: verified
629
+ ok: true,
630
+ at
643
631
  };
644
632
  }
645
633
  /**
634
+ * The vault's own entries after `after`, through `to` or its last, no more
635
+ * than `limit` of them, oldest first: each against its own hash, and by its
636
+ * MAC, over the link it names, its keys moving only forward, each fed to
637
+ * `walk`. The links between entries are the caller's to recompute, as the
638
+ * app does over the same stretch. The last it read, and whether that is as
639
+ * far as it was to go.
640
+ */
641
+ async function ownEntries(db, logKeys, after, walk, { to, limit = Infinity }) {
642
+ for (let left = limit;;) {
643
+ if (left <= 0) return {
644
+ ok: true,
645
+ at: after,
646
+ done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === void 0 || entry.seq <= to)
647
+ };
648
+ let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
649
+ if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
650
+ if (batch.length === 0) return {
651
+ ok: true,
652
+ at: after,
653
+ done: true
654
+ };
655
+ for (const entry of batch) {
656
+ const result = verifyEntries([entry], {
657
+ startSeq: entry.seq,
658
+ startPrevHash: entry.prevHash,
659
+ keys: logKeys
660
+ });
661
+ if (!result.ok) return {
662
+ ok: false,
663
+ failedAtSeq: Number(result.failedAtSeq),
664
+ reason: withCause(result.reason)
665
+ };
666
+ }
667
+ for (const entry of batch) {
668
+ const moved = walk.add(entry, logKeys[0].keyId);
669
+ if (moved !== null) return {
670
+ ok: false,
671
+ failedAtSeq: Number(moved.failedAtSeq),
672
+ reason: moved.reason
673
+ };
674
+ }
675
+ after = batch[batch.length - 1].seq;
676
+ left -= batch.length;
677
+ }
678
+ }
679
+ /**
646
680
  * The vault's keys only move forward. Once its entries move from one key to
647
681
  * the next, the one before writes no more; once they reach `current`, the
648
682
  * key it writes with now, no other key writes again. So a key it replaced,
@@ -960,6 +994,376 @@ function sealed(key, member, grants) {
960
994
  return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
961
995
  }
962
996
  //#endregion
997
+ //#region src/accounting.ts
998
+ const KEY_ACTIONS = /* @__PURE__ */ new Set([
999
+ "secret.read",
1000
+ "key.wrap",
1001
+ "key.rewrap"
1002
+ ]);
1003
+ /**
1004
+ * Every key operation's intent and its outcomes, entry by entry, oldest
1005
+ * first, as a walk reads the vault's entries (`add`); then what is still
1006
+ * open (`result`). The chain can hold while a process died between an
1007
+ * intent and its outcomes: only a missed deadline is a fault.
1008
+ *
1009
+ * A walk resumes from a checkpoint's `state()`: the batches open there, and
1010
+ * nothing of those that closed. So an intent's identity is checked against
1011
+ * those still open and those since, not every one the log ever held: a
1012
+ * closed batch takes no more outcomes in any case, since each names its
1013
+ * intent's entry.
1014
+ */
1015
+ var KeyAccounting = class {
1016
+ #intents = /* @__PURE__ */ new Map();
1017
+ #ids = /* @__PURE__ */ new Set();
1018
+ #fault = null;
1019
+ constructor(from = {
1020
+ intents: [],
1021
+ fault: null
1022
+ }) {
1023
+ for (const { seq, seen, ...open } of from.intents) {
1024
+ this.#intents.set(BigInt(seq), {
1025
+ ...open,
1026
+ seq: BigInt(seq),
1027
+ seen: new Set(seen)
1028
+ });
1029
+ this.#ids.add(open.intentId);
1030
+ }
1031
+ this.#fault = from.fault === null ? null : {
1032
+ ok: false,
1033
+ ...from.fault
1034
+ };
1035
+ }
1036
+ /** Where it stands, to resume from: the batches open, oldest first. */
1037
+ state() {
1038
+ return {
1039
+ intents: [...this.#intents.values()].sort((a, b) => a.seq < b.seq ? -1 : 1).map(({ seq, seen, ...open }) => ({
1040
+ ...open,
1041
+ seq: Number(seq),
1042
+ seen: [...seen].sort((a, b) => a - b)
1043
+ })),
1044
+ fault: this.#fault === null ? null : {
1045
+ failedAtSeq: this.#fault.failedAtSeq,
1046
+ reason: this.#fault.reason
1047
+ }
1048
+ };
1049
+ }
1050
+ add(entry) {
1051
+ if (this.#fault !== null || entry.author !== "vault") return;
1052
+ const broken = (reason) => {
1053
+ this.#fault = {
1054
+ ok: false,
1055
+ failedAtSeq: Number(entry.seq),
1056
+ reason
1057
+ };
1058
+ };
1059
+ if (entry.action === "key.intent") {
1060
+ const parsed = parseIntent(entry);
1061
+ if (typeof parsed === "string") return broken(parsed);
1062
+ if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
1063
+ this.#ids.add(parsed.intentId);
1064
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
1065
+ ...parsed,
1066
+ seq: entry.seq,
1067
+ actor: entry.actor,
1068
+ seen: /* @__PURE__ */ new Set()
1069
+ });
1070
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
1071
+ const parsed = parseOutcome(entry);
1072
+ if (typeof parsed === "string") return broken(parsed);
1073
+ const intent = this.#intents.get(parsed.relatedSeq);
1074
+ for (const outcome of parsed.items) {
1075
+ const key = intent?.keys[outcome.item];
1076
+ if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
1077
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
1078
+ if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
1079
+ intent.seen.add(outcome.item);
1080
+ }
1081
+ if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
1082
+ }
1083
+ }
1084
+ /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
1085
+ result(at) {
1086
+ if (this.#fault !== null) return this.#fault;
1087
+ const intents = [...this.#intents.values()];
1088
+ const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
1089
+ if (overdue.length === 0) return {
1090
+ ok: true,
1091
+ pending: intents.length
1092
+ };
1093
+ const reason = overdue.map((intent) => {
1094
+ const missing = intent.keys.length - intent.seen.size;
1095
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
1096
+ }).join("; ");
1097
+ return {
1098
+ ok: false,
1099
+ failedAtSeq: Number(overdue[0].seq),
1100
+ reason
1101
+ };
1102
+ }
1103
+ };
1104
+ function parseIntent(entry) {
1105
+ const detail = payload(entry.metadata);
1106
+ if (detail === null) return "key intent has no valid accounting payload";
1107
+ const { intent, operation, expiresAt } = detail;
1108
+ if (typeof intent !== "string") return "key intent has no operation identity";
1109
+ if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
1110
+ if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
1111
+ if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
1112
+ const keys = [];
1113
+ for (const [index, value] of detail.keys.entries()) {
1114
+ const key = record(value);
1115
+ if (key === null || key.item !== index) return "key intent items are not numbered in order";
1116
+ const { secretId, version, subject } = key;
1117
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
1118
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
1119
+ keys.push({
1120
+ item: index,
1121
+ secretId,
1122
+ version,
1123
+ subject
1124
+ });
1125
+ }
1126
+ return {
1127
+ intentId: intent,
1128
+ operation,
1129
+ expiresAt,
1130
+ keys
1131
+ };
1132
+ }
1133
+ function parseOutcome(entry) {
1134
+ const detail = payload(entry.metadata);
1135
+ if (detail === null) return "key outcome has no valid accounting payload";
1136
+ const { intent } = detail;
1137
+ if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
1138
+ const many = detail.secrets;
1139
+ if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
1140
+ const listed = many === void 0 ? [{
1141
+ ...detail,
1142
+ secretId: entry.secretId ?? detail.secretId
1143
+ }] : many.map(record);
1144
+ const items = [];
1145
+ for (const one of listed) {
1146
+ if (one === null) return "key outcome has no valid item list";
1147
+ const { item, secretId, version, subject } = one;
1148
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
1149
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
1150
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
1151
+ items.push({
1152
+ item,
1153
+ secretId,
1154
+ version,
1155
+ subject
1156
+ });
1157
+ }
1158
+ return {
1159
+ intentId: intent,
1160
+ relatedSeq: entry.relatedSeq,
1161
+ items
1162
+ };
1163
+ }
1164
+ function payload(text) {
1165
+ try {
1166
+ return record(JSON.parse(text));
1167
+ } catch {
1168
+ return null;
1169
+ }
1170
+ }
1171
+ function record(value) {
1172
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
1173
+ }
1174
+ //#endregion
1175
+ //#region src/tally.ts
1176
+ /** Before the first entry. */
1177
+ const EMPTY_TALLY = {
1178
+ entries: 0,
1179
+ keys: [],
1180
+ accounting: {
1181
+ intents: [],
1182
+ fault: null
1183
+ },
1184
+ access: {
1185
+ members: [],
1186
+ grants: [],
1187
+ newest: []
1188
+ },
1189
+ replayFault: null
1190
+ };
1191
+ /** The digest of what `access` says, over its canonical form. */
1192
+ function accessDigest(access) {
1193
+ return createHash("sha256").update(canonical(access)).digest("hex");
1194
+ }
1195
+ /**
1196
+ * What two tallies are compared by, so that they match whatever order
1197
+ * their maps were built in: the members and grants by their digest, which
1198
+ * a stretch passing many checkpoints computes once a change; and a fault by
1199
+ * where it was found, since the words for it may change from one release
1200
+ * to the next.
1201
+ */
1202
+ function tallyKey({ accounting, entries, keys, replayFault }, digest) {
1203
+ return canonical({
1204
+ entries,
1205
+ keys,
1206
+ replayFault,
1207
+ accounting: {
1208
+ ...accounting,
1209
+ fault: accounting.fault?.failedAtSeq ?? null
1210
+ },
1211
+ access: digest
1212
+ });
1213
+ }
1214
+ /**
1215
+ * What a walk must come to at a checkpoint, from the tally it keeps: its
1216
+ * members and grants by the digest it keeps of them, never read.
1217
+ */
1218
+ function expectedKey(stored) {
1219
+ return tallyKey(stored, stored.access.digest);
1220
+ }
1221
+ /**
1222
+ * The vault's entries walked, oldest first, each after its MAC was checked,
1223
+ * gathered into what the checks after the walk need: the tally it started
1224
+ * from, carried on.
1225
+ */
1226
+ var Walk = class {
1227
+ accounting;
1228
+ replayed;
1229
+ /** Each member's newest allowed access entry, by seq. */
1230
+ newestAccess;
1231
+ /** The first access entry the replay could not apply. */
1232
+ replayFault;
1233
+ /** The vault's entries so far, those before the walk included. */
1234
+ entries;
1235
+ /** The vault's log keys, as the log moved through them. */
1236
+ keys;
1237
+ /** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
1238
+ checkpoints = [];
1239
+ /** The tallies the walk must come to as it passes them, oldest first (`expect`). */
1240
+ #expected = [];
1241
+ /** The first it did not come to, and why. */
1242
+ mismatch = null;
1243
+ /** The digest of the members and grants as the walk has them, until an access entry changes them. */
1244
+ #digest = null;
1245
+ /** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
1246
+ digest() {
1247
+ return this.#digest ??= accessDigest(this.tally().access);
1248
+ }
1249
+ /** The tally but for its members and grants, which `digest` stands for. */
1250
+ #unaccessed() {
1251
+ return {
1252
+ entries: this.entries,
1253
+ keys: [...this.keys],
1254
+ accounting: this.accounting.state(),
1255
+ replayFault: this.replayFault
1256
+ };
1257
+ }
1258
+ /** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
1259
+ constructor(from, digest) {
1260
+ this.#digest = digest ?? null;
1261
+ this.accounting = new KeyAccounting(from.accounting);
1262
+ this.replayed = {
1263
+ members: new Map(from.access.members.map((member) => [member.principal, {
1264
+ ...member,
1265
+ accessSeq: BigInt(member.accessSeq)
1266
+ }])),
1267
+ held: /* @__PURE__ */ new Map()
1268
+ };
1269
+ for (const grant of from.access.grants) {
1270
+ const held = this.replayed.held.get(grant.principal) ?? /* @__PURE__ */ new Map();
1271
+ this.replayed.held.set(grant.principal, held);
1272
+ held.set(placeKey$1(grant), { ...grant });
1273
+ }
1274
+ this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
1275
+ this.replayFault = from.replayFault;
1276
+ this.entries = from.entries;
1277
+ this.keys = from.keys;
1278
+ }
1279
+ /**
1280
+ * Hold the walk to these tallies, each as the walk stands once it has
1281
+ * read every entry through the prefix's end: before the vault's next
1282
+ * entry, since only the vault's change a tally, or at `finish`.
1283
+ */
1284
+ expect(expected) {
1285
+ this.#expected = [...expected].sort((a, b) => a.through < b.through ? -1 : 1);
1286
+ }
1287
+ /** The tallies expected through `through`, held to the walk. */
1288
+ finish(through) {
1289
+ this.#reach(through + 1n);
1290
+ }
1291
+ #reach(before) {
1292
+ while (this.#expected.length > 0 && this.#expected[0].through < before) {
1293
+ const expected = this.#expected.shift();
1294
+ if (this.mismatch !== null) continue;
1295
+ if ("unreadable" in expected) this.mismatch = {
1296
+ entry: expected.entry,
1297
+ reason: expected.unreadable
1298
+ };
1299
+ else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) this.mismatch = {
1300
+ entry: expected.entry,
1301
+ reason: "the tally this checkpoint keeps does not follow from the log before it"
1302
+ };
1303
+ }
1304
+ }
1305
+ /**
1306
+ * One of the vault's entries, its MAC checked, in the log's order: a
1307
+ * fault when it is under a key the vault had moved past (`forward`).
1308
+ */
1309
+ add(entry, current) {
1310
+ this.#reach(entry.seq);
1311
+ const moved = forward([entry], this.keys, current);
1312
+ if ("failedAtSeq" in moved) return moved;
1313
+ this.keys = moved.keys;
1314
+ this.entries += 1;
1315
+ this.accounting.add(entry);
1316
+ if (entry.decision !== "allow") return null;
1317
+ if (ACCESS.has(entry.action)) {
1318
+ this.#digest = null;
1319
+ this.replayFault ??= apply(this.replayed, entry);
1320
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
1321
+ } else if (entry.action === "audit.checkpoint") {
1322
+ const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata);
1323
+ this.checkpoints.push({
1324
+ entry: {
1325
+ seq: entry.seq,
1326
+ keyId: entry.keyId,
1327
+ relatedSeq: entry.relatedSeq
1328
+ },
1329
+ checkpoint: {
1330
+ seq,
1331
+ hash,
1332
+ signedAt,
1333
+ keyId,
1334
+ signature
1335
+ }
1336
+ });
1337
+ }
1338
+ return null;
1339
+ }
1340
+ /** Where the walk stands, to resume from. */
1341
+ tally() {
1342
+ const byPrincipal = (entries) => [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
1343
+ return {
1344
+ entries: this.entries,
1345
+ keys: [...this.keys],
1346
+ accounting: this.accounting.state(),
1347
+ access: {
1348
+ members: byPrincipal(this.replayed.members).map(([, member]) => ({
1349
+ ...member,
1350
+ accessSeq: Number(member.accessSeq)
1351
+ })),
1352
+ grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
1353
+ newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)])
1354
+ },
1355
+ replayFault: this.replayFault
1356
+ };
1357
+ }
1358
+ };
1359
+ /** The action of the vault's entry that signs a prefix of the log. */
1360
+ const CHECKPOINT = "audit.checkpoint";
1361
+ const ACCESS = new Set(ACCESS_ACTIONS);
1362
+ /** JSON with every object's keys sorted: one text for one value, however it was built. */
1363
+ function canonical(value) {
1364
+ return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
1365
+ }
1366
+ //#endregion
963
1367
  //#region src/vault.ts
964
1368
  async function prepareVault(config, options = {}) {
965
1369
  return {
@@ -967,9 +1371,13 @@ async function prepareVault(config, options = {}) {
967
1371
  ...await keysOf(config.signingKeys),
968
1372
  options: {
969
1373
  keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
970
- clockOffset: options.clockOffset ?? (() => 0)
1374
+ clockOffset: options.clockOffset ?? (() => 0),
1375
+ walks: {
1376
+ ...WALKS,
1377
+ ...options.walks
1378
+ }
971
1379
  },
972
- verified: UNVERIFIED,
1380
+ verified: START,
973
1381
  reported: /* @__PURE__ */ new Set(),
974
1382
  since: null,
975
1383
  superseded: null,
@@ -1018,12 +1426,35 @@ const UNSEALED = {
1018
1426
  };
1019
1427
  /** Why a change to a tampered member is refused. */
1020
1428
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
1021
- /** The action of the vault's entry that signs a prefix of the log. */
1022
- const CHECKPOINT = "audit.checkpoint";
1023
1429
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
1024
1430
  const SETTINGS = "settings.change";
1025
1431
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
1026
1432
  const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
1433
+ /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
1434
+ const POLICY = "policy.change";
1435
+ const NO_EDITS = {
1436
+ roles: {},
1437
+ instanceRoles: {},
1438
+ protected: []
1439
+ };
1440
+ /** A role holding nothing, which every role but Owner is under the closed policy. */
1441
+ const NOTHING_HELD = (definition) => ({
1442
+ ...definition,
1443
+ permissions: [],
1444
+ protected: []
1445
+ });
1446
+ /**
1447
+ * The policy when the log has some, but none the vault wrote within reach:
1448
+ * whoever inserted rows may have hidden an Owner's narrowing, so no role
1449
+ * holds anything, a service account's neither, but Owner, which holds
1450
+ * everything always and so may write the policy again.
1451
+ */
1452
+ const CLOSED_POLICY = {
1453
+ roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
1454
+ instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
1455
+ protected: [EVERY_ENVIRONMENT],
1456
+ protectedIn: {}
1457
+ };
1027
1458
  /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
1028
1459
  const CLOSED_SETTINGS = { serviceAccounts: {
1029
1460
  projects: { only: [] },
@@ -1039,12 +1470,6 @@ function declaredOwner(principal) {
1039
1470
  const NOT_THEIRS = "a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold";
1040
1471
  /** Who asks for checkpoints: the app's scheduled job. */
1041
1472
  const SCHEDULER = "system:coffre-scheduler";
1042
- /**
1043
- * How often a checkpoint recomputes the chain from its first entry: the
1044
- * first of each hour, by the vault's clock. The others resume from the
1045
- * prefix the last one signed (`#recompute`).
1046
- */
1047
- const FULL_RECOMPUTE_MS = 36e5;
1048
1473
  /** A refusal and the entries that record it. */
1049
1474
  var Refused = class {
1050
1475
  refusal;
@@ -1067,6 +1492,8 @@ var Outage = class {
1067
1492
  this.entries = entries;
1068
1493
  }
1069
1494
  };
1495
+ /** A rewrap that would make a value less sensitive than it is. */
1496
+ const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more";
1070
1497
  function refusal(code, message) {
1071
1498
  return {
1072
1499
  code,
@@ -1083,10 +1510,11 @@ const MESSAGES = {
1083
1510
  deleted: "this project or environment was deleted",
1084
1511
  ended: "the reference read through was ended",
1085
1512
  personal: "a personal config's values are its owner's alone",
1086
- stand_ins: "people use this instance's values through stand-ins, never as values: `coffre run` gives your app them; a service account reads values",
1513
+ sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
1087
1514
  not_allowed: "not allowed to change this",
1088
1515
  last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
1089
1516
  invalid: "not something the rules allow",
1517
+ policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
1090
1518
  log_broken: "the vault log does not hold from the last checkpoint",
1091
1519
  wrong_kek: "this vault's key does not open the data it holds",
1092
1520
  tampered: "this member's record failed the vault's integrity check"
@@ -1144,36 +1572,44 @@ var VaultService = class {
1144
1572
  return await now(db) + this.#prepared.options.clockOffset();
1145
1573
  }
1146
1574
  /**
1147
- * Decide in one transaction, with `principals`' rows locked first. A
1148
- * `Refused` or an `Outage` rolls back all but its own entries, which
1149
- * commit on their own: then the refusal is the answer, and the outage
1150
- * fails the call.
1575
+ * Decide in one transaction, with `principals`' rows locked first, then
1576
+ * the log's head. A `Refused` or an `Outage` rolls back all but its own
1577
+ * entries, which commit on their own: then the refusal is the answer, and
1578
+ * the outage fails the call.
1579
+ *
1580
+ * Every change to the policy, the settings, a member or a place appends
1581
+ * under the head, so whatever the decision reads of them stands until it
1582
+ * commits: no decision acts on a policy read before it. A key operation
1583
+ * alone takes the head later (`late`), once its key service has answered,
1584
+ * which it must not wait on holding it, and decides again then (`#keys`).
1151
1585
  */
1152
- async #decide(principals, decide) {
1586
+ async #decide(principals, decide, late = false) {
1153
1587
  const superseded = await this.#settled();
1154
1588
  if (superseded !== null) return {
1155
1589
  ok: false,
1156
1590
  refusal: refusal("wrong_kek", superseded)
1157
1591
  };
1158
- return this.#decideNow(principals, decide);
1592
+ return this.#decideNow(principals, decide, late);
1159
1593
  }
1160
1594
  /** `#decide`, once the vault is settled. */
1161
- async #decideNow(principals, decide) {
1595
+ async #decideNow(principals, decide, late = false) {
1162
1596
  const reports = [];
1163
1597
  for (let attempt = 1;; attempt += 1) try {
1164
- return await this.#decideOnce(principals, decide, reports);
1598
+ return await this.#decideOnce(principals, decide, reports, late);
1165
1599
  } catch (error) {
1166
1600
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
1167
1601
  throw error;
1168
1602
  }
1169
1603
  }
1170
- async #decideOnce(principals, decide, reports) {
1604
+ async #decideOnce(principals, decide, reports, late) {
1171
1605
  try {
1172
1606
  const result = await this.#db.transaction(async (tx) => {
1173
1607
  await boundLockWaits(tx, LOCK_TIMEOUT_MS);
1608
+ const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
1609
+ if (!late) await lockLogHead(tx);
1174
1610
  const d = {
1175
1611
  tx,
1176
- members: principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals),
1612
+ members,
1177
1613
  at: await this.#now(tx),
1178
1614
  log: [],
1179
1615
  writes: [],
@@ -1278,14 +1714,17 @@ var VaultService = class {
1278
1714
  }
1279
1715
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
1280
1716
  #authentic(entry) {
1281
- const { logKeys, since } = this.#prepared;
1282
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
1283
1717
  return verifyEntries([entry], {
1284
1718
  startSeq: entry.seq,
1285
1719
  startPrevHash: entry.prevHash,
1286
- keys
1720
+ keys: this.#keysFor(entry)
1287
1721
  }).ok;
1288
1722
  }
1723
+ /** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
1724
+ #keysFor(entry) {
1725
+ const { logKeys, since } = this.#prepared;
1726
+ return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
1727
+ }
1289
1728
  /**
1290
1729
  * Whether `row`, with these grants, carries the MAC of the vault's row
1291
1730
  * key, or of one it replaced before the rotation sealed every row again
@@ -1367,7 +1806,6 @@ var VaultService = class {
1367
1806
  const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
1368
1807
  if (everyProject.length === 0) return;
1369
1808
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
1370
- await lockLogHead(d.tx);
1371
1809
  const before = roleOf(principal, row);
1372
1810
  const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await liveProjects(d.tx));
1373
1811
  const after = {
@@ -1529,6 +1967,7 @@ var VaultService = class {
1529
1967
  async unwrap(input) {
1530
1968
  const { principal } = input;
1531
1969
  validateText(input.purpose);
1970
+ if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
1532
1971
  const vias = input.items.map((item) => validateVia(item.via));
1533
1972
  const throughs = input.items.map((item) => validateThrough(item.through));
1534
1973
  const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
@@ -1577,7 +2016,7 @@ var VaultService = class {
1577
2016
  return this.#keys({
1578
2017
  action: "secret.read",
1579
2018
  principal,
1580
- permission: "secret.read",
2019
+ permission: input.purpose === "use" ? "secret.use" : "secret.read",
1581
2020
  secrets: items.map((item) => item.secret),
1582
2021
  places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
1583
2022
  vet: ends.length === 0 ? void 0 : async (db) => {
@@ -1627,7 +2066,8 @@ var VaultService = class {
1627
2066
  }
1628
2067
  }), (done) => ({
1629
2068
  wrapped: done.map(({ wrapped }) => wrapped),
1630
- seqs: []
2069
+ seqs: [],
2070
+ sensitive: items.map(({ secret }) => secret.sensitive === true)
1631
2071
  }));
1632
2072
  }
1633
2073
  async rewrap(input) {
@@ -1644,6 +2084,10 @@ var VaultService = class {
1644
2084
  ...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
1645
2085
  secretVersionId: source.id
1646
2086
  })));
2087
+ if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
2088
+ ...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
2089
+ secretVersionId: source.id
2090
+ })), LOOSER);
1647
2091
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
1648
2092
  const entry = (secret, decision, code) => ({
1649
2093
  ...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
@@ -1657,9 +2101,19 @@ var VaultService = class {
1657
2101
  secrets: items.map((item) => item.secret),
1658
2102
  remote,
1659
2103
  entry,
1660
- input
2104
+ input,
2105
+ vet: async (db) => {
2106
+ const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
2107
+ return items.map(({ secret }) => secret.sensitive !== true && newest.has(secret.secretId) ? "invalid" : null);
2108
+ },
2109
+ needs: async (db) => {
2110
+ return (await restoredNatures(db, items.map(({ secret }) => ({
2111
+ secretId: secret.secretId,
2112
+ from: sources.get(secret).id
2113
+ })))).map((nature) => nature === "secret" ? ["secret.read"] : nature === "sensitive" ? ["secret.use"] : null);
2114
+ }
1661
2115
  }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
1662
- const key = await this.#open(wrapped, contextOf(secret), operation);
2116
+ const key = await this.#open(wrapped, contextOf(sources.get(secret).secret), operation);
1663
2117
  if (key === null) return null;
1664
2118
  try {
1665
2119
  return {
@@ -1671,7 +2125,8 @@ var VaultService = class {
1671
2125
  }
1672
2126
  }), (done) => ({
1673
2127
  wrapped: done.map(({ wrapped }) => wrapped),
1674
- seqs: []
2128
+ seqs: [],
2129
+ sensitive: items.map(({ secret }) => secret.sensitive === true)
1675
2130
  }));
1676
2131
  }
1677
2132
  /** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
@@ -1710,9 +2165,9 @@ var VaultService = class {
1710
2165
  versions: ids.map((id) => found.get(id))
1711
2166
  };
1712
2167
  }
1713
- async #refuseVersions(principal, code, entries) {
2168
+ async #refuseVersions(principal, code, entries, message = MESSAGES[code]) {
1714
2169
  return this.#decide([principal], async () => {
1715
- throw new Refused(refusal(code, MESSAGES[code]), entries);
2170
+ throw new Refused(refusal(code, message), entries);
1716
2171
  });
1717
2172
  }
1718
2173
  /**
@@ -1756,14 +2211,27 @@ var VaultService = class {
1756
2211
  const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
1757
2212
  owners.clear();
1758
2213
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
2214
+ const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
1759
2215
  const codes = secrets.map((secret, i) => {
1760
2216
  const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
1761
2217
  if (personal !== void 0) return personal;
2218
+ const also = needs[i];
2219
+ if (also != null) {
2220
+ const place = placeIn(at[i], environments);
2221
+ const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i])));
2222
+ if (refused.every((code) => code !== null)) return refused[0];
2223
+ }
1762
2224
  return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
1763
2225
  });
1764
2226
  if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
2227
+ const copying = action === "secret.read" && call.input.purpose === "copy";
2228
+ if (action === "secret.read" && principal.startsWith("user:") && handsBack({
2229
+ purpose: call.input.purpose ?? "",
2230
+ keepsSensitive: call.input.keepsSensitive === true
2231
+ }) || copying && call.input.keepsSensitive !== true) secrets.forEach((secret, i) => {
2232
+ if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
2233
+ });
1765
2234
  let first = codes.find((code) => code !== null) ?? null;
1766
- if (first === null && action === "secret.read" && this.#config.people === "stand-ins" && principal.startsWith("user:") && HANDED_BACK.includes(call.input.purpose)) first = "stand_ins";
1767
2235
  if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
1768
2236
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
1769
2237
  };
@@ -1785,7 +2253,6 @@ var VaultService = class {
1785
2253
  if (call.remote) {
1786
2254
  const intent = await this.#decide([principal], async (d) => {
1787
2255
  await check(d);
1788
- await lockLogHead(d.tx);
1789
2256
  const at = await this.#now(d.tx);
1790
2257
  return { seq: (await this.#append(d.tx, [{
1791
2258
  actor: principal,
@@ -1811,15 +2278,19 @@ var VaultService = class {
1811
2278
  if (!intent.ok) return intent;
1812
2279
  intentSeq = intent.seq;
1813
2280
  }
1814
- return this.#decide([principal], async (d) => {
2281
+ const late = call.remote;
2282
+ const decided = async (d) => {
1815
2283
  try {
1816
2284
  await check(d);
1817
2285
  } catch (error) {
1818
2286
  if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1819
2287
  throw error;
1820
2288
  }
2289
+ };
2290
+ return this.#decide([principal], async (d) => {
2291
+ await decided(d);
1821
2292
  const { vet } = call;
1822
- if (vet !== void 0) d.underLock.push(async (locked) => {
2293
+ if (late && vet !== void 0) d.underLock.push(async (locked) => {
1823
2294
  const codes = await vet(locked);
1824
2295
  const first = codes.find((code) => code !== null);
1825
2296
  if (first === void 0 || first === null) return;
@@ -1835,10 +2306,13 @@ var VaultService = class {
1835
2306
  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());
1836
2307
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1837
2308
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1838
- await lockLogHead(d.tx);
1839
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1840
- const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
1841
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2309
+ if (late) {
2310
+ await lockLogHead(d.tx);
2311
+ await decided(d);
2312
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
2313
+ const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
2314
+ if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2315
+ }
1842
2316
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
1843
2317
  d.log.push(...released);
1844
2318
  const answer = result(done);
@@ -1847,7 +2321,7 @@ var VaultService = class {
1847
2321
  } finally {
1848
2322
  for (const value of done) value.wipe();
1849
2323
  }
1850
- });
2324
+ }, late);
1851
2325
  }
1852
2326
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1853
2327
  async #overBulkLimit(d, principal, n) {
@@ -1903,6 +2377,7 @@ var VaultService = class {
1903
2377
  };
1904
2378
  return this.#decide([principal], async (d) => {
1905
2379
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
2380
+ const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
1906
2381
  const environments = await environmentsById(d.tx, items.flatMap((item) => {
1907
2382
  const source = sources.get(item.source.secretId);
1908
2383
  return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
@@ -1913,14 +2388,11 @@ var VaultService = class {
1913
2388
  if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
1914
2389
  if (source.secretId === item.holder.secretId) return "invalid";
1915
2390
  if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
1916
- return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", placeIn(source, environments), gone(source));
2391
+ const at = placeIn(source, environments);
2392
+ return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (natures$1.get(source.secretId) === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null);
1917
2393
  });
1918
2394
  const first = codes.find((code) => code !== null) ?? null;
1919
2395
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
1920
- d.underLock.push(async (locked) => {
1921
- if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
1922
- throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
1923
- });
1924
2396
  const entries = items.map((item) => entry(item, "allow", null));
1925
2397
  d.log.push(...entries);
1926
2398
  const answer = { seqs: [] };
@@ -1994,10 +2466,6 @@ var VaultService = class {
1994
2466
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
1995
2467
  const first = codes.find((code) => code !== null) ?? null;
1996
2468
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
1997
- d.underLock.push(async (locked) => {
1998
- if ((await ended(locked)).every((code) => code === null)) return;
1999
- throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
2000
- });
2001
2469
  const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
2002
2470
  d.log.push(...entries);
2003
2471
  const answer = { seqs: [] };
@@ -2071,11 +2539,11 @@ var VaultService = class {
2071
2539
  /**
2072
2540
  * Refuse a change that would leave `principal`, an Owner of the whole
2073
2541
  * instance, no longer one, unless another is. Counted under the log's
2074
- * head: two changes at once, each demoting or removing a different
2075
- * Owner, lock different rows, and only the head puts one after the other.
2542
+ * head, which every decision holds: two changes at once, each demoting or
2543
+ * removing a different Owner, lock different rows, and only the head puts
2544
+ * one after the other.
2076
2545
  */
2077
2546
  async #keepAnOwner(d, actor, principal, refused) {
2078
- await lockLogHead(d.tx);
2079
2547
  if (await this.#anOwner(d.tx, d.reports, principal) !== null) return;
2080
2548
  throw refused("last_owner", actor === principal ? "you're the last Owner of the whole instance: make someone else an Owner first" : `${principal.slice(5)} is the last Owner of the whole instance: make someone else an Owner first`);
2081
2549
  }
@@ -2175,24 +2643,30 @@ var VaultService = class {
2175
2643
  role: grant.role
2176
2644
  }));
2177
2645
  const { role, scope } = roleOf(principal, row);
2646
+ const { policy } = await this.#policyAt(db, reports, at);
2647
+ const service = !principal.startsWith("user:");
2178
2648
  return {
2179
2649
  principal,
2180
2650
  status: "active",
2181
2651
  live: {
2182
2652
  role,
2183
2653
  scope,
2184
- grants: held.filter((grant) => live(grant, at))
2654
+ grants: held.filter((grant) => live(grant, at)),
2655
+ policy,
2656
+ service
2185
2657
  },
2186
2658
  all: {
2187
2659
  role,
2188
2660
  scope,
2189
- grants: held
2661
+ grants: held,
2662
+ policy,
2663
+ service
2190
2664
  },
2191
2665
  fault,
2192
2666
  stored: grants$1
2193
2667
  };
2194
2668
  }
2195
- #access(principal, row, held, at, tampered) {
2669
+ #access(principal, row, held, at, tampered, policy) {
2196
2670
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
2197
2671
  return {
2198
2672
  principal,
@@ -2204,7 +2678,8 @@ var VaultService = class {
2204
2678
  },
2205
2679
  grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
2206
2680
  since: row ? iso(row.statusChangedAt) : null,
2207
- by: row?.statusChangedBy ?? null
2681
+ by: row?.statusChangedBy ?? null,
2682
+ policy
2208
2683
  };
2209
2684
  }
2210
2685
  /**
@@ -2234,7 +2709,8 @@ var VaultService = class {
2234
2709
  this.#now(db)
2235
2710
  ]);
2236
2711
  const fault = await this.#integrity(db, principal, row, held, reports);
2237
- return this.#access(principal, row, held, at, fault !== null);
2712
+ const { policy } = await this.#policyAt(db, reports, at);
2713
+ return this.#access(principal, row, held, at, fault !== null, policy);
2238
2714
  };
2239
2715
  const found = [];
2240
2716
  const quick = await read(this.#db, found).catch((error) => {
@@ -2304,12 +2780,15 @@ var VaultService = class {
2304
2780
  const key = placeKey(change);
2305
2781
  if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
2306
2782
  places$1.add(key);
2307
- if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
2308
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
2783
+ if (change.role !== null && typeof change.role !== "string") throw refused("invalid", `no such role: ${String(change.role)}`);
2309
2784
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
2310
2785
  if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
2311
2786
  }
2312
- await lockLogHead(d.tx);
2787
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
2788
+ for (const change of changes) {
2789
+ if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
2790
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
2791
+ }
2313
2792
  const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
2314
2793
  const where = changes.map(({ projectId, environmentId, role }) => {
2315
2794
  const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
@@ -2627,12 +3106,140 @@ var VaultService = class {
2627
3106
  * the default: otherwise whoever can insert rows could open it again.
2628
3107
  */
2629
3108
  async #settingsAt(db, reports) {
2630
- const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before), this.#check(reports, null));
3109
+ const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, SETTINGS, limit, before), this.#check(reports, null));
2631
3110
  if (entry === "none") return DEFAULT_SETTINGS;
2632
3111
  if (typeof entry === "string") return CLOSED_SETTINGS;
2633
3112
  const { serviceAccounts } = JSON.parse(entry.metadata);
2634
3113
  return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
2635
3114
  }
3115
+ async policy() {
3116
+ await this.#settled();
3117
+ const reports = [];
3118
+ const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
3119
+ await this.#record(reports);
3120
+ return policy;
3121
+ }
3122
+ /**
3123
+ * The policy the vault's newest `policy.change` says, by its MAC, and its
3124
+ * edits, as of `at`: one in its name it did not write is passed over, and
3125
+ * reported. With such entries and none of its own within reach, the
3126
+ * policy is unreadable, and closed: every environment is protected, and
3127
+ * no role holds anything but Owner, otherwise whoever can insert rows
3128
+ * could unprotect them. Its edits are then unknown, not none.
3129
+ */
3130
+ async #policyAt(db, reports, at) {
3131
+ const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
3132
+ if (entry === "none") return {
3133
+ policy: DEFAULT_POLICY,
3134
+ edits: NO_EDITS,
3135
+ readable: true
3136
+ };
3137
+ const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
3138
+ return edits === null ? {
3139
+ policy: CLOSED_POLICY,
3140
+ edits: NO_EDITS,
3141
+ readable: false
3142
+ } : {
3143
+ policy: await livePolicy(db, edits, at),
3144
+ edits,
3145
+ readable: true
3146
+ };
3147
+ }
3148
+ changePolicy(input) {
3149
+ const { actor, change, revision } = input;
3150
+ if (revision !== void 0 && (typeof revision !== "string" || !POLICY_REVISION.test(revision))) throw new Error("revision names a policy as policyRevision writes it");
3151
+ const at = {};
3152
+ const entry = (decision, detail, code = null) => ({
3153
+ actor,
3154
+ action: POLICY,
3155
+ decision,
3156
+ code,
3157
+ operationId: input.operationId ?? null,
3158
+ requestId: input.requestId ?? null,
3159
+ ...at,
3160
+ metadata: JSON.stringify({
3161
+ ...detail,
3162
+ ...traced(input)
3163
+ })
3164
+ });
3165
+ const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
3166
+ return this.#decide([actor], async (d) => {
3167
+ validateCorrelation(input);
3168
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
3169
+ if (acting.status === "tampered") throw refused("tampered");
3170
+ if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
3171
+ const newest = await newestVaultSeq(d.tx, POLICY);
3172
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
3173
+ if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
3174
+ const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
3175
+ if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
3176
+ const next = {
3177
+ roles: { ...edits.roles },
3178
+ instanceRoles: { ...edits.instanceRoles },
3179
+ protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place }))
3180
+ };
3181
+ if (change?.kind === "role" || change?.kind === "instance-role") {
3182
+ if (!owner) throw refused("not_allowed", "only an Owner of the whole instance edits roles");
3183
+ const instance = change.kind === "instance-role";
3184
+ const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role) : FIXED_ROLES.roles.includes(change.role);
3185
+ if (typeof change.role !== "string" || fixed) throw refused("invalid", `${change.role} is not edited: it holds ${change.role === "member" ? "nothing" : "everything"}, always`);
3186
+ if (instance && !isInstanceRole(change.role)) throw refused("invalid", `no instance role ${change.role}: an Owner makes project roles`);
3187
+ if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused("invalid", "a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter");
3188
+ const table = instance ? next.instanceRoles : next.roles;
3189
+ if (change.definition === null) {
3190
+ if (!instance && !isBuiltinRole(change.role)) {
3191
+ if (table[change.role] === void 0) throw refused("invalid", `no role ${change.role}`);
3192
+ const held = await grantsNaming(d.tx, change.role);
3193
+ if (held > 0) throw refused("invalid", `${held === 1 ? "1 grant names" : `${held} grants name`} ${change.role}: revoke ${held === 1 ? "it" : "them"} first`);
3194
+ }
3195
+ const shipped = instance ? void 0 : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
3196
+ if (shipped !== void 0 && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
3197
+ delete table[change.role];
3198
+ } else {
3199
+ const problem = definitionProblem(change.definition);
3200
+ if (problem !== null) throw refused("invalid", problem);
3201
+ const definition = normalDefinition(change.definition);
3202
+ if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
3203
+ const shipped = instance ? INSTANCE_ROLES[change.role] : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
3204
+ if (shipped !== void 0 && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
3205
+ else table[change.role] = definition;
3206
+ }
3207
+ } else if (change?.kind === "protect") {
3208
+ if (typeof change.projectId !== "string" || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0 || change.environmentIds.some((id) => typeof id !== "string" || !UUID.test(id)) || typeof change.protected !== "boolean") throw refused("invalid", "protection names a project and some of its environments, by id, and whether they are protected");
3209
+ if (!await projectLive(d.tx, change.projectId)) throw refused("invalid", "no such project");
3210
+ at.projectId = change.projectId;
3211
+ const found = await environmentsById(d.tx, change.environmentIds);
3212
+ if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused("invalid", "each environment is one of the project's");
3213
+ if (change.environmentIds.length === 1 && found.has(change.environmentIds[0])) at.environmentId = change.environmentIds[0];
3214
+ if (change.protected) {
3215
+ if (!allows(acting.live, "environment.manage", { projectId: change.projectId })) throw refused("not_allowed", "protecting an environment takes managing its project's environments");
3216
+ const known = new Set(next.protected.map((place) => place.environmentId));
3217
+ next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({
3218
+ projectId: change.projectId,
3219
+ environmentId,
3220
+ at: d.at
3221
+ }))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
3222
+ } else {
3223
+ if (!owner) throw refused("not_allowed", "only an Owner of the whole instance unprotects an environment");
3224
+ next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
3225
+ }
3226
+ } else throw refused("invalid", "a change is to a role, an instance role, or protection");
3227
+ const answer = {
3228
+ policy: await livePolicy(d.tx, next, d.at),
3229
+ seq: newest === null ? null : Number(newest)
3230
+ };
3231
+ if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
3232
+ const made = entry("allow", {
3233
+ policy: next,
3234
+ change: describeChange(change),
3235
+ before: describeBefore(change, before)
3236
+ });
3237
+ d.log.push(made);
3238
+ d.after.push((seqOf) => answer.seq = seqOf(made));
3239
+ }
3240
+ return answer;
3241
+ });
3242
+ }
2636
3243
  setSettings(input) {
2637
3244
  const { actor } = input;
2638
3245
  const entry = (decision, detail, code = null) => ({
@@ -2654,7 +3261,6 @@ var VaultService = class {
2654
3261
  if (acting.status === "tampered") throw refused("tampered");
2655
3262
  if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
2656
3263
  if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
2657
- await lockLogHead(d.tx);
2658
3264
  const previous = await this.#settingsAt(d.tx, d.reports);
2659
3265
  const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
2660
3266
  if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
@@ -2817,63 +3423,162 @@ var VaultService = class {
2817
3423
  }
2818
3424
  return checks;
2819
3425
  }
2820
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
2821
- async #latest(db) {
2822
- const row = await latestVaultEntry(db, [CHECKPOINT]);
2823
- return row === void 0 ? null : {
2824
- checkpoint: JSON.parse(row.metadata),
2825
- seq: row.seq,
2826
- keyId: row.keyId
3426
+ /**
3427
+ * Where a walk of the log resumes: the first entry (`start`), a
3428
+ * checkpoint's entry that keeps a tally, or the newest such entry, the
3429
+ * first entry while there is none. The entry must be the vault's, by its
3430
+ * MAC, and its tally whole: the members and grants it keeps, or the
3431
+ * earlier checkpoint it names keeps, match the digest it carries.
3432
+ *
3433
+ * Its signature is left to the walk: the entry comes after the prefix it
3434
+ * signs, so a walk from there reads it, as it reads every other.
3435
+ */
3436
+ async #resume(db, from) {
3437
+ if (from === "start") return FIRST_ENTRY;
3438
+ const entry = from === "newest" ? await latestTallied(db) : (await entriesAt(db, [from])).get(from);
3439
+ if (entry === void 0) return from === "newest" ? FIRST_ENTRY : {
3440
+ ok: false,
3441
+ failedAtSeq: Number(from),
3442
+ reason: "no checkpoint at this entry"
2827
3443
  };
3444
+ return (await this.#resumeEach(db, [entry]))[0];
3445
+ }
3446
+ /** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
3447
+ async #resumeEach(db, entries) {
3448
+ const reads = entries.map((entry) => this.#tallied(entry));
3449
+ const named = reads.flatMap((read) => typeof read !== "string" && "at" in read.tally.access ? [BigInt(read.tally.access.at)] : []);
3450
+ const carriers = new Map([...await entriesAt(db, named)].map(([seq, held]) => [seq, this.#tallied(held)]));
3451
+ return entries.map((entry, i) => {
3452
+ const broken = (reason) => ({
3453
+ ok: false,
3454
+ failedAtSeq: Number(entry.seq),
3455
+ reason
3456
+ });
3457
+ const read = reads[i];
3458
+ if (typeof read === "string") return broken(read);
3459
+ let carrier = entry.seq;
3460
+ let { access } = read.tally;
3461
+ if ("at" in access) {
3462
+ carrier = BigInt(access.at);
3463
+ const holder = carrier < entry.seq ? carriers.get(carrier) : void 0;
3464
+ if (holder === void 0 || typeof holder === "string" || "at" in holder.tally.access) return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
3465
+ access = holder.tally.access;
3466
+ }
3467
+ const kept = {
3468
+ members: access.members,
3469
+ grants: access.grants,
3470
+ newest: access.newest
3471
+ };
3472
+ if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
3473
+ return {
3474
+ ok: true,
3475
+ entry: entry.seq,
3476
+ at: {
3477
+ seq: BigInt(read.seq),
3478
+ hash: Buffer.from(read.hash, "hex")
3479
+ },
3480
+ tally: {
3481
+ ...read.tally,
3482
+ access: kept
3483
+ },
3484
+ digest: read.tally.access.digest,
3485
+ carrier,
3486
+ scrub: read.scrub === null ? null : {
3487
+ at: positionOf(read.scrub.at),
3488
+ end: positionOf(read.scrub.end)
3489
+ }
3490
+ };
3491
+ });
3492
+ }
3493
+ /** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
3494
+ #tallied(entry) {
3495
+ const not = "not a checkpoint of the vault's that keeps a tally";
3496
+ if (entry.author !== "vault" || entry.action !== "audit.checkpoint" || entry.decision !== "allow" || entry.relatedSeq === null) return not;
3497
+ const authentic = verifyEntries([entry], {
3498
+ startSeq: entry.seq,
3499
+ startPrevHash: entry.prevHash,
3500
+ keys: this.#keysFor(entry)
3501
+ });
3502
+ if (!authentic.ok) return withCause(authentic.reason);
3503
+ const read = metadataOf(entry);
3504
+ if (read?.tally?.v !== 1 || read.scrub === void 0 || typeof read.seq !== "number" || BigInt(read.seq) !== entry.relatedSeq) return not;
3505
+ return read;
3506
+ }
3507
+ /** Which of `heads` the log still holds where they were, in one read. */
3508
+ async #holding(db, heads) {
3509
+ const hashes = await hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
3510
+ return (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
2828
3511
  }
2829
3512
  /**
2830
- * The chain recomputed to its last entry, for a checkpoint to sign: from
2831
- * its first entry at the first checkpoint of each hour, and otherwise from
2832
- * the prefix the last checkpoint signed, if that one was signed this hour.
2833
- *
2834
- * Resuming never signs what the vault did not recompute. The vault
2835
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
2836
- * hour's first checkpoint and onward at each one since, and the entry
2837
- * that holds it is the first one checked again, by its MAC. The next
2838
- * signature extends that same chain.
3513
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
2839
3514
  *
2840
- * What resuming does not do is recompute the rows of that prefix again.
2841
- * They can change while the hash stored at its end stays, so it first
2842
- * checks the two things that are cheap: that hash is still there, which a
2843
- * prefix rewritten and chained again changes, and every entry of the
2844
- * prefix is, which a cut changes. Either failing, it recomputes from entry
2845
- * 0, which names what broke. An entry edited in place, its hash left as
2846
- * it was, is the one change left to the next full pass, within the hour.
3515
+ * In one snapshot, and without the log's lock, so that no append waits on
3516
+ * it: every member's row checked, since rows changed around the vault
3517
+ * write nothing to the log; the prefix the newest checkpoint signed still
3518
+ * there, its hash where it was, which a rewrite chained again changes, and
3519
+ * every entry of it, which a cut changes; the chain recomputed from there,
3520
+ * every hash from content and every vault entry by its MAC, no further
3521
+ * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
3522
+ * before rehashed (`#scrub`). Then, under the lock, the entries appended
3523
+ * since the snapshot, so that it signs the head.
2847
3524
  *
2848
- * It resumes only from a checkpoint under the key the vault writes with
2849
- * now: once the log reaches that key, no other writes again (`forward`).
2850
- * After a rotation, the next pass is full.
3525
+ * A walk cut short signs where it got to, behind the head: the log after
3526
+ * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
3527
+ * from the first entry, one walk a call, and `/readyz` stays red until the
3528
+ * checkpoints reach the newest heartbeat.
2851
3529
  */
2852
- async #recompute(tx) {
2853
- const { logKeys } = this.#prepared;
2854
- const latest = await this.#latest(tx);
2855
- const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
2856
- if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
2857
- const { seq, hash } = latest.checkpoint;
2858
- return verifyChain(tx, logKeys, [], {
2859
- nextSeq: BigInt(seq) + 1n,
2860
- hash: Buffer.from(hash, "hex"),
2861
- vaultEntries: 0,
2862
- vaultKeys: [logKeys[0].keyId]
2863
- });
2864
- }
2865
3530
  async checkpoint() {
2866
3531
  await this.#settled();
2867
- const { wrongKek } = this.#prepared;
3532
+ const { wrongKek, logKeys } = this.#prepared;
3533
+ const { walks } = this.#prepared.options;
2868
3534
  const found = [];
2869
- const whole = await this.#db.transaction(async (tx) => {
3535
+ let seen = null;
3536
+ const walked = await this.#db.transaction(async (tx) => {
3537
+ seen = (await newestCheckpointEntry(tx))?.seq ?? null;
2870
3538
  await this.#sweep(tx, found);
2871
- return this.#recompute(tx);
3539
+ const from = await this.#resume(tx, "newest");
3540
+ if (!from.ok) return from;
3541
+ const signed = headOf(from.at);
3542
+ if (!(await this.#holding(tx, [signed]))(signed)) return {
3543
+ ok: false,
3544
+ failedAtSeq: null,
3545
+ reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed`
3546
+ };
3547
+ const gap = await missingEntry(tx, from.at.seq);
3548
+ if (gap !== null) return {
3549
+ ok: false,
3550
+ failedAtSeq: Number(gap.found),
3551
+ reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
3552
+ };
3553
+ const walk = new Walk(from.tally, from.digest);
3554
+ const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
3555
+ if (!chain.ok) return chain;
3556
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
3557
+ if (unsigned !== null) return unsigned;
3558
+ return {
3559
+ ok: true,
3560
+ from,
3561
+ tally: walk.tally(),
3562
+ digest: walk.digest(),
3563
+ at: chain.at,
3564
+ done: chain.done
3565
+ };
2872
3566
  }, SNAPSHOT);
3567
+ let scrub = {
3568
+ ok: true,
3569
+ next: walked.ok ? walked.from.scrub : null
3570
+ };
3571
+ let stalled = null;
3572
+ if (walked.ok) try {
3573
+ scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
3574
+ } catch (error) {
3575
+ if (!isUnreachable(error) && !timedOut(error)) throw error;
3576
+ stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
3577
+ }
2873
3578
  return this.#decide([], async (d) => {
2874
3579
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2875
3580
  const head = await lockLogHead(d.tx);
2876
- const latest = await this.#latest(d.tx);
3581
+ const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
2877
3582
  const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
2878
3583
  actor: SCHEDULER,
2879
3584
  action: CHECKPOINT,
@@ -2883,21 +3588,57 @@ var VaultService = class {
2883
3588
  }]);
2884
3589
  if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
2885
3590
  if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
2886
- if (!whole.verification.ok) throw refused("log_broken", {
2887
- failedAtSeq: whole.verification.failedAtSeq,
2888
- reason: whole.verification.reason
3591
+ if (!walked.ok) throw refused("log_broken", {
3592
+ failedAtSeq: walked.failedAtSeq,
3593
+ reason: walked.reason
2889
3594
  });
2890
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
2891
- 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` });
2892
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
2893
- if (!held.ok) throw refused("log_broken", {
2894
- failedAtSeq: held.failedAtSeq,
2895
- reason: held.reason
3595
+ if (!scrub.ok) throw refused("log_broken", {
3596
+ failedAtSeq: scrub.failedAtSeq,
3597
+ reason: scrub.reason
2896
3598
  });
2897
- if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
3599
+ const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
3600
+ if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
3601
+ const standing = await this.#standingRefusal(d.tx);
3602
+ if (standing !== null) throw refused("log_broken", standing);
3603
+ if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
3604
+ }
3605
+ if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
3606
+ if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
3607
+ const walk = new Walk(walked.tally, walked.digest);
3608
+ let { at } = walked;
3609
+ if (walked.done) {
3610
+ if (!await carries(d.tx, headOf(at))) throw refused("log_broken", {
3611
+ failedAtSeq: Number(at.seq),
3612
+ reason: `the log up to entry ${at.seq} changed while the vault walked it`
3613
+ });
3614
+ const rest = await rehash(d.tx, logKeys, at, walk, {});
3615
+ if (!rest.ok) throw refused("log_broken", {
3616
+ failedAtSeq: rest.failedAtSeq,
3617
+ reason: rest.reason
3618
+ });
3619
+ const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
3620
+ if (unsigned !== null) throw refused("log_broken", {
3621
+ failedAtSeq: unsigned.failedAtSeq,
3622
+ reason: unsigned.reason
3623
+ });
3624
+ at = rest.at;
3625
+ if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
3626
+ } else if (!await carries(d.tx, headOf(at))) throw refused("log_broken", { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
3627
+ if (newest !== null && latest.relatedSeq !== null && latest.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
3628
+ this.#prepared.verified = further(this.#prepared.verified, at);
3629
+ const tally = walk.tally();
3630
+ const digest = walk.digest();
3631
+ const { from } = walked;
3632
+ const access = digest === from.digest && from.carrier !== null ? {
3633
+ digest,
3634
+ at: Number(from.carrier)
3635
+ } : {
3636
+ digest,
3637
+ ...tally.access
3638
+ };
2898
3639
  const signed = {
2899
- seq: Number(verified.nextSeq - 1n),
2900
- hash: verified.hash.toString("hex"),
3640
+ seq: Number(at.seq),
3641
+ hash: at.hash.toString("hex"),
2901
3642
  signedAt: iso(d.at)
2902
3643
  };
2903
3644
  const checkpoint = {
@@ -2905,15 +3646,85 @@ var VaultService = class {
2905
3646
  keyId: this.#prepared.signer.keyId,
2906
3647
  signature: await this.#prepared.signer.sign(checkpointMessage(signed))
2907
3648
  };
3649
+ const lap = scrub.next === null ? null : {
3650
+ at: headOf(scrub.next.at),
3651
+ end: headOf(scrub.next.end)
3652
+ };
3653
+ const kept = {
3654
+ ...checkpoint,
3655
+ tally: {
3656
+ v: 1,
3657
+ ...tally,
3658
+ access
3659
+ },
3660
+ scrub: lap
3661
+ };
2908
3662
  d.log.push({
2909
3663
  actor: SCHEDULER,
2910
3664
  action: CHECKPOINT,
2911
3665
  decision: "allow",
2912
- metadata: JSON.stringify(checkpoint)
3666
+ relatedSeq: at.seq,
3667
+ metadata: JSON.stringify(kept)
2913
3668
  });
2914
- return { checkpoint };
3669
+ return stalled === null ? { checkpoint } : {
3670
+ checkpoint,
3671
+ warning: stalled
3672
+ };
2915
3673
  });
2916
3674
  }
3675
+ /**
3676
+ * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
3677
+ * runs from the first entry to the prefix the newest checkpoint signed
3678
+ * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
3679
+ * hash recomputed from its entry and each link checked (`relink`); the
3680
+ * next lap runs to the prefix signed then. A checkpoint resumes from the
3681
+ * newest one's tally and never reads what it signed again, and an entry
3682
+ * before it edited in place, its stored hash left as it was, keeps the
3683
+ * chain: this is what finds it, within two laps at most, each the length
3684
+ * of the log when it began over `walks.scrub` beats, however fast the log
3685
+ * grows meanwhile. The lap as it goes on, null between laps.
3686
+ */
3687
+ async #scrub(db, newest) {
3688
+ const lap = newest.scrub ?? {
3689
+ at: START,
3690
+ end: newest.at
3691
+ };
3692
+ if (lap.end.seq < 0n) return {
3693
+ ok: true,
3694
+ next: null
3695
+ };
3696
+ const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
3697
+ if (!relinked.ok) return relinked;
3698
+ if (relinked.at.seq < lap.end.seq) return {
3699
+ ok: true,
3700
+ next: {
3701
+ at: relinked.at,
3702
+ end: lap.end
3703
+ }
3704
+ };
3705
+ if (!relinked.at.hash.equals(lap.end.hash)) return {
3706
+ ok: false,
3707
+ failedAtSeq: Number(lap.end.seq),
3708
+ reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed`
3709
+ };
3710
+ return {
3711
+ ok: true,
3712
+ next: null
3713
+ };
3714
+ }
3715
+ /**
3716
+ * The newest refusal for a broken log since the newest checkpoint was
3717
+ * signed, by the vault's MAC, as it said: it stands, for every
3718
+ * verification and checkpoint, until a checkpoint is signed, which takes
3719
+ * the check that found it running again and passing. A refusal of
3720
+ * another kind since, a wrong KEK, hides none.
3721
+ */
3722
+ async #standingRefusal(db) {
3723
+ const signed = await latestVaultEntry(db, [CHECKPOINT]);
3724
+ const refused = await newestRefusal(db, CHECKPOINT, "log_broken", signed?.seq ?? -1n);
3725
+ if (refused === void 0 || !this.#authentic(refused)) return null;
3726
+ return JSON.parse(refused.metadata);
3727
+ }
2917
3728
  async about() {
2918
3729
  await this.#settled();
2919
3730
  const { signers, since } = this.#prepared;
@@ -2942,65 +3753,181 @@ var VaultService = class {
2942
3753
  checks
2943
3754
  };
2944
3755
  }
2945
- verifyLog(input) {
2946
- return this.#verifyAll(input.upTo ?? null);
2947
- }
2948
3756
  /**
2949
- * The whole log, in one snapshot and one pass over the vault's entries:
2950
- * each by its MAC, the keys it moved through, and what the other checks
2951
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2952
- * heads that must still be there, the key batches accounted for, and the
2953
- * members and grants replayed.
3757
+ * The log from a checkpoint's tally, in one snapshot: the vault's
3758
+ * entries since, each by its MAC, the keys it moved through, and what the
3759
+ * other checks need of it (`Walk`); then every checkpoint walked, the
3760
+ * heads that must still be there, and, for a walk that ends at the
3761
+ * present, the key batches accounted for and the members and grants
3762
+ * replayed against their rows.
2954
3763
  *
2955
3764
  * With `upTo`, the head the app verified to, the links and hashes of the
2956
- * chain are the app's to recompute, as it just has, over its own snapshot,
2957
- * through that head; `carries(upTo)` ties this snapshot to that one. So
2958
- * the vault reads only its own entries, each checked against its own hash
2959
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
2960
- * the chain: the vault reads it all, and rehashes every link.
3765
+ * chain are the app's to recompute, as it just has, over its own
3766
+ * snapshot, through that head; holding `upTo` ties this snapshot to that
3767
+ * one. So the vault reads only its own entries, each checked against its
3768
+ * own hash and MAC over the link it names. Without `upTo`, nobody else
3769
+ * recomputed the chain: the vault reads it all, and rehashes every link,
3770
+ * no further than one walk (`walks.verify`).
2961
3771
  */
2962
- async #verifyAll(upTo) {
3772
+ async verifyLog(input) {
2963
3773
  await this.#settled();
3774
+ const upTo = input.upTo ?? null;
3775
+ const { logKeys } = this.#prepared;
3776
+ const { walks } = this.#prepared.options;
2964
3777
  return this.#db.transaction(async (tx) => {
2965
3778
  const remembered = this.#prepared.verified;
2966
- const findings = new Findings();
2967
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2968
- if (!read.ok) return read;
2969
- const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2970
- const rememberedHead = remembered.nextSeq > 0n ? {
2971
- seq: Number(remembered.nextSeq - 1n),
2972
- hash: remembered.hash.toString("hex")
2973
- } : null;
2974
- if (rememberedHead !== null) heads.push(rememberedHead);
2975
- const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2976
- const holds = (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
3779
+ const from = await this.#resume(tx, input.from === void 0 ? "newest" : input.from === "start" ? "start" : BigInt(input.from));
3780
+ if (!from.ok) return from;
3781
+ const to = input.to === void 0 ? null : BigInt(input.to);
3782
+ const ends = to === null ? null : (await entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
3783
+ if (ends !== null && (ends <= from.at.seq || upTo !== null && BigInt(upTo.seq) !== ends)) return {
3784
+ ok: false,
3785
+ failedAtSeq: Number(to),
3786
+ reason: "not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to"
3787
+ };
3788
+ if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) return {
3789
+ ok: false,
3790
+ failedAtSeq: Number(from.entry),
3791
+ reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten`
3792
+ };
3793
+ const gap = await missingEntry(tx, from.at.seq);
3794
+ if (gap !== null) return {
3795
+ ok: false,
3796
+ failedAtSeq: Number(gap.found),
3797
+ reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
3798
+ };
3799
+ const walk = new Walk(from.tally, from.digest);
3800
+ const passed = (await talliedAfter(tx, from.entry ?? -1n, walks.verify)).filter(({ seq }) => to === null || seq <= to);
3801
+ if (to !== null && passed.at(-1)?.seq !== to) return {
3802
+ ok: false,
3803
+ failedAtSeq: Number(to),
3804
+ reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner`
3805
+ };
3806
+ const holding = /* @__PURE__ */ new Map();
3807
+ const carried = async (carrier) => {
3808
+ if (!holding.has(carrier)) {
3809
+ const entry = (await entriesAt(tx, [carrier])).get(carrier);
3810
+ const read = entry === void 0 ? "missing" : this.#tallied(entry);
3811
+ holding.set(carrier, typeof read === "string" || "at" in read.tally.access ? null : keptDigest(read.tally.access));
3812
+ }
3813
+ return holding.get(carrier);
3814
+ };
3815
+ const expected = [];
3816
+ let endSigned = null;
3817
+ for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== void 0 && after < last;) {
3818
+ const batch = await talliedWithin(tx, after, last, walks.verify + 1);
3819
+ if (batch.length === 0) break;
3820
+ for (const entry of batch) {
3821
+ const read = this.#tallied(entry);
3822
+ const point = {
3823
+ entry: entry.seq,
3824
+ through: entry.relatedSeq
3825
+ };
3826
+ if (typeof read === "string") {
3827
+ expected.push({
3828
+ ...point,
3829
+ unreadable: read
3830
+ });
3831
+ continue;
3832
+ }
3833
+ if (entry.seq === to) endSigned = read.hash;
3834
+ const { access } = read.tally;
3835
+ if (!("at" in access)) holding.set(entry.seq, keptDigest(access));
3836
+ const kept = "at" in access ? BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null : holding.get(entry.seq);
3837
+ expected.push(kept === access.digest ? {
3838
+ ...point,
3839
+ key: expectedKey(read.tally)
3840
+ } : {
3841
+ ...point,
3842
+ unreadable: kept === null ? `its tally keeps its members and grants in entry ${"at" in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest"
3843
+ });
3844
+ }
3845
+ after = batch.at(-1).seq;
3846
+ }
3847
+ walk.expect(expected);
3848
+ let through = from.at.seq;
3849
+ let reached = headOf(from.at).hash;
3850
+ let present = false;
3851
+ if (upTo === null) {
3852
+ const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
3853
+ if (!chain.ok) return chain;
3854
+ [through, reached, present] = [
3855
+ chain.at.seq,
3856
+ headOf(chain.at).hash,
3857
+ chain.done && ends === null
3858
+ ];
3859
+ this.#prepared.verified = further(this.#prepared.verified, chain.at);
3860
+ } else {
3861
+ const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
3862
+ if (!own.ok) return own;
3863
+ if (!own.done && input.from === void 0) return {
3864
+ ok: false,
3865
+ failedAtSeq: null,
3866
+ reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app`
3867
+ };
3868
+ const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
3869
+ [through, reached, present] = [
3870
+ own.done ? BigInt(upTo.seq) : short,
3871
+ upTo.hash,
3872
+ own.done && ends === null
3873
+ ];
3874
+ }
3875
+ walk.finish(ends ?? through);
3876
+ const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
3877
+ const heads = [...upTo === null ? [] : [upTo], ...rememberedHead === null ? [] : [rememberedHead]];
3878
+ const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
2977
3879
  if (upTo !== null && !holds(upTo)) return {
2978
3880
  ok: false,
2979
3881
  failedAtSeq: upTo.seq,
2980
3882
  reason: "not the entry the app verified up to: the log changed between the two checks"
2981
3883
  };
2982
- const unsigned = await this.#checkpointFault(findings, holds);
3884
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
2983
3885
  if (unsigned !== null) return unsigned;
2984
3886
  if (rememberedHead !== null && !holds(rememberedHead)) return {
2985
3887
  ok: false,
2986
3888
  failedAtSeq: rememberedHead.seq,
2987
3889
  reason: "changed since the vault last verified it"
2988
3890
  };
3891
+ if (walk.mismatch !== null) return {
3892
+ ok: false,
3893
+ failedAtSeq: Number(walk.mismatch.entry),
3894
+ reason: walk.mismatch.reason
3895
+ };
3896
+ if (ends !== null) {
3897
+ if (upTo === null && through !== ends || endSigned !== reached) return {
3898
+ ok: false,
3899
+ failedAtSeq: Number(to),
3900
+ reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten`
3901
+ };
3902
+ return {
3903
+ ok: true,
3904
+ entries: walk.entries,
3905
+ through: Number(ends)
3906
+ };
3907
+ }
3908
+ const standing = await this.#standingRefusal(tx);
3909
+ if (standing !== null) return {
3910
+ ok: false,
3911
+ failedAtSeq: standing.failedAtSeq ?? null,
3912
+ reason: `the last checkpoint was refused: ${standing.reason ?? "the log is broken"}`
3913
+ };
3914
+ const verified = {
3915
+ ok: true,
3916
+ entries: walk.entries,
3917
+ through: Number(through)
3918
+ };
3919
+ if (!present) return verified;
2989
3920
  const at = await this.#now(tx);
2990
- const accounting = findings.accounting.result(at);
3921
+ const accounting = walk.accounting.result(at);
2991
3922
  if (!accounting.ok) return accounting;
2992
3923
  const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
2993
- const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
3924
+ const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
2994
3925
  if (fault !== null) return {
2995
3926
  ok: false,
2996
3927
  failedAtSeq: null,
2997
3928
  reason: describeAccessFault(fault),
2998
3929
  fault
2999
3930
  };
3000
- const verified = {
3001
- ok: true,
3002
- entries: findings.entries
3003
- };
3004
3931
  return accounting.pending === 0 ? verified : {
3005
3932
  ...verified,
3006
3933
  pending: accounting.pending
@@ -3008,55 +3935,15 @@ var VaultService = class {
3008
3935
  }, SNAPSHOT);
3009
3936
  }
3010
3937
  /**
3011
- * The vault's own entries, oldest first: each against its own hash, and by
3012
- * its MAC, over the link it names, which the app checked; and the vault's
3013
- * keys moving only forward through them.
3014
- */
3015
- async #ownEntries(tx, findings) {
3016
- const { logKeys } = this.#prepared;
3017
- let keys = [];
3018
- for (let after = -1n;;) {
3019
- const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
3020
- if (batch.length === 0) return { ok: true };
3021
- for (const entry of batch) {
3022
- const result = verifyEntries([entry], {
3023
- startSeq: entry.seq,
3024
- startPrevHash: entry.prevHash,
3025
- keys: logKeys
3026
- });
3027
- if (!result.ok) return {
3028
- ok: false,
3029
- failedAtSeq: Number(result.failedAtSeq),
3030
- reason: withCause(result.reason)
3031
- };
3032
- }
3033
- const moved = forward(batch, keys, logKeys[0].keyId);
3034
- if ("failedAtSeq" in moved) return {
3035
- ok: false,
3036
- failedAtSeq: Number(moved.failedAtSeq),
3037
- reason: moved.reason
3038
- };
3039
- keys = moved.keys;
3040
- for (const entry of batch) findings.add(entry);
3041
- after = batch[batch.length - 1].seq;
3042
- }
3043
- }
3044
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
3045
- async #rehashed(tx, findings) {
3046
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
3047
- for (const entry of batch) if (entry.author === "vault") findings.add(entry);
3048
- });
3049
- if (!verification.ok) return verification;
3050
- this.#prepared.verified = further(this.#prepared.verified, anchor);
3051
- return { ok: true };
3052
- }
3053
- /**
3054
- * Every checkpoint, not only the newest: each signed by the key whose log
3055
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
3056
- * for entry. Its entry's MAC is checked by now, and one under a key the
3057
- * vault replaced comes before the rotation.
3938
+ * Every checkpoint walked, not only the newest: each signed by the key
3939
+ * whose log key wrote its entry, over a prefix the log still holds,
3940
+ * entry for entry, which one that keeps a tally names as related. Its
3941
+ * entry's MAC is checked by now, and one under a key the vault replaced
3942
+ * comes before the rotation.
3058
3943
  */
3059
- async #checkpointFault(findings, holds) {
3944
+ async #checkpointFault(db, checkpoints, known) {
3945
+ if (checkpoints.length === 0) return null;
3946
+ const holds = known ?? await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint));
3060
3947
  const { logKeys, signers } = this.#prepared;
3061
3948
  const verifiers = /* @__PURE__ */ new Map();
3062
3949
  const signed = (checkpoint, by) => {
@@ -3064,14 +3951,13 @@ var VaultService = class {
3064
3951
  if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
3065
3952
  return verifier.then((verify) => verify(checkpoint));
3066
3953
  };
3067
- const { checkpoints } = findings;
3068
3954
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
3069
3955
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
3070
3956
  const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
3071
3957
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
3072
3958
  if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
3073
3959
  if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
3074
- if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
3960
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
3075
3961
  return null;
3076
3962
  }));
3077
3963
  const at = faults.findIndex((fault) => fault !== null);
@@ -3085,8 +3971,8 @@ var VaultService = class {
3085
3971
  }
3086
3972
  /**
3087
3973
  * The first member whose row fails its MAC, or names an older access
3088
- * entry than the log's newest about them (`newest`, from the full check's
3089
- * pass, where each entry's MAC was checked).
3974
+ * entry than the log's newest about them (`newest`, from the walk, where
3975
+ * each entry's MAC was checked, on from the tally).
3090
3976
  */
3091
3977
  #unsealed(rows, held, newest) {
3092
3978
  for (const row of rows) {
@@ -3105,44 +3991,71 @@ var VaultService = class {
3105
3991
  return null;
3106
3992
  }
3107
3993
  };
3994
+ /** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
3995
+ function timedOut(error) {
3996
+ for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (cause.code === "57014") return true;
3997
+ return false;
3998
+ }
3999
+ const FIRST_ENTRY = {
4000
+ ok: true,
4001
+ entry: null,
4002
+ at: START,
4003
+ tally: EMPTY_TALLY,
4004
+ digest: accessDigest(EMPTY_TALLY.access),
4005
+ carrier: null,
4006
+ scrub: null
4007
+ };
3108
4008
  /**
3109
- * What one pass over the vault's entries gathers for the other checks of a
3110
- * full verification, so that the log is read once: the key batches, the
3111
- * access entries replayed, each member's newest, and the checkpoints. Each
3112
- * entry comes after its MAC was checked.
4009
+ * A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
4010
+ * with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
4011
+ * of catching up, about a second, and a team's instance writes fewer than
4012
+ * a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
4013
+ * and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
3113
4014
  */
3114
- var Findings = class {
3115
- accounting = new KeyAccounting();
3116
- replayed = {
3117
- members: /* @__PURE__ */ new Map(),
3118
- held: /* @__PURE__ */ new Map()
3119
- };
3120
- /** The first access entry the replay could not apply. */
3121
- replayFault = null;
3122
- /** Each member's newest allowed access entry, by seq. */
3123
- newestAccess = /* @__PURE__ */ new Map();
3124
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
3125
- checkpoints = [];
3126
- /** The vault's entries read. */
3127
- entries = 0;
3128
- add(entry) {
3129
- this.entries += 1;
3130
- this.accounting.add(entry);
3131
- if (entry.decision !== "allow") return;
3132
- if (ACCESS.has(entry.action)) {
3133
- this.replayFault ??= apply(this.replayed, entry);
3134
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
3135
- } else if (entry.action === CHECKPOINT) this.checkpoints.push({
3136
- entry: {
3137
- seq: entry.seq,
3138
- keyId: entry.keyId
3139
- },
3140
- checkpoint: JSON.parse(entry.metadata)
3141
- });
3142
- }
4015
+ const WALKS = {
4016
+ checkpoint: 1e4,
4017
+ scrub: 100,
4018
+ verify: 1e4
3143
4019
  };
3144
- const ACCESS = new Set(ACCESS_ACTIONS);
3145
- /** Checkpoint signatures checked at once, in a full verification. */
4020
+ /** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
4021
+ function keptDigest({ members, grants, newest }) {
4022
+ return accessDigest({
4023
+ members,
4024
+ grants,
4025
+ newest
4026
+ });
4027
+ }
4028
+ /** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
4029
+ const parsed = /* @__PURE__ */ new WeakMap();
4030
+ function metadataOf(entry) {
4031
+ if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
4032
+ return parsed.get(entry);
4033
+ }
4034
+ /** A head, as an entry keeps it, as a walk goes on from it. */
4035
+ function positionOf(head) {
4036
+ return {
4037
+ seq: BigInt(head.seq),
4038
+ hash: Buffer.from(head.hash, "hex")
4039
+ };
4040
+ }
4041
+ /** A position as the log's heads are named, in hex. */
4042
+ function headOf(position) {
4043
+ return {
4044
+ seq: Number(position.seq),
4045
+ hash: position.hash.toString("hex")
4046
+ };
4047
+ }
4048
+ /** What a checkpoint's signature covers, and nothing it keeps beside it. */
4049
+ function signedPart({ seq, hash, signedAt, keyId, signature }) {
4050
+ return {
4051
+ seq,
4052
+ hash,
4053
+ signedAt,
4054
+ keyId,
4055
+ signature
4056
+ };
4057
+ }
4058
+ /** Checkpoint signatures checked at once. */
3146
4059
  const SIGNATURES_AT_ONCE = 256;
3147
4060
  /** Settle every operation before releasing the member lock, including cancelled requests. */
3148
4061
  async function settle(operations, budgetMs) {
@@ -3199,6 +4112,7 @@ function validateItems(items, field) {
3199
4112
  if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
3200
4113
  if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
3201
4114
  checkContext(context(secret));
4115
+ if (secret.sensitive !== void 0 && typeof secret.sensitive !== "boolean") throw new Error("a secret is sensitive or not: true, false or absent");
3202
4116
  if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
3203
4117
  if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
3204
4118
  if (key !== void 0) {
@@ -3346,9 +4260,10 @@ function personalRefusal(reader, principal, permission, secret, at, environments
3346
4260
  projectId: secret.projectId,
3347
4261
  environmentId: own.parentId
3348
4262
  }, environments);
3349
- const refused = refuses(reader, "secret.read", parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
4263
+ const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
4264
+ const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
3350
4265
  if (refused !== null) return refused;
3351
- return withinCredential(reader.live, permission, parent) ? null : "no_grant";
4266
+ return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
3352
4267
  }
3353
4268
  function refuses(reader, permission, where, deleted) {
3354
4269
  if (reader.status === "tampered") return "tampered";
@@ -3545,13 +4460,87 @@ function view(grant) {
3545
4460
  function iso(ms) {
3546
4461
  return new Date(ms).toISOString();
3547
4462
  }
3548
- /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
4463
+ /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
4464
+ function readEdits(stored) {
4465
+ const edits = stored.policy;
4466
+ if (typeof edits !== "object" || edits === null) return null;
4467
+ const { roles, instanceRoles, protected: protectedIds } = edits;
4468
+ if (typeof roles !== "object" || roles === null || typeof instanceRoles !== "object" || instanceRoles === null || !Array.isArray(protectedIds)) return null;
4469
+ const defined = (table) => Object.values(table).every((definition) => definitionProblem(definition) === null);
4470
+ if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
4471
+ if (protectedIds.some((place) => typeof place !== "object" || place === null || !UUID.test(place.projectId) || !UUID.test(place.environmentId) || !Number.isSafeInteger(place.at))) return null;
4472
+ return {
4473
+ roles,
4474
+ instanceRoles,
4475
+ protected: protectedIds
4476
+ };
4477
+ }
4478
+ /**
4479
+ * The policy `edits` say as of `at`: the roles as they ship, each edited
4480
+ * one replaced and each made one added; and the protected environments, but
4481
+ * for those deleted since, the project around one's included: a deleted
4482
+ * prod protects nothing, and holds nobody back from giving a service
4483
+ * account a role on all of its project (`givesService`). A fork's id with
4484
+ * no row yet stays, protected before it is written, ten minutes: one with
4485
+ * none after that is a fork refused or abandoned, never written. Each
4486
+ * keeps its slug, which a scoped instance role's filters read. The edits
4487
+ * keep them all: no write is needed.
4488
+ */
4489
+ async function livePolicy(db, edits, at) {
4490
+ const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
4491
+ const live = edits.protected.filter((place) => {
4492
+ const row = rows.get(place.environmentId);
4493
+ return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
4494
+ });
4495
+ const slugOf = (place) => rows.get(place.environmentId)?.slug ?? null;
4496
+ return {
4497
+ roles: {
4498
+ ...ROLES,
4499
+ ...edits.roles
4500
+ },
4501
+ instanceRoles: {
4502
+ ...INSTANCE_ROLES,
4503
+ ...edits.instanceRoles
4504
+ },
4505
+ protected: live.map((place) => place.environmentId),
4506
+ protectedIn: live.reduce((byProject, place) => ({
4507
+ ...byProject,
4508
+ [place.projectId]: [...byProject[place.projectId] ?? [], {
4509
+ environmentId: place.environmentId,
4510
+ slug: slugOf(place)
4511
+ }]
4512
+ }), {})
4513
+ };
4514
+ }
4515
+ /** A change, as its entry names it. */
4516
+ function describeChange(change) {
4517
+ if (change === void 0 || change === null || typeof change !== "object") return null;
4518
+ if (change.kind === "protect") return {
4519
+ kind: "protect",
4520
+ projectId: change.projectId,
4521
+ environmentIds: change.environmentIds,
4522
+ protected: change.protected
4523
+ };
4524
+ return {
4525
+ kind: change.kind,
4526
+ role: change.role,
4527
+ definition: change.definition
4528
+ };
4529
+ }
4530
+ /** What a change changed, as it was: the role's definition, or which environments were protected. */
4531
+ function describeBefore(change, before) {
4532
+ if (change.kind === "protect") return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
4533
+ const table = change.kind === "instance-role" ? before.instanceRoles : before.roles;
4534
+ return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
4535
+ }
4536
+ /** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
3549
4537
  function context(secret, owner) {
3550
4538
  return {
3551
4539
  projectId: secret.projectId,
3552
4540
  environmentId: secret.environmentId,
3553
4541
  secretId: secret.secretId,
3554
- ...owner === void 0 ? {} : { owner }
4542
+ ...owner === void 0 ? {} : { owner },
4543
+ ...secret.sensitive === true ? { sensitive: true } : {}
3555
4544
  };
3556
4545
  }
3557
4546
  function unwrappable(wrapped) {