@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,8 +36,8 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.1.12",
40
- "@coffre/db": "0.1.12"
39
+ "@coffre/core": "0.1.13",
40
+ "@coffre/db": "0.1.13"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/accounting.ts CHANGED
@@ -1,9 +1,5 @@
1
1
  import type { StoredEntry } from '@coffre/core/audit';
2
2
  import type { LogVerification } from '@coffre/core/vault';
3
- import type { Queryable } from '@coffre/db';
4
-
5
- import { VERIFY_BATCH } from './log.ts';
6
- import { entriesFrom } from './store.ts';
7
3
 
8
4
  const KEY_ACTIONS = new Set(['secret.read', 'key.wrap', 'key.rewrap']);
9
5
 
@@ -13,54 +9,61 @@ type ParsedOutcome = Item & { intentId: string; relatedSeq: bigint };
13
9
  type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
14
10
  type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
15
11
 
16
- /** The chain can hold while a process died between an intent and its outcomes. */
17
- export async function verifyAccounting(db: Queryable, at: number): Promise<Accounting> {
18
- const intents = new Map<bigint, Intent>();
19
- const ids = new Set<string>();
20
- const broken = (entry: StoredEntry, reason: string): Accounting => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
21
- let next = 0n;
22
- for (;;) {
23
- const batch = await entriesFrom(db, next, VERIFY_BATCH);
24
- if (batch.length === 0) break;
25
- for (const entry of batch) {
26
- if (entry.author !== 'vault') continue;
27
- if (entry.action === 'key.intent') {
28
- const parsed = parseIntent(entry);
29
- if (typeof parsed === 'string') return broken(entry, parsed);
30
- if (ids.has(parsed.intentId)) return broken(entry, 'key intent repeats an operation identity');
31
- ids.add(parsed.intentId);
32
- if (parsed.keys.length > 0) intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
33
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
34
- // An outcome names its intent; a key released with no service to call has none.
35
- const parsed = parseOutcome(entry);
36
- if (typeof parsed === 'string') return broken(entry, parsed);
37
- const intent = intents.get(parsed.relatedSeq);
38
- const key = intent?.keys[parsed.item];
39
- if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
40
- return broken(entry, 'key outcome does not identify one item of its intent');
41
- }
42
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
43
- return broken(entry, 'key outcome belongs to another operation');
44
- }
45
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
46
- return broken(entry, 'key outcome does not match its intended secret');
47
- }
48
- intent.seen.add(parsed.item);
49
- // Completed batches need no state, and another outcome for one is a duplicate.
50
- if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
12
+ /**
13
+ * Every key operation's intent and its outcomes, entry by entry, oldest
14
+ * first, as the full check reads the vault's entries (`add`); then what is
15
+ * still open (`result`). The chain can hold while a process died between an
16
+ * intent and its outcomes: only a missed deadline is a fault.
17
+ */
18
+ export class KeyAccounting {
19
+ readonly #intents = new Map<bigint, Intent>();
20
+ readonly #ids = new Set<string>();
21
+ #fault: Extract<Accounting, { ok: false }> | null = null;
22
+
23
+ add(entry: StoredEntry): void {
24
+ if (this.#fault !== null || entry.author !== 'vault') return;
25
+ const broken = (reason: string) => {
26
+ this.#fault = { ok: false, failedAtSeq: Number(entry.seq), reason };
27
+ };
28
+ if (entry.action === 'key.intent') {
29
+ const parsed = parseIntent(entry);
30
+ if (typeof parsed === 'string') return broken(parsed);
31
+ if (this.#ids.has(parsed.intentId)) return broken('key intent repeats an operation identity');
32
+ this.#ids.add(parsed.intentId);
33
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
34
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
35
+ // An outcome names its intent; a key released with no service to call has none.
36
+ const parsed = parseOutcome(entry);
37
+ if (typeof parsed === 'string') return broken(parsed);
38
+ const intent = this.#intents.get(parsed.relatedSeq);
39
+ const key = intent?.keys[parsed.item];
40
+ if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
41
+ return broken('key outcome does not identify one item of its intent');
42
+ }
43
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
44
+ return broken('key outcome belongs to another operation');
51
45
  }
46
+ if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
47
+ return broken('key outcome does not match its intended secret');
48
+ }
49
+ intent.seen.add(parsed.item);
50
+ // Completed batches need no state, and another outcome for one is a duplicate.
51
+ if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
52
52
  }
53
- next = batch[batch.length - 1].seq + 1n;
54
- if (batch.length < VERIFY_BATCH) break;
55
53
  }
56
- // Live calls can finish after this snapshot. Only a missed deadline is a fault.
57
- const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
58
- if (overdue.length === 0) return { ok: true, pending: intents.size };
59
- const reason = overdue.map((intent) => {
60
- const missing = intent.keys.length - intent.seen.size;
61
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
62
- }).join('; ');
63
- return broken(overdue[0].entry, reason);
54
+
55
+ /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
56
+ result(at: number): Accounting {
57
+ if (this.#fault !== null) return this.#fault;
58
+ const intents = [...this.#intents.values()];
59
+ const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
60
+ if (overdue.length === 0) return { ok: true, pending: intents.length };
61
+ const reason = overdue.map((intent) => {
62
+ const missing = intent.keys.length - intent.seen.size;
63
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
64
+ }).join('; ');
65
+ return { ok: false, failedAtSeq: Number(overdue[0]!.entry.seq), reason };
66
+ }
64
67
  }
65
68
 
66
69
  function parseIntent(entry: StoredEntry): ParsedIntent | string {
package/src/log.ts CHANGED
@@ -38,7 +38,8 @@ export function further(a: Anchor, b: Anchor): Anchor {
38
38
  return b.nextSeq > a.nextSeq ? b : a;
39
39
  }
40
40
 
41
- export const VERIFY_BATCH = 1000;
41
+ /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
42
+ export const VERIFY_BATCH = 5000;
42
43
 
43
44
  type Verified = { verification: LogVerification; anchor: Anchor };
44
45
 
@@ -55,13 +56,15 @@ type Verified = { verification: LogVerification; anchor: Anchor };
55
56
  * So a view rehashes only what is new since the last one. What it leaves
56
57
  * out is an entry before the anchor edited in place, not chained again, and
57
58
  * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
58
- * as does the first view after a start, which has no anchor.
59
+ * as does the first view after a start, which has no anchor. `onBatch` sees
60
+ * each batch once it has verified, for checks that read the same entries.
59
61
  */
60
62
  export async function verifyChain(
61
63
  db: Queryable,
62
64
  logKeys: readonly LogKey[],
63
65
  shown: readonly StoredEntry[],
64
66
  anchor: Anchor,
67
+ onBatch: (batch: readonly StoredEntry[]) => void = () => {},
65
68
  ): Promise<Verified> {
66
69
  const broken = (failedAtSeq: bigint, reason: string): Verified => ({
67
70
  verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason: withCause(reason) },
@@ -86,6 +89,7 @@ export async function verifyChain(
86
89
  if (!result.ok) return broken(result.failedAtSeq, result.reason);
87
90
  const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
88
91
  if ('failedAtSeq' in moved) return broken(moved.failedAtSeq, moved.reason);
92
+ onBatch(batch);
89
93
  verified = {
90
94
  nextSeq: result.nextSeq,
91
95
  hash: result.head,
@@ -104,7 +108,7 @@ export async function verifyChain(
104
108
  * even one that leaked, verifies what came before the rotation, and nothing
105
109
  * after it.
106
110
  */
107
- function forward(
111
+ export function forward(
108
112
  entries: readonly StoredEntry[],
109
113
  keys: readonly string[],
110
114
  current: string,
@@ -130,7 +134,7 @@ function forward(
130
134
  * signing key, or from its KEK when it has none. The second is the one an
131
135
  * operator can fix.
132
136
  */
133
- function withCause(reason: string): string {
137
+ export function withCause(reason: string): string {
134
138
  if (!/^written under vault:\S+, a key this verifier does not hold$/.test(reason)) return reason;
135
139
  return `${reason}: either it is forged, or the vault wrote it under another vault key or signing key, which must stay configured: a vault key that was replaced stays in the vault's config, in previousKeks`;
136
140
  }
package/src/replay.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  import type { StoredEntry } from '@coffre/core/audit';
2
2
  import type { AccessFault, FaultGrant } from '@coffre/core/vault';
3
- import type { Queryable } from '@coffre/db';
4
3
 
5
- import { ACCESS_ACTIONS, allMembers, grants, vaultEntriesOf, type GrantRow, type Member, type Place } from './store.ts';
4
+ import { ACCESS_ACTIONS, type GrantRow, type Member, type Place } from './store.ts';
6
5
 
7
6
  export { ACCESS_ACTIONS };
8
7
 
9
- const BATCH = 1000;
10
-
11
8
  /**
12
9
  * Why the members and grants in the database do not follow from the
13
10
  * vault's entries, or null when they do; `describeAccessFault` words it.
@@ -17,25 +14,15 @@ const BATCH = 1000;
17
14
  * written around the vault: a grant inserted with the database's own login,
18
15
  * a removal undone.
19
16
  *
20
- * Call it after the chain is verified, in the same snapshot, so every entry
21
- * it replays carries the vault's MAC.
17
+ * `state` is the allowed access entries replayed (`apply`), each after its
18
+ * MAC was checked, in the snapshot `rows` and `stored` were read in.
22
19
  *
23
20
  * Grants are compared as they are live at `at`. Clearing one that has
24
21
  * lapsed changes nothing anyone holds, so it is not logged.
25
22
  */
26
- export async function replay(db: Queryable, at: number): Promise<AccessFault | null> {
27
- const state: Replayed = { members: new Map(), held: new Map() };
28
- for (let after = -1n; ; ) {
29
- const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
30
- for (const row of batch) {
31
- const fault = apply(state, row);
32
- if (fault !== null) return fault;
33
- }
34
- if (batch.length < BATCH) break;
35
- after = batch[batch.length - 1].seq;
36
- }
23
+ export function replayFault(state: Replayed, rows: readonly Member[], storedGrants: readonly GrantRow[], at: number): AccessFault | null {
37
24
  const { members, held } = state;
38
- const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
25
+ const stored = new Map(rows.map((member) => [member.principal, member]));
39
26
  for (const principal of [...new Set([...stored.keys(), ...members.keys()])].sort()) {
40
27
  const [inStore, inLog] = [stored.get(principal), members.get(principal)];
41
28
  if (inLog === undefined) return { kind: 'unlogged-member', principal };
@@ -46,7 +33,7 @@ export async function replay(db: Queryable, at: number): Promise<AccessFault | n
46
33
 
47
34
  const live = (grant: GrantRow) => grant.expiresAt === null || grant.expiresAt > at;
48
35
  const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
49
- const inStore = new Set((await grants(db)).filter(live).map(grantKey));
36
+ const inStore = new Set(storedGrants.filter(live).map(grantKey));
50
37
  const extra = [...inStore].sort().find((grant) => !logged.has(grant));
51
38
  if (extra !== undefined) return { kind: 'unlogged-grant', grant: faultGrant(extra) };
52
39
  const missing = [...logged].sort().find((grant) => !inStore.has(grant));
package/src/store.ts CHANGED
@@ -287,6 +287,35 @@ export async function entriesFrom(db: Queryable, fromSeq: bigint, limit: number)
287
287
  return stored(rows);
288
288
  }
289
289
 
290
+ /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
291
+ export async function vaultEntriesAfter(db: Queryable, afterSeq: bigint, limit: number): Promise<StoredEntry[]> {
292
+ const { auditLog } = tablesOf(db);
293
+ const rows = await db
294
+ .select(entryColumns(db))
295
+ .from(auditLog)
296
+ .where(and(eq(auditLog.author, 'vault' satisfies Author), gt(auditLog.seq, afterSeq)))
297
+ .orderBy(asc(auditLog.seq))
298
+ .limit(limit);
299
+ return stored(rows);
300
+ }
301
+
302
+ /** The hash of the entry at each of `seqs` the log holds, by seq. */
303
+ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<Map<bigint, Buffer>> {
304
+ const { auditLog } = tablesOf(db);
305
+ const hashes = new Map<bigint, Buffer>();
306
+ for (let from = 0; from < seqs.length; from += HASHES_AT_ONCE) {
307
+ const rows = await db
308
+ .select({ seq: auditLog.seq, hash: auditLog.hash })
309
+ .from(auditLog)
310
+ .where(inArray(auditLog.seq, seqs.slice(from, from + HASHES_AT_ONCE)));
311
+ for (const { seq, hash } of rows) hashes.set(seq, hash);
312
+ }
313
+ return hashes;
314
+ }
315
+
316
+ /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
317
+ const HASHES_AT_ONCE = 5000;
318
+
290
319
  /** The entry at `seq`'s hash, or undefined when there is none. */
291
320
  export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
292
321
  const { auditLog } = tablesOf(db);
package/src/vault.ts CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  type Permission,
10
10
  type Role,
11
11
  } from '@coffre/core/access';
12
- import { verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
12
+ import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
13
13
  import { checkContext, type SecretContext } from '@coffre/core/envelope';
14
14
  import {
15
15
  DEK_BYTES,
@@ -26,7 +26,7 @@ import {
26
26
  } from '@coffre/core/kek';
27
27
  import {
28
28
  checkpointMessage,
29
- verifyCheckpoint,
29
+ checkpointVerifier,
30
30
  describeAccessFault,
31
31
  type Access,
32
32
  type AccessChange,
@@ -56,11 +56,11 @@ import type { Database, Queryable, Transaction } from '@coffre/db';
56
56
  import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
57
57
  import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
58
58
 
59
- import { verifyAccounting } from './accounting.ts';
59
+ import { KeyAccounting } from './accounting.ts';
60
60
  import { signer, type Signer } from './checkpoint.ts';
61
61
  import type { ResolvedVaultConfig } from './config.ts';
62
- import { carries, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, type Anchor } from './log.ts';
63
- import { apply, replay, type LoggedMember, type Replayed } from './replay.ts';
62
+ import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
63
+ import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
64
64
  import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
65
65
  import * as store from './store.ts';
66
66
  import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
@@ -1456,94 +1456,176 @@ class VaultService implements Vault {
1456
1456
  }
1457
1457
 
1458
1458
  verifyLog(input: VerifyLogInput): Promise<LogVerification> {
1459
- return this.#verifyAll([], input.upTo ?? null);
1460
- }
1461
-
1462
- /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
1463
- async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
1464
- const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
1465
- if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
1466
- return verification;
1459
+ return this.#verifyAll(input.upTo ?? null);
1467
1460
  }
1468
1461
 
1469
1462
  /**
1470
- * `shown`, and the chain from its first entry, in one snapshot: every
1471
- * link and hash, the vault's MACs; then the heads that must still be
1472
- * there: the one the app verified up to (`upTo`), so both authors are
1473
- * checked over the same entries, the one the app last recorded from a
1474
- * checkpoint (`through`), and the last checkpoint's; and the members and
1475
- * grants replayed from it.
1463
+ * The whole log, in one snapshot and one pass over the vault's entries:
1464
+ * each by its MAC, the keys it moved through, and what the other checks
1465
+ * need of it, gathered as it goes (`Findings`); then every checkpoint, the
1466
+ * heads that must still be there, the key batches accounted for, and the
1467
+ * members and grants replayed.
1468
+ *
1469
+ * With `upTo`, the head the app verified to, the links and hashes of the
1470
+ * chain are the app's to recompute, as it just has, over its own snapshot,
1471
+ * through that head; `carries(upTo)` ties this snapshot to that one. So
1472
+ * the vault reads only its own entries, each checked against its own hash
1473
+ * and MAC over the link it names. Without `upTo`, nobody else recomputed
1474
+ * the chain: the vault reads it all, and rehashes every link.
1476
1475
  */
1477
- async #verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
1476
+ async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
1478
1477
  await this.#settled();
1479
1478
  return this.#db.transaction(async (tx) => {
1480
1479
  const remembered = this.#prepared.verified;
1481
- const verification = await this.#verify(tx, shown, UNVERIFIED);
1482
- if (!verification.ok) return verification;
1483
- if (upTo !== null && !(await carries(tx, upTo))) {
1480
+ const findings = new Findings();
1481
+ const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
1482
+ if (!read.ok) return read;
1483
+ // Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
1484
+ const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
1485
+ const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
1486
+ if (rememberedHead !== null) heads.push(rememberedHead);
1487
+ const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
1488
+ const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
1489
+ if (upTo !== null && !holds(upTo)) {
1484
1490
  return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
1485
1491
  }
1486
- const unsigned = await this.#checkpointFault(tx);
1492
+ const unsigned = await this.#checkpointFault(findings, holds);
1487
1493
  if (unsigned !== null) return unsigned;
1488
1494
  // What this vault last verified must still be there: whoever holds
1489
1495
  // its key can seal a rewrite, but cannot put back the head it saw. A
1490
1496
  // checkpoint names an earlier break, so it is reported first.
1491
- if (remembered.nextSeq > 0n) {
1492
- const seq = remembered.nextSeq - 1n;
1493
- if (!(await carries(tx, { seq: Number(seq), hash: remembered.hash.toString('hex') }))) {
1494
- return { ok: false, failedAtSeq: Number(seq), reason: 'changed since the vault last verified it' };
1495
- }
1497
+ if (rememberedHead !== null && !holds(rememberedHead)) {
1498
+ return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
1496
1499
  }
1497
1500
  const at = await this.#now(tx);
1498
- const accounting = await verifyAccounting(tx, at);
1501
+ const accounting = findings.accounting.result(at);
1499
1502
  if (!accounting.ok) return accounting;
1500
- const fault = (await this.#unsealed(tx)) ?? (await replay(tx, at));
1503
+ const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
1504
+ const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
1501
1505
  if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
1502
- return accounting.pending === 0 ? verification : { ...verification, pending: accounting.pending };
1506
+ const verified = { ok: true as const, entries: findings.entries };
1507
+ return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
1503
1508
  }, SNAPSHOT);
1504
1509
  }
1505
1510
 
1506
1511
  /**
1507
- * Every checkpoint, not only the newest: each signed by the key whose log
1508
- * key wrote its entry, over a prefix the log still holds, entry for entry.
1509
- * The chain is verified by now, so each checkpoint entry is the vault's,
1510
- * and one under a key it replaced comes before the rotation.
1512
+ * The vault's own entries, oldest first: each against its own hash, and by
1513
+ * its MAC, over the link it names, which the app checked; and the vault's
1514
+ * keys moving only forward through them.
1511
1515
  */
1512
- async #checkpointFault(db: Queryable): Promise<Extract<LogVerification, { ok: false }> | null> {
1516
+ async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
1517
+ const { logKeys } = this.#prepared;
1518
+ let keys: readonly string[] = [];
1513
1519
  for (let after = -1n; ; ) {
1514
- const batch = await store.vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
1520
+ const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
1515
1521
  for (const entry of batch) {
1516
- const checkpoint = JSON.parse(entry.metadata) as Checkpoint;
1517
- const broken = (reason: string) => ({ ok: false as const, failedAtSeq: Number(entry.seq), reason });
1518
- const { logKeys, signers } = this.#prepared;
1522
+ const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
1523
+ if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
1524
+ }
1525
+ const moved = forward(batch, keys, logKeys[0]!.keyId);
1526
+ if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
1527
+ keys = moved.keys;
1528
+ for (const entry of batch) findings.add(entry);
1529
+ if (batch.length < VERIFY_BATCH) return { ok: true };
1530
+ after = batch[batch.length - 1]!.seq;
1531
+ }
1532
+ }
1533
+
1534
+ /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
1535
+ async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
1536
+ const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
1537
+ for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
1538
+ });
1539
+ if (!verification.ok) return verification;
1540
+ this.#prepared.verified = further(this.#prepared.verified, anchor);
1541
+ return { ok: true };
1542
+ }
1543
+
1544
+ /**
1545
+ * Every checkpoint, not only the newest: each signed by the key whose log
1546
+ * key wrote its entry, over a prefix the log still holds (`holds`), entry
1547
+ * for entry. Its entry's MAC is checked by now, and one under a key the
1548
+ * vault replaced comes before the rotation.
1549
+ */
1550
+ async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
1551
+ const { logKeys, signers } = this.#prepared;
1552
+ // Each signer's key imported once, for this check only.
1553
+ const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
1554
+ const signed = (checkpoint: Checkpoint, by: Signer) => {
1555
+ let verifier = verifiers.get(by.keyId);
1556
+ if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
1557
+ return verifier.then((verify) => verify(checkpoint));
1558
+ };
1559
+ const { checkpoints } = findings;
1560
+ for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
1561
+ const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
1562
+ // The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
1563
+ const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
1519
1564
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
1520
- 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`);
1521
- if (!(await verifyCheckpoint(checkpoint, by.publicKey))) return broken('a checkpoint the vault did not sign');
1522
- if (BigInt(checkpoint.seq) >= entry.seq || !(await carries(db, checkpoint))) {
1523
- return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
1565
+ 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`;
1566
+ if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
1567
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
1568
+ return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
1524
1569
  }
1525
- }
1526
- if (batch.length < VERIFY_BATCH) return null;
1527
- after = batch[batch.length - 1].seq;
1570
+ return null;
1571
+ }));
1572
+ const at = faults.findIndex((fault) => fault !== null);
1573
+ if (at !== -1) return { ok: false, failedAtSeq: Number(chunk[at]!.entry.seq), reason: faults[at]! };
1528
1574
  }
1575
+ return null;
1529
1576
  }
1530
1577
 
1531
1578
  /**
1532
1579
  * The first member whose row fails its MAC, or names an older access
1533
- * entry than the log's newest about them. The chain is verified by now,
1534
- * so every entry read here carries the vault's MAC.
1580
+ * entry than the log's newest about them (`newest`, from the full check's
1581
+ * pass, where each entry's MAC was checked).
1535
1582
  */
1536
- async #unsealed(db: Queryable): Promise<AccessFault | null> {
1537
- const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
1583
+ #unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
1538
1584
  for (const row of rows) {
1539
1585
  const grants = held.filter((grant) => grant.principal === row.principal);
1540
1586
  if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
1541
- if (newest.get(row.principal)?.seq !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
1587
+ if (newest.get(row.principal) !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
1542
1588
  }
1543
1589
  return null;
1544
1590
  }
1545
1591
  }
1546
1592
 
1593
+ /**
1594
+ * What one pass over the vault's entries gathers for the other checks of a
1595
+ * full verification, so that the log is read once: the key batches, the
1596
+ * access entries replayed, each member's newest, and the checkpoints. Each
1597
+ * entry comes after its MAC was checked.
1598
+ */
1599
+ class Findings {
1600
+ readonly accounting = new KeyAccounting();
1601
+ readonly replayed: Replayed = { members: new Map(), held: new Map() };
1602
+ /** The first access entry the replay could not apply. */
1603
+ replayFault: AccessFault | null = null;
1604
+ /** Each member's newest allowed access entry, by seq. */
1605
+ readonly newestAccess = new Map<string, bigint>();
1606
+ /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
1607
+ readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
1608
+ /** The vault's entries read. */
1609
+ entries = 0;
1610
+
1611
+ add(entry: StoredEntry): void {
1612
+ this.entries += 1;
1613
+ this.accounting.add(entry);
1614
+ if (entry.decision !== 'allow') return;
1615
+ if (ACCESS.has(entry.action)) {
1616
+ this.replayFault ??= apply(this.replayed, entry);
1617
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
1618
+ } else if (entry.action === CHECKPOINT) {
1619
+ this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
1620
+ }
1621
+ }
1622
+ }
1623
+
1624
+ const ACCESS = new Set<string>(ACCESS_ACTIONS);
1625
+
1626
+ /** Checkpoint signatures checked at once, in a full verification. */
1627
+ const SIGNATURES_AT_ONCE = 256;
1628
+
1547
1629
  /** Settle every operation before releasing the member lock, including cancelled requests. */
1548
1630
  async function settle<T extends { wipe: () => void }>(
1549
1631
  operations: ((operation: KeyOperation) => Promise<T | null>)[],