@coffre/vault 0.10.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
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";
3
+ import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
+ import { prepared, tablesOf } from "@coffre/db";
5
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, newestVaultSeq, 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);
@@ -529,6 +388,51 @@ async function latestVaultEntry(db, actions) {
529
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);
530
389
  return row === void 0 ? void 0 : stored([row])[0];
531
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
+ }
532
436
  /** The vault's newest allowed access entry about each member who has one. */
533
437
  async function newestAccessEntries(db) {
534
438
  const { auditLog } = tablesOf(db);
@@ -621,86 +525,158 @@ async function versions(db, ids) {
621
525
  function vaultLogKey(signingKey) {
622
526
  return deriveLogKey("vault", signingKey);
623
527
  }
624
- /** Before the first entry: nothing verified yet. */
625
- const UNVERIFIED = {
626
- nextSeq: 0n,
627
- hash: GENESIS_HASH,
628
- vaultEntries: 0,
629
- vaultKeys: []
528
+ /** Before the first entry. */
529
+ const START = {
530
+ seq: -1n,
531
+ hash: GENESIS_HASH
630
532
  };
631
- /** The further of two anchors, when two calls verified at once. */
533
+ /** The further of two positions, when two calls walked at once. */
632
534
  function further(a, b) {
633
- return b.nextSeq > a.nextSeq ? b : a;
535
+ return b.seq > a.seq ? b : a;
634
536
  }
635
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. */
636
538
  const VERIFY_BATCH = 5e3;
637
539
  /**
638
- * Check the chain, a batch in memory at a time, and return where it is now
639
- * verified to. Three parts:
640
- *
641
- * - `shown`, the page a reader is looking at: each entry against its own
642
- * hash and MAC;
643
- * - `anchor`, the head at the last check: still there, unchanged. A rewrite
644
- * of anything before it, chained again to hide, changes its hash;
645
- * - 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.
646
546
  *
647
- * So a view rehashes only what is new since the last one. What it leaves
648
- * out is an entry before the anchor edited in place, not chained again, and
649
- * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
650
- * as does the first view after a start, which has no anchor. `onBatch` sees
651
- * 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.
652
549
  */
653
- async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
654
- const broken = (failedAtSeq, reason) => ({
655
- verification: {
656
- ok: false,
657
- failedAtSeq: Number(failedAtSeq),
658
- reason: withCause(reason)
659
- },
660
- anchor
661
- });
662
- const keys = {
663
- keys: logKeys,
664
- chainOnly: ["app"]
665
- };
666
- for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
667
- const result = verifyEntries([row], {
668
- ...keys,
669
- startSeq: row.seq,
670
- 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
671
575
  });
672
- 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;
673
594
  }
674
- 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");
675
- let verified = anchor;
676
- for (;;) {
677
- 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);
678
610
  if (batch.length === 0) break;
679
611
  const result = verifyEntries(batch, {
680
- ...keys,
681
- startSeq: verified.nextSeq,
682
- startPrevHash: verified.hash
612
+ keys: [],
613
+ chainOnly: ["app", "vault"],
614
+ startSeq: at.seq + 1n,
615
+ startPrevHash: at.hash
683
616
  });
684
- if (!result.ok) return broken(result.failedAtSeq, result.reason);
685
- const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
686
- if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
687
- onBatch(batch);
688
- verified = {
689
- nextSeq: result.nextSeq,
690
- hash: result.head,
691
- vaultEntries: verified.vaultEntries + result.authenticated,
692
- vaultKeys: moved.keys
617
+ if (!result.ok) return {
618
+ ok: false,
619
+ failedAtSeq: Number(result.failedAtSeq),
620
+ reason: result.reason
693
621
  };
622
+ at = {
623
+ seq: result.nextSeq - 1n,
624
+ hash: result.head
625
+ };
626
+ limit -= batch.length;
694
627
  }
695
628
  return {
696
- verification: {
697
- ok: true,
698
- entries: verified.vaultEntries
699
- },
700
- anchor: verified
629
+ ok: true,
630
+ at
701
631
  };
702
632
  }
703
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
+ /**
704
680
  * The vault's keys only move forward. Once its entries move from one key to
705
681
  * the next, the one before writes no more; once they reach `current`, the
706
682
  * key it writes with now, no other key writes again. So a key it replaced,
@@ -963,59 +939,429 @@ function rowKey(signingKey) {
963
939
  return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
964
940
  }
965
941
  /**
966
- * A member's grants as the MAC covers them: each one's place, role, end and
967
- * grant, as a sorted set. A grant is kept by what its row names first: its
968
- * environment, its project, or neither, which is a grant on every project
969
- * of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
970
- * set, and so the MAC, they had before there were any.
942
+ * A member's grants as the MAC covers them: each one's place, role, end and
943
+ * grant, as a sorted set. A grant is kept by what its row names first: its
944
+ * environment, its project, or neither, which is a grant on every project
945
+ * of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
946
+ * set, and so the MAC, they had before there were any.
947
+ */
948
+ function grantSet(grants) {
949
+ return grants.map((grant) => [
950
+ grant.environmentId !== null ? "environment" : grant.projectId !== null ? "project" : "every-project",
951
+ grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
952
+ grant.role,
953
+ grant.expiresAt,
954
+ grant.grantedAt,
955
+ grant.grantedBy
956
+ ]).map((tuple) => JSON.stringify(tuple)).sort();
957
+ }
958
+ /** Whether two reads of a member's grants are the same set, as the MAC sees them. */
959
+ function sameGrants(a, b) {
960
+ const [x, y] = [grantSet(a), grantSet(b)];
961
+ return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
962
+ }
963
+ /**
964
+ * A member's instance role and its scope, as the MAC covers them: as 0.4
965
+ * did, whether they are an owner, for a Member or an Admin everywhere, so
966
+ * that every row 0.4 sealed holds as it is; otherwise the role and the
967
+ * scope, which no row of 0.4 can match.
968
+ */
969
+ function standing(member) {
970
+ const scope = normalScope(member.scope);
971
+ if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
972
+ return [member.role, scope];
973
+ }
974
+ function memberMac(key, member, grants) {
975
+ const held = grantSet(grants);
976
+ const tuple = [
977
+ "coffre.vault.member.v1",
978
+ member.principal,
979
+ member.status,
980
+ standing(member),
981
+ member.generation,
982
+ member.accessSeq.toString(),
983
+ member.createdAt,
984
+ member.createdBy,
985
+ member.statusChangedAt,
986
+ member.statusChangedBy,
987
+ held
988
+ ];
989
+ return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
990
+ }
991
+ /** Whether `member`'s MAC is the vault's, over this row and these grants. */
992
+ function sealed(key, member, grants) {
993
+ const expected = memberMac(key, member, grants);
994
+ return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
995
+ }
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.
971
1201
  */
972
- function grantSet(grants) {
973
- return grants.map((grant) => [
974
- grant.environmentId !== null ? "environment" : grant.projectId !== null ? "project" : "every-project",
975
- grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
976
- grant.role,
977
- grant.expiresAt,
978
- grant.grantedAt,
979
- grant.grantedBy
980
- ]).map((tuple) => JSON.stringify(tuple)).sort();
981
- }
982
- /** Whether two reads of a member's grants are the same set, as the MAC sees them. */
983
- function sameGrants(a, b) {
984
- const [x, y] = [grantSet(a), grantSet(b)];
985
- return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
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
+ });
986
1213
  }
