@coffre/vault 0.1.12 → 0.1.13

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,4 +1,4 @@
1
- import { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFault } 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
3
  import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
@@ -8,6 +8,164 @@ import { checkContext } from "@coffre/core/envelope";
8
8
  import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
9
9
  import { appendEntries, lockLogHead } from "@coffre/db/log";
10
10
  import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from "drizzle-orm";
11
+ //#region src/accounting.ts
12
+ const KEY_ACTIONS = /* @__PURE__ */ new Set([
13
+ "secret.read",
14
+ "key.wrap",
15
+ "key.rewrap"
16
+ ]);
17
+ /**
18
+ * Every key operation's intent and its outcomes, entry by entry, oldest
19
+ * first, as the full check reads the vault's entries (`add`); then what is
20
+ * still open (`result`). The chain can hold while a process died between an
21
+ * intent and its outcomes: only a missed deadline is a fault.
22
+ */
23
+ var KeyAccounting = class {
24
+ #intents = /* @__PURE__ */ new Map();
25
+ #ids = /* @__PURE__ */ new Set();
26
+ #fault = null;
27
+ add(entry) {
28
+ if (this.#fault !== null || entry.author !== "vault") return;
29
+ const broken = (reason) => {
30
+ this.#fault = {
31
+ ok: false,
32
+ failedAtSeq: Number(entry.seq),
33
+ reason
34
+ };
35
+ };
36
+ if (entry.action === "key.intent") {
37
+ const parsed = parseIntent(entry);
38
+ if (typeof parsed === "string") return broken(parsed);
39
+ if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
40
+ this.#ids.add(parsed.intentId);
41
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
42
+ ...parsed,
43
+ entry,
44
+ seen: /* @__PURE__ */ new Set()
45
+ });
46
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
47
+ const parsed = parseOutcome(entry);
48
+ if (typeof parsed === "string") return broken(parsed);
49
+ const intent = this.#intents.get(parsed.relatedSeq);
50
+ const key = intent?.keys[parsed.item];
51
+ if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken("key outcome does not identify one item of its intent");
52
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
53
+ if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken("key outcome does not match its intended secret");
54
+ intent.seen.add(parsed.item);
55
+ if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
56
+ }
57
+ }
58
+ /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
59
+ result(at) {
60
+ if (this.#fault !== null) return this.#fault;
61
+ const intents = [...this.#intents.values()];
62
+ const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
63
+ if (overdue.length === 0) return {
64
+ ok: true,
65
+ pending: intents.length
66
+ };
67
+ const reason = overdue.map((intent) => {
68
+ const missing = intent.keys.length - intent.seen.size;
69
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
70
+ }).join("; ");
71
+ return {
72
+ ok: false,
73
+ failedAtSeq: Number(overdue[0].entry.seq),
74
+ reason
75
+ };
76
+ }
77
+ };
78
+ function parseIntent(entry) {
79
+ const detail = payload(entry.metadata);
80
+ if (detail === null) return "key intent has no valid accounting payload";
81
+ const { intent, operation, expiresAt } = detail;
82
+ if (typeof intent !== "string") return "key intent has no operation identity";
83
+ if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
84
+ if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
85
+ if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
86
+ const keys = [];
87
+ for (const [index, value] of detail.keys.entries()) {
88
+ const key = record(value);
89
+ if (key === null || key.item !== index) return "key intent items are not numbered in order";
90
+ const { secretId, version, subject } = key;
91
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
92
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
93
+ keys.push({
94
+ item: index,
95
+ secretId,
96
+ version,
97
+ subject
98
+ });
99
+ }
100
+ return {
101
+ intentId: intent,
102
+ operation,
103
+ expiresAt,
104
+ keys
105
+ };
106
+ }
107
+ function parseOutcome(entry) {
108
+ const detail = payload(entry.metadata);
109
+ if (detail === null) return "key outcome has no valid accounting payload";
110
+ const { intent, item, version, subject } = detail;
111
+ if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
112
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
113
+ const secretId = entry.secretId ?? detail.secretId;
114
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
115
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
116
+ return {
117
+ intentId: intent,
118
+ relatedSeq: entry.relatedSeq,
119
+ item,
120
+ secretId,
121
+ version,
122
+ subject
123
+ };
124
+ }
125
+ function payload(text) {
126
+ try {
127
+ return record(JSON.parse(text));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ function record(value) {
133
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
134
+ }
135
+ //#endregion
136
+ //#region src/checkpoint.ts
137
+ const PKCS8_ED25519 = [
138
+ 48,
139
+ 46,
140
+ 2,
141
+ 1,
142
+ 0,
143
+ 48,
144
+ 5,
145
+ 6,
146
+ 3,
147
+ 43,
148
+ 101,
149
+ 112,
150
+ 4,
151
+ 34,
152
+ 4,
153
+ 32
154
+ ];
155
+ /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
156
+ async function signer(seed) {
157
+ const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
158
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
159
+ const { x } = await crypto.subtle.exportKey("jwk", privateKey);
160
+ const publicKey = Buffer.from(x, "base64url");
161
+ const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
162
+ return {
163
+ publicKey: publicKey.toString("base64"),
164
+ keyId: digest.toString("hex").slice(0, 16),
165
+ sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
166
+ };
167
+ }
168
+ //#endregion
11
169
  //#region src/store.ts
12
170
  /**
13
171
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
@@ -178,6 +336,26 @@ async function entriesFrom(db, fromSeq, limit) {
178
336
  const { auditLog } = tablesOf(db);
179
337
  return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
180
338
  }
339
+ /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
340
+ async function vaultEntriesAfter(db, afterSeq, limit) {
341
+ const { auditLog } = tablesOf(db);
342
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
343
+ }
344
+ /** The hash of the entry at each of `seqs` the log holds, by seq. */
345
+ async function hashesAt(db, seqs) {
346
+ const { auditLog } = tablesOf(db);
347
+ const hashes = /* @__PURE__ */ new Map();
348
+ for (let from = 0; from < seqs.length; from += HASHES_AT_ONCE) {
349
+ const rows = await db.select({
350
+ seq: auditLog.seq,
351
+ hash: auditLog.hash
352
+ }).from(auditLog).where(inArray(auditLog.seq, seqs.slice(from, from + HASHES_AT_ONCE)));
353
+ for (const { seq, hash } of rows) hashes.set(seq, hash);
354
+ }
355
+ return hashes;
356
+ }
357
+ /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
358
+ const HASHES_AT_ONCE = 5e3;
181
359
  /** The entry at `seq`'s hash, or undefined when there is none. */
182
360
  async function hashAt(db, seq) {
183
361
  const { auditLog } = tablesOf(db);
@@ -283,7 +461,8 @@ const UNVERIFIED = {
283
461
  function further(a, b) {
284
462
  return b.nextSeq > a.nextSeq ? b : a;
285
463
  }
286
- const VERIFY_BATCH = 1e3;
464
+ /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
465
+ const VERIFY_BATCH = 5e3;
287
466
  /**
288
467
  * Check the chain, a batch in memory at a time, and return where it is now
289
468
  * verified to. Three parts:
@@ -297,9 +476,10 @@ const VERIFY_BATCH = 1e3;
297
476
  * So a view rehashes only what is new since the last one. What it leaves
298
477
  * out is an entry before the anchor edited in place, not chained again, and
299
478
  * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
300
- * as does the first view after a start, which has no anchor.
479
+ * as does the first view after a start, which has no anchor. `onBatch` sees
480
+ * each batch once it has verified, for checks that read the same entries.
301
481
  */
302
- async function verifyChain(db, logKeys, shown, anchor) {
482
+ async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
303
483
  const broken = (failedAtSeq, reason) => ({
304
484
  verification: {
305
485
  ok: false,
@@ -333,13 +513,14 @@ async function verifyChain(db, logKeys, shown, anchor) {
333
513
  if (!result.ok) return broken(result.failedAtSeq, result.reason);
334
514
  const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
335
515
  if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
516
+ onBatch(batch);
336
517
  verified = {
337
518
  nextSeq: result.nextSeq,
338
519
  hash: result.head,
339
520
  vaultEntries: verified.vaultEntries + result.authenticated,
340
521
  vaultKeys: moved.keys
341
522
  };
342
- if (batch.length < 1e3) break;
523
+ if (batch.length < 5e3) break;
343
524
  }
344
525
  return {
345
526
  verification: {
@@ -389,156 +570,7 @@ async function carries(db, head) {
389
570
  return (await hashAt(db, BigInt(head.seq)))?.toString("hex") === head.hash;
390
571
  }
391
572
  //#endregion
392
- //#region src/accounting.ts
393
- const KEY_ACTIONS = /* @__PURE__ */ new Set([
394
- "secret.read",
395
- "key.wrap",
396
- "key.rewrap"
397
- ]);
398
- /** The chain can hold while a process died between an intent and its outcomes. */
399
- async function verifyAccounting(db, at) {
400
- const intents = /* @__PURE__ */ new Map();
401
- const ids = /* @__PURE__ */ new Set();
402
- const broken = (entry, reason) => ({
403
- ok: false,
404
- failedAtSeq: Number(entry.seq),
405
- reason
406
- });
407
- let next = 0n;
408
- for (;;) {
409
- const batch = await entriesFrom(db, next, VERIFY_BATCH);
410
- if (batch.length === 0) break;
411
- for (const entry of batch) {
412
- if (entry.author !== "vault") continue;
413
- if (entry.action === "key.intent") {
414
- const parsed = parseIntent(entry);
415
- if (typeof parsed === "string") return broken(entry, parsed);
416
- if (ids.has(parsed.intentId)) return broken(entry, "key intent repeats an operation identity");
417
- ids.add(parsed.intentId);
418
- if (parsed.keys.length > 0) intents.set(entry.seq, {
419
- ...parsed,
420
- entry,
421
- seen: /* @__PURE__ */ new Set()
422
- });
423
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
424
- const parsed = parseOutcome(entry);
425
- if (typeof parsed === "string") return broken(entry, parsed);
426
- const intent = intents.get(parsed.relatedSeq);
427
- const key = intent?.keys[parsed.item];
428
- if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken(entry, "key outcome does not identify one item of its intent");
429
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken(entry, "key outcome belongs to another operation");
430
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken(entry, "key outcome does not match its intended secret");
431
- intent.seen.add(parsed.item);
432
- if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
433
- }
434
- }
435
- next = batch[batch.length - 1].seq + 1n;
436
- if (batch.length < 1e3) break;
437
- }
438
- const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
439
- if (overdue.length === 0) return {
440
- ok: true,
441
- pending: intents.size
442
- };
443
- const reason = overdue.map((intent) => {
444
- const missing = intent.keys.length - intent.seen.size;
445
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
446
- }).join("; ");
447
- return broken(overdue[0].entry, reason);
448
- }
449
- function parseIntent(entry) {
450
- const detail = payload(entry.metadata);
451
- if (detail === null) return "key intent has no valid accounting payload";
452
- const { intent, operation, expiresAt } = detail;
453
- if (typeof intent !== "string") return "key intent has no operation identity";
454
- if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
455
- if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
456
- if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
457
- const keys = [];
458
- for (const [index, value] of detail.keys.entries()) {
459
- const key = record(value);
460
- if (key === null || key.item !== index) return "key intent items are not numbered in order";
461
- const { secretId, version, subject } = key;
462
- if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
463
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
464
- keys.push({
465
- item: index,
466
- secretId,
467
- version,
468
- subject
469
- });
470
- }
471
- return {
472
- intentId: intent,
473
- operation,
474
- expiresAt,
475
- keys
476
- };
477
- }
478
- function parseOutcome(entry) {
479
- const detail = payload(entry.metadata);
480
- if (detail === null) return "key outcome has no valid accounting payload";
481
- const { intent, item, version, subject } = detail;
482
- if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
483
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
484
- const secretId = entry.secretId ?? detail.secretId;
485
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
486
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
487
- return {
488
- intentId: intent,
489
- relatedSeq: entry.relatedSeq,
490
- item,
491
- secretId,
492
- version,
493
- subject
494
- };
495
- }
496
- function payload(text) {
497
- try {
498
- return record(JSON.parse(text));
499
- } catch {
500
- return null;
501
- }
502
- }
503
- function record(value) {
504
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
505
- }
506
- //#endregion
507
- //#region src/checkpoint.ts
508
- const PKCS8_ED25519 = [
509
- 48,
510
- 46,
511
- 2,
512
- 1,
513
- 0,
514
- 48,
515
- 5,
516
- 6,
517
- 3,
518
- 43,
519
- 101,
520
- 112,
521
- 4,
522
- 34,
523
- 4,
524
- 32
525
- ];
526
- /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
527
- async function signer(seed) {
528
- const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
529
- const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
530
- const { x } = await crypto.subtle.exportKey("jwk", privateKey);
531
- const publicKey = Buffer.from(x, "base64url");
532
- const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
533
- return {
534
- publicKey: publicKey.toString("base64"),
535
- keyId: digest.toString("hex").slice(0, 16),
536
- sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
537
- };
538
- }
539
- //#endregion
540
573
  //#region src/replay.ts
541
- const BATCH = 1e3;
542
574
  /**
543
575
  * Why the members and grants in the database do not follow from the
544
576
  * vault's entries, or null when they do; `describeAccessFault` words it.
@@ -548,28 +580,15 @@ const BATCH = 1e3;
548
580
  * written around the vault: a grant inserted with the database's own login,
549
581
  * a removal undone.
550
582
  *
551
- * Call it after the chain is verified, in the same snapshot, so every entry
552
- * it replays carries the vault's MAC.
583
+ * `state` is the allowed access entries replayed (`apply`), each after its
584
+ * MAC was checked, in the snapshot `rows` and `stored` were read in.
553
585
  *
554
586
  * Grants are compared as they are live at `at`. Clearing one that has
555
587
  * lapsed changes nothing anyone holds, so it is not logged.
556
588
  */
557
- async function replay(db, at) {
558
- const state = {
559
- members: /* @__PURE__ */ new Map(),
560
- held: /* @__PURE__ */ new Map()
561
- };
562
- for (let after = -1n;;) {
563
- const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
564
- for (const row of batch) {
565
- const fault = apply(state, row);
566
- if (fault !== null) return fault;
567
- }
568
- if (batch.length < BATCH) break;
569
- after = batch[batch.length - 1].seq;
570
- }
589
+ function replayFault(state, rows, storedGrants, at) {
571
590
  const { members, held } = state;
572
- const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
591
+ const stored = new Map(rows.map((member) => [member.principal, member]));
573
592
  for (const principal of [.../* @__PURE__ */ new Set([...stored.keys(), ...members.keys()])].sort()) {
574
593
  const [inStore, inLog] = [stored.get(principal), members.get(principal)];
575
594
  if (inLog === void 0) return {
@@ -589,7 +608,7 @@ async function replay(db, at) {
589
608
  }
590
609
  const live = (grant) => grant.expiresAt === null || grant.expiresAt > at;
591
610
  const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
592
- const inStore = new Set((await grants(db)).filter(live).map(grantKey));
611
+ const inStore = new Set(storedGrants.filter(live).map(grantKey));
593
612
  const extra = [...inStore].sort().find((grant) => !logged.has(grant));
594
613
  if (extra !== void 0) return {
595
614
  kind: "unlogged-grant",
@@ -2072,99 +2091,152 @@ var VaultService = class {
2072
2091
  };
2073
2092
  }
2074
2093
  verifyLog(input) {
2075
- return this.#verifyAll([], input.upTo ?? null);
2076
- }
2077
- /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
2078
- async #verify(db, shown, anchor) {
2079
- const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
2080
- if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
2081
- return verification;
2094
+ return this.#verifyAll(input.upTo ?? null);
2082
2095
  }
2083
2096
  /**
2084
- * `shown`, and the chain from its first entry, in one snapshot: every
2085
- * link and hash, the vault's MACs; then the heads that must still be
2086
- * there: the one the app verified up to (`upTo`), so both authors are
2087
- * checked over the same entries, the one the app last recorded from a
2088
- * checkpoint (`through`), and the last checkpoint's; and the members and
2089
- * grants replayed from it.
2097
+ * The whole log, in one snapshot and one pass over the vault's entries:
2098
+ * each by its MAC, the keys it moved through, and what the other checks
2099
+ * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2100
+ * heads that must still be there, the key batches accounted for, and the
2101
+ * members and grants replayed.
2102
+ *
2103
+ * With `upTo`, the head the app verified to, the links and hashes of the
2104
+ * chain are the app's to recompute, as it just has, over its own snapshot,
2105
+ * through that head; `carries(upTo)` ties this snapshot to that one. So
2106
+ * the vault reads only its own entries, each checked against its own hash
2107
+ * and MAC over the link it names. Without `upTo`, nobody else recomputed
2108
+ * the chain: the vault reads it all, and rehashes every link.
2090
2109
  */
2091
- async #verifyAll(shown, upTo) {
2110
+ async #verifyAll(upTo) {
2092
2111
  await this.#settled();
2093
2112
  return this.#db.transaction(async (tx) => {
2094
2113
  const remembered = this.#prepared.verified;
2095
- const verification = await this.#verify(tx, shown, UNVERIFIED);
2096
- if (!verification.ok) return verification;
2097
- if (upTo !== null && !await carries(tx, upTo)) return {
2114
+ const findings = new Findings();
2115
+ const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2116
+ if (!read.ok) return read;
2117
+ const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2118
+ const rememberedHead = remembered.nextSeq > 0n ? {
2119
+ seq: Number(remembered.nextSeq - 1n),
2120
+ hash: remembered.hash.toString("hex")
2121
+ } : null;
2122
+ if (rememberedHead !== null) heads.push(rememberedHead);
2123
+ const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2124
+ const holds = (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
2125
+ if (upTo !== null && !holds(upTo)) return {
2098
2126
  ok: false,
2099
2127
  failedAtSeq: upTo.seq,
2100
2128
  reason: "not the entry the app verified up to: the log changed between the two checks"
2101
2129
  };
2102
- const unsigned = await this.#checkpointFault(tx);
2130
+ const unsigned = await this.#checkpointFault(findings, holds);
2103
2131
  if (unsigned !== null) return unsigned;
2104
- if (remembered.nextSeq > 0n) {
2105
- const seq = remembered.nextSeq - 1n;
2106
- if (!await carries(tx, {
2107
- seq: Number(seq),
2108
- hash: remembered.hash.toString("hex")
2109
- })) return {
2110
- ok: false,
2111
- failedAtSeq: Number(seq),
2112
- reason: "changed since the vault last verified it"
2113
- };
2114
- }
2132
+ if (rememberedHead !== null && !holds(rememberedHead)) return {
2133
+ ok: false,
2134
+ failedAtSeq: rememberedHead.seq,
2135
+ reason: "changed since the vault last verified it"
2136
+ };
2115
2137
  const at = await this.#now(tx);
2116
- const accounting = await verifyAccounting(tx, at);
2138
+ const accounting = findings.accounting.result(at);
2117
2139
  if (!accounting.ok) return accounting;
2118
- const fault = await this.#unsealed(tx) ?? await replay(tx, at);
2140
+ const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
2141
+ const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
2119
2142
  if (fault !== null) return {
2120
2143
  ok: false,
2121
2144
  failedAtSeq: null,
2122
2145
  reason: describeAccessFault(fault),
2123
2146
  fault
2124
2147
  };
2125
- return accounting.pending === 0 ? verification : {
2126
- ...verification,
2148
+ const verified = {
2149
+ ok: true,
2150
+ entries: findings.entries
2151
+ };
2152
+ return accounting.pending === 0 ? verified : {
2153
+ ...verified,
2127
2154
  pending: accounting.pending
2128
2155
  };
2129
2156
  }, SNAPSHOT);
2130
2157
  }
2131
2158
  /**
2132
- * Every checkpoint, not only the newest: each signed by the key whose log
2133
- * key wrote its entry, over a prefix the log still holds, entry for entry.
2134
- * The chain is verified by now, so each checkpoint entry is the vault's,
2135
- * and one under a key it replaced comes before the rotation.
2159
+ * The vault's own entries, oldest first: each against its own hash, and by
2160
+ * its MAC, over the link it names, which the app checked; and the vault's
2161
+ * keys moving only forward through them.
2136
2162
  */
2137
- async #checkpointFault(db) {
2163
+ async #ownEntries(tx, findings) {
2164
+ const { logKeys } = this.#prepared;
2165
+ let keys = [];
2138
2166
  for (let after = -1n;;) {
2139
- const batch = await vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
2167
+ const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
2140
2168
  for (const entry of batch) {
2141
- const checkpoint = JSON.parse(entry.metadata);
2142
- const broken = (reason) => ({
2143
- ok: false,
2144
- failedAtSeq: Number(entry.seq),
2145
- reason
2169
+ const result = verifyEntries([entry], {
2170
+ startSeq: entry.seq,
2171
+ startPrevHash: entry.prevHash,
2172
+ keys: logKeys
2146
2173
  });
2147
- const { logKeys, signers } = this.#prepared;
2148
- const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2149
- if (by?.keyId !== checkpoint.keyId) return broken(`a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`);
2150
- if (!await verifyCheckpoint(checkpoint, by.publicKey)) return broken("a checkpoint the vault did not sign");
2151
- if (BigInt(checkpoint.seq) >= entry.seq || !await carries(db, checkpoint)) return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
2174
+ if (!result.ok) return {
2175
+ ok: false,
2176
+ failedAtSeq: Number(result.failedAtSeq),
2177
+ reason: withCause(result.reason)
2178
+ };
2152
2179
  }
2153
- if (batch.length < 1e3) return null;
2180
+ const moved = forward(batch, keys, logKeys[0].keyId);
2181
+ if ("failedAtSeq" in moved) return {
2182
+ ok: false,
2183
+ failedAtSeq: Number(moved.failedAtSeq),
2184
+ reason: moved.reason
2185
+ };
2186
+ keys = moved.keys;
2187
+ for (const entry of batch) findings.add(entry);
2188
+ if (batch.length < 5e3) return { ok: true };
2154
2189
  after = batch[batch.length - 1].seq;
2155
2190
  }
2156
2191
  }
2192
+ /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
2193
+ async #rehashed(tx, findings) {
2194
+ const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
2195
+ for (const entry of batch) if (entry.author === "vault") findings.add(entry);
2196
+ });
2197
+ if (!verification.ok) return verification;
2198
+ this.#prepared.verified = further(this.#prepared.verified, anchor);
2199
+ return { ok: true };
2200
+ }
2201
+ /**
2202
+ * Every checkpoint, not only the newest: each signed by the key whose log
2203
+ * key wrote its entry, over a prefix the log still holds (`holds`), entry
2204
+ * for entry. Its entry's MAC is checked by now, and one under a key the
2205
+ * vault replaced comes before the rotation.
2206
+ */
2207
+ async #checkpointFault(findings, holds) {
2208
+ const { logKeys, signers } = this.#prepared;
2209
+ const verifiers = /* @__PURE__ */ new Map();
2210
+ const signed = (checkpoint, by) => {
2211
+ let verifier = verifiers.get(by.keyId);
2212
+ if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
2213
+ return verifier.then((verify) => verify(checkpoint));
2214
+ };
2215
+ const { checkpoints } = findings;
2216
+ for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
2217
+ const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
2218
+ const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
2219
+ const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2220
+ 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`;
2221
+ if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
2222
+ 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`;
2223
+ return null;
2224
+ }));
2225
+ const at = faults.findIndex((fault) => fault !== null);
2226
+ if (at !== -1) return {
2227
+ ok: false,
2228
+ failedAtSeq: Number(chunk[at].entry.seq),
2229
+ reason: faults[at]
2230
+ };
2231
+ }
2232
+ return null;
2233
+ }
2157
2234
  /**
2158
2235
  * The first member whose row fails its MAC, or names an older access
2159
- * entry than the log's newest about them. The chain is verified by now,
2160
- * so every entry read here carries the vault's MAC.
2236
+ * entry than the log's newest about them (`newest`, from the full check's
2237
+ * pass, where each entry's MAC was checked).
2161
2238
  */
2162
- async #unsealed(db) {
2163
- const [rows, held, newest] = await Promise.all([
2164
- allMembers(db),
2165
- grants(db),
2166
- newestAccessEntries(db)
2167
- ]);
2239
+ #unsealed(rows, held, newest) {
2168
2240
  for (const row of rows) {
2169
2241
  const grants = held.filter((grant) => grant.principal === row.principal);
2170
2242
  if (!this.#sealed(row, grants)) return {
@@ -2172,7 +2244,7 @@ var VaultService = class {
2172
2244
  principal: row.principal,
2173
2245
  why: "mac"
2174
2246
  };
2175
- if (newest.get(row.principal)?.seq !== row.accessSeq) return {
2247
+ if (newest.get(row.principal) !== row.accessSeq) return {
2176
2248
  kind: "tampered-member",
2177
2249
  principal: row.principal,
2178
2250
  why: "stale"
@@ -2181,6 +2253,45 @@ var VaultService = class {
2181
2253
  return null;
2182
2254
  }
2183
2255
  };
2256
+ /**
2257
+ * What one pass over the vault's entries gathers for the other checks of a
2258
+ * full verification, so that the log is read once: the key batches, the
2259
+ * access entries replayed, each member's newest, and the checkpoints. Each
2260
+ * entry comes after its MAC was checked.
2261
+ */
2262
+ var Findings = class {
2263
+ accounting = new KeyAccounting();
2264
+ replayed = {
2265
+ members: /* @__PURE__ */ new Map(),
2266
+ held: /* @__PURE__ */ new Map()
2267
+ };
2268
+ /** The first access entry the replay could not apply. */
2269
+ replayFault = null;
2270
+ /** Each member's newest allowed access entry, by seq. */
2271
+ newestAccess = /* @__PURE__ */ new Map();
2272
+ /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
2273
+ checkpoints = [];
2274
+ /** The vault's entries read. */
2275
+ entries = 0;
2276
+ add(entry) {
2277
+ this.entries += 1;
2278
+ this.accounting.add(entry);
2279
+ if (entry.decision !== "allow") return;
2280
+ if (ACCESS.has(entry.action)) {
2281
+ this.replayFault ??= apply(this.replayed, entry);
2282
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
2283
+ } else if (entry.action === CHECKPOINT) this.checkpoints.push({
2284
+ entry: {
2285
+ seq: entry.seq,
2286
+ keyId: entry.keyId
2287
+ },
2288
+ checkpoint: JSON.parse(entry.metadata)
2289
+ });
2290
+ }
2291
+ };
2292
+ const ACCESS = new Set(ACCESS_ACTIONS);
2293
+ /** Checkpoint signatures checked at once, in a full verification. */
2294
+ const SIGNATURES_AT_ONCE = 256;
2184
2295
  /** Settle every operation before releasing the member lock, including cancelled requests. */
2185
2296
  async function settle(operations, budgetMs) {
2186
2297
  const controller = new AbortController();