987
1214
  /**
988
- * A member's instance role and its scope, as the MAC covers them: as 0.4
989
- * did, whether they are an owner, for a Member or an Admin everywhere, so
990
- * that every row 0.4 sealed holds as it is; otherwise the role and the
991
- * scope, which no row of 0.4 can match.
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.
992
1217
  */
993
- function standing(member) {
994
- const scope = normalScope(member.scope);
995
- if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
996
- return [member.role, scope];
997
- }
998
- function memberMac(key, member, grants) {
999
- const held = grantSet(grants);
1000
- const tuple = [
1001
- "coffre.vault.member.v1",
1002
- member.principal,
1003
- member.status,
1004
- standing(member),
1005
- member.generation,
1006
- member.accessSeq.toString(),
1007
- member.createdAt,
1008
- member.createdBy,
1009
- member.statusChangedAt,
1010
- member.statusChangedBy,
1011
- held
1012
- ];
1013
- return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
1218
+ function expectedKey(stored) {
1219
+ return tallyKey(stored, stored.access.digest);
1014
1220
  }
1015
- /** Whether `member`'s MAC is the vault's, over this row and these grants. */
1016
- function sealed(key, member, grants) {
1017
- const expected = memberMac(key, member, grants);
1018
- return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
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);
1019
1365
  }
1020
1366
  //#endregion
1021
1367
  //#region src/vault.ts
@@ -1025,9 +1371,13 @@ async function prepareVault(config, options = {}) {
1025
1371
  ...await keysOf(config.signingKeys),
1026
1372
  options: {
1027
1373
  keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
1028
- clockOffset: options.clockOffset ?? (() => 0)
1374
+ clockOffset: options.clockOffset ?? (() => 0),
1375
+ walks: {
1376
+ ...WALKS,
1377
+ ...options.walks
1378
+ }
1029
1379
  },
1030
- verified: UNVERIFIED,
1380
+ verified: START,
1031
1381
  reported: /* @__PURE__ */ new Set(),
1032
1382
  since: null,
1033
1383
  superseded: null,
@@ -1076,8 +1426,6 @@ const UNSEALED = {
1076
1426
  };
1077
1427
  /** Why a change to a tampered member is refused. */
1078
1428
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
1079
- /** The action of the vault's entry that signs a prefix of the log. */
1080
- const CHECKPOINT = "audit.checkpoint";
1081
1429
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
1082
1430
  const SETTINGS = "settings.change";
1083
1431
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
@@ -1122,12 +1470,6 @@ function declaredOwner(principal) {
1122
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";
1123
1471
  /** Who asks for checkpoints: the app's scheduled job. */
1124
1472
  const SCHEDULER = "system:coffre-scheduler";
1125
- /**
1126
- * How often a checkpoint recomputes the chain from its first entry: the
1127
- * first of each hour, by the vault's clock. The others resume from the
1128
- * prefix the last one signed (`#recompute`).
1129
- */
1130
- const FULL_RECOMPUTE_MS = 36e5;
1131
1473
  /** A refusal and the entries that record it. */
1132
1474
  var Refused = class {
1133
1475
  refusal;
@@ -1372,14 +1714,17 @@ var VaultService = class {
1372
1714
  }
1373
1715
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
1374
1716
  #authentic(entry) {
1375
- const { logKeys, since } = this.#prepared;
1376
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
1377
1717
  return verifyEntries([entry], {
1378
1718
  startSeq: entry.seq,
1379
1719
  startPrevHash: entry.prevHash,
1380
- keys
1720
+ keys: this.#keysFor(entry)
1381
1721
  }).ok;
1382
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
+ }
1383
1728
  /**
1384
1729
  * Whether `row`, with these grants, carries the MAC of the vault's row
1385
1730
  * key, or of one it replaced before the rotation sealed every row again
@@ -1933,15 +2278,19 @@ var VaultService = class {
1933
2278
  if (!intent.ok) return intent;
1934
2279
  intentSeq = intent.seq;
1935
2280
  }
1936
- return this.#decide([principal], async (d) => {
2281
+ const late = call.remote;
2282
+ const decided = async (d) => {
1937
2283
  try {
1938
2284
  await check(d);
1939
2285
  } catch (error) {
1940
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)));
1941
2287
  throw error;
1942
2288
  }
2289
+ };
2290
+ return this.#decide([principal], async (d) => {
2291
+ await decided(d);
1943
2292
  const { vet } = call;
1944
- if (vet !== void 0) d.underLock.push(async (locked) => {
2293
+ if (late && vet !== void 0) d.underLock.push(async (locked) => {
1945
2294
  const codes = await vet(locked);
1946
2295
  const first = codes.find((code) => code !== null);
1947
2296
  if (first === void 0 || first === null) return;
@@ -1957,16 +2306,13 @@ var VaultService = class {
1957
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());
1958
2307
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
1959
2308
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
1960
- await lockLogHead(d.tx);
1961
- try {
1962
- await check(d);
1963
- } catch (error) {
1964
- if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
1965
- throw error;
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")));
1966
2315
  }
1967
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1968
- const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
1969
- 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")));
1970
2316
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
1971
2317
  d.log.push(...released);
1972
2318
  const answer = result(done);
@@ -1975,7 +2321,7 @@ var VaultService = class {
1975
2321
  } finally {
1976
2322
  for (const value of done) value.wipe();
1977
2323
  }
1978
- }, true);
2324
+ }, late);
1979
2325
  }
1980
2326
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
1981
2327
  async #overBulkLimit(d, principal, n) {
@@ -2760,7 +3106,7 @@ var VaultService = class {
2760
3106
  * the default: otherwise whoever can insert rows could open it again.
2761
3107
  */
2762
3108
  async #settingsAt(db, reports) {
2763
- 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));
2764
3110
  if (entry === "none") return DEFAULT_SETTINGS;
2765
3111
  if (typeof entry === "string") return CLOSED_SETTINGS;
2766
3112
  const { serviceAccounts } = JSON.parse(entry.metadata);
@@ -2782,7 +3128,7 @@ var VaultService = class {
2782
3128
  * could unprotect them. Its edits are then unknown, not none.
2783
3129
  */
2784
3130
  async #policyAt(db, reports, at) {
2785
- const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY, LOOK_BACK.page, before), this.#check(reports, null));
3131
+ const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
2786
3132
  if (entry === "none") return {
2787
3133
  policy: DEFAULT_POLICY,
2788
3134
  edits: NO_EDITS,
@@ -3077,63 +3423,162 @@ var VaultService = class {
3077
3423
  }
3078
3424
  return checks;
3079
3425
  }
3080
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
3081
- async #latest(db) {
3082
- const row = await latestVaultEntry(db, [CHECKPOINT]);
3083
- return row === void 0 ? null : {
3084
- checkpoint: JSON.parse(row.metadata),
3085
- seq: row.seq,
3086
- 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"
3087
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;
3088
3511
  }
3089
3512
  /**
3090
- * The chain recomputed to its last entry, for a checkpoint to sign: from
3091
- * its first entry at the first checkpoint of each hour, and otherwise from
3092
- * the prefix the last checkpoint signed, if that one was signed this hour.
3093
- *
3094
- * Resuming never signs what the vault did not recompute. The vault
3095
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
3096
- * hour's first checkpoint and onward at each one since, and the entry
3097
- * that holds it is the first one checked again, by its MAC. The next
3098
- * signature extends that same chain.
3513
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
3099
3514
  *
3100
- * What resuming does not do is recompute the rows of that prefix again.
3101
- * They can change while the hash stored at its end stays, so it first
3102
- * checks the two things that are cheap: that hash is still there, which a
3103
- * prefix rewritten and chained again changes, and every entry of the
3104
- * prefix is, which a cut changes. Either failing, it recomputes from entry
3105
- * 0, which names what broke. An entry edited in place, its hash left as
3106
- * 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.
3107
3524
  *
3108
- * It resumes only from a checkpoint under the key the vault writes with
3109
- * now: once the log reaches that key, no other writes again (`forward`).
3110
- * 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.
3111
3529
  */
3112
- async #recompute(tx) {
3113
- const { logKeys } = this.#prepared;
3114
- const latest = await this.#latest(tx);
3115
- const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
3116
- 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);
3117
- const { seq, hash } = latest.checkpoint;
3118
- return verifyChain(tx, logKeys, [], {
3119
- nextSeq: BigInt(seq) + 1n,
3120
- hash: Buffer.from(hash, "hex"),
3121
- vaultEntries: 0,
3122
- vaultKeys: [logKeys[0].keyId]
3123
- });
3124
- }
3125
3530
  async checkpoint() {
3126
3531
  await this.#settled();
3127
- const { wrongKek } = this.#prepared;
3532
+ const { wrongKek, logKeys } = this.#prepared;
3533
+ const { walks } = this.#prepared.options;
3128
3534
  const found = [];
3129
- 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;
3130
3538
  await this.#sweep(tx, found);
3131
- 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
+ };
3132
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
+ }
3133
3578
  return this.#decide([], async (d) => {
3134
3579
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
3135
3580
  const head = await lockLogHead(d.tx);
3136
- const latest = await this.#latest(d.tx);
3581
+ const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
3137
3582
  const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
3138
3583
  actor: SCHEDULER,
3139
3584
  action: CHECKPOINT,
@@ -3143,21 +3588,57 @@ var VaultService = class {
3143
3588
  }]);
3144
3589
  if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
3145
3590
  if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
3146
- if (!whole.verification.ok) throw refused("log_broken", {
3147
- failedAtSeq: whole.verification.failedAtSeq,
3148
- reason: whole.verification.reason
3591
+ if (!walked.ok) throw refused("log_broken", {
3592
+ failedAtSeq: walked.failedAtSeq,
3593
+ reason: walked.reason
3149
3594
  });
3150
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
3151
- 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` });
3152
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
3153
- if (!held.ok) throw refused("log_broken", {
3154
- failedAtSeq: held.failedAtSeq,
3155
- reason: held.reason
3595
+ if (!scrub.ok) throw refused("log_broken", {
3596
+ failedAtSeq: scrub.failedAtSeq,
3597
+ reason: scrub.reason
3156
3598
  });
3157
- 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
+ };
3158
3639
  const signed = {
3159
- seq: Number(verified.nextSeq - 1n),
3160
- hash: verified.hash.toString("hex"),
3640
+ seq: Number(at.seq),
3641
+ hash: at.hash.toString("hex"),
3161
3642
  signedAt: iso(d.at)
3162
3643
  };
3163
3644
  const checkpoint = {
@@ -3165,15 +3646,85 @@ var VaultService = class {
3165
3646
  keyId: this.#prepared.signer.keyId,
3166
3647
  signature: await this.#prepared.signer.sign(checkpointMessage(signed))
3167
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
+ };
3168
3662
  d.log.push({
3169
3663
  actor: SCHEDULER,
3170
3664
  action: CHECKPOINT,
3171
3665
  decision: "allow",
3172
- metadata: JSON.stringify(checkpoint)
3666
+ relatedSeq: at.seq,
3667
+ metadata: JSON.stringify(kept)
3173
3668
  });
3174
- return { checkpoint };
3669
+ return stalled === null ? { checkpoint } : {
3670
+ checkpoint,
3671
+ warning: stalled
3672
+ };
3175
3673
  });
3176
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
+ }
3177
3728
  async about() {
3178
3729
  await this.#settled();
3179
3730
  const { signers, since } = this.#prepared;
@@ -3202,65 +3753,181 @@ var VaultService = class {
3202
3753
  checks
3203
3754
  };
3204
3755
  }
3205
- verifyLog(input) {
3206
- return this.#verifyAll(input.upTo ?? null);
3207
- }
3208
3756
  /**
3209
- * The whole log, in one snapshot and one pass over the vault's entries:
3210
- * each by its MAC, the keys it moved through, and what the other checks
3211
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
3212
- * heads that must still be there, the key batches accounted for, and the
3213
- * 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.
3214
3763
  *
3215
3764
  * With `upTo`, the head the app verified to, the links and hashes of the
3216
- * chain are the app's to recompute, as it just has, over its own snapshot,
3217
- * through that head; `carries(upTo)` ties this snapshot to that one. So
3218
- * the vault reads only its own entries, each checked against its own hash
3219
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
3220
- * 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`).
3221
3771
  */
3222
- async #verifyAll(upTo) {
3772
+ async verifyLog(input) {
3223
3773
  await this.#settled();
3774
+ const upTo = input.upTo ?? null;
3775
+ const { logKeys } = this.#prepared;
3776
+ const { walks } = this.#prepared.options;
3224
3777
  return this.#db.transaction(async (tx) => {
3225
3778
  const remembered = this.#prepared.verified;
3226
- const findings = new Findings();
3227
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
3228
- if (!read.ok) return read;
3229
- const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
3230
- const rememberedHead = remembered.nextSeq > 0n ? {
3231
- seq: Number(remembered.nextSeq - 1n),
3232
- hash: remembered.hash.toString("hex")
3233
- } : null;
3234
- if (rememberedHead !== null) heads.push(rememberedHead);
3235
- const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
3236
- 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)]);
3237
3879
  if (upTo !== null && !holds(upTo)) return {
3238
3880
  ok: false,
3239
3881
  failedAtSeq: upTo.seq,
3240
3882
  reason: "not the entry the app verified up to: the log changed between the two checks"
3241
3883
  };
3242
- const unsigned = await this.#checkpointFault(findings, holds);
3884
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
3243
3885
  if (unsigned !== null) return unsigned;
3244
3886
  if (rememberedHead !== null && !holds(rememberedHead)) return {
3245
3887
  ok: false,
3246
3888
  failedAtSeq: rememberedHead.seq,
3247
3889
  reason: "changed since the vault last verified it"
3248
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;
3249
3920
  const at = await this.#now(tx);
3250
- const accounting = findings.accounting.result(at);
3921
+ const accounting = walk.accounting.result(at);
3251
3922
  if (!accounting.ok) return accounting;
3252
3923
  const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
3253
- 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);
3254
3925
  if (fault !== null) return {
3255
3926
  ok: false,
3256
3927
  failedAtSeq: null,
3257
3928
  reason: describeAccessFault(fault),
3258
3929
  fault
3259
3930
  };
3260
- const verified = {
3261
- ok: true,
3262
- entries: findings.entries
3263
- };
3264
3931
  return accounting.pending === 0 ? verified : {
3265
3932
  ...verified,
3266
3933
  pending: accounting.pending
@@ -3268,55 +3935,15 @@ var VaultService = class {
3268
3935
  }, SNAPSHOT);
3269
3936
  }
3270
3937
  /**
3271
- * The vault's own entries, oldest first: each against its own hash, and by
3272
- * its MAC, over the link it names, which the app checked; and the vault's
3273
- * keys moving only forward through them.
3274
- */
3275
- async #ownEntries(tx, findings) {
3276
- const { logKeys } = this.#prepared;
3277
- let keys = [];
3278
- for (let after = -1n;;) {
3279
- const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
3280
- if (batch.length === 0) return { ok: true };
3281
- for (const entry of batch) {
3282
- const result = verifyEntries([entry], {
3283
- startSeq: entry.seq,
3284
- startPrevHash: entry.prevHash,
3285
- keys: logKeys
3286
- });
3287
- if (!result.ok) return {
3288
- ok: false,
3289
- failedAtSeq: Number(result.failedAtSeq),
3290
- reason: withCause(result.reason)
3291
- };
3292
- }
3293
- const moved = forward(batch, keys, logKeys[0].keyId);
3294
- if ("failedAtSeq" in moved) return {
3295
- ok: false,
3296
- failedAtSeq: Number(moved.failedAtSeq),
3297
- reason: moved.reason
3298
- };
3299
- keys = moved.keys;
3300
- for (const entry of batch) findings.add(entry);
3301
- after = batch[batch.length - 1].seq;
3302
- }
3303
- }
3304
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
3305
- async #rehashed(tx, findings) {
3306
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
3307
- for (const entry of batch) if (entry.author === "vault") findings.add(entry);
3308
- });
3309
- if (!verification.ok) return verification;
3310
- this.#prepared.verified = further(this.#prepared.verified, anchor);
3311
- return { ok: true };
3312
- }
3313
- /**
3314
- * Every checkpoint, not only the newest: each signed by the key whose log
3315
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
3316
- * for entry. Its entry's MAC is checked by now, and one under a key the
3317
- * 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.
3318
3943
  */
3319
- 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));
3320
3947
  const { logKeys, signers } = this.#prepared;
3321
3948
  const verifiers = /* @__PURE__ */ new Map();
3322
3949
  const signed = (checkpoint, by) => {
@@ -3324,14 +3951,13 @@ var VaultService = class {
3324
3951
  if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
3325
3952
  return verifier.then((verify) => verify(checkpoint));
3326
3953
  };
3327
- const { checkpoints } = findings;
3328
3954
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
3329
3955
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
3330
3956
  const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
3331
3957
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
3332
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`;
3333
3959
  if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
3334
- 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`;
3335
3961
  return null;
3336
3962
  }));
3337
3963
  const at = faults.findIndex((fault) => fault !== null);
@@ -3345,8 +3971,8 @@ var VaultService = class {
3345
3971
  }
3346
3972
  /**
3347
3973
  * The first member whose row fails its MAC, or names an older access
3348
- * entry than the log's newest about them (`newest`, from the full check's
3349
- * 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).
3350
3976
  */
3351
3977
  #unsealed(rows, held, newest) {
3352
3978
  for (const row of rows) {
@@ -3365,44 +3991,71 @@ var VaultService = class {
3365
3991
  return null;
3366
3992
  }
3367
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
+ };
3368
4008
  /**
3369
- * What one pass over the vault's entries gathers for the other checks of a
3370
- * full verification, so that the log is read once: the key batches, the
3371
- * access entries replayed, each member's newest, and the checkpoints. Each
3372
- * 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.
3373
4014
  */
3374
- var Findings = class {
3375
- accounting = new KeyAccounting();
3376
- replayed = {
3377
- members: /* @__PURE__ */ new Map(),
3378
- held: /* @__PURE__ */ new Map()
3379
- };
3380
- /** The first access entry the replay could not apply. */
3381
- replayFault = null;
3382
- /** Each member's newest allowed access entry, by seq. */
3383
- newestAccess = /* @__PURE__ */ new Map();
3384
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
3385
- checkpoints = [];
3386
- /** The vault's entries read. */
3387
- entries = 0;
3388
- add(entry) {
3389
- this.entries += 1;
3390
- this.accounting.add(entry);
3391
- if (entry.decision !== "allow") return;
3392
- if (ACCESS.has(entry.action)) {
3393
- this.replayFault ??= apply(this.replayed, entry);
3394
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
3395
- } else if (entry.action === CHECKPOINT) this.checkpoints.push({
3396
- entry: {
3397
- seq: entry.seq,
3398
- keyId: entry.keyId
3399
- },
3400
- checkpoint: JSON.parse(entry.metadata)
3401
- });
3402
- }
4015
+ const WALKS = {
4016
+ checkpoint: 1e4,
4017
+ scrub: 100,
4018
+ verify: 1e4
3403
4019
  };
3404
- const ACCESS = new Set(ACCESS_ACTIONS);
3405
- /** 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. */
3406
4059
  const SIGNATURES_AT_ONCE = 256;
3407
4060
  /** Settle every operation before releasing the member lock, including cancelled requests. */
3408
4061
  async function settle(operations, budgetMs) {