@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.
package/src/vault.ts CHANGED
@@ -99,17 +99,17 @@ import {
99
99
  type WrappedKey,
100
100
  } from '@coffre/core/vault';
101
101
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
- import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
102
+ import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
103
103
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
104
- import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
104
+ import { appendEntries, lockLogHead, missingEntry, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
105
 
106
- import { KeyAccounting } from './accounting.ts';
107
106
  import { signer, type Signer } from './checkpoint.ts';
108
107
  import type { ResolvedVaultConfig } from './config.ts';
109
- import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
108
+ import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
110
109
  import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
111
110
  import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
112
111
  import * as store from './store.ts';
112
+ import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
113
113
  import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
114
114
 
115
115
  export type VaultOptions = {
@@ -120,6 +120,8 @@ export type VaultOptions = {
120
120
  keyBudgetMs?: number;
121
121
  /** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
122
122
  clockOffset?: () => number;
123
+ /** How far one call walks the log (`WALKS`); tests shorten them. */
124
+ walks?: Partial<Walks>;
123
125
  };
124
126
 
125
127
  /**
@@ -140,9 +142,9 @@ export type PreparedVault = {
140
142
  logKey: LogKey;
141
143
  /** Each signing key's log key: those after the first verify only entries before `since`. */
142
144
  logKeys: LogKey[];
143
- options: Required<VaultOptions>;
144
- /** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
145
- verified: Anchor;
145
+ options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
146
+ /** How far the chain is rehashed, by any call of this process: its head must still be there. */
147
+ verified: Position;
146
148
  /** What member rows are sealed under now; rows.ts. */
147
149
  rowKey: Buffer;
148
150
  /** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
@@ -169,8 +171,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
169
171
  return {
170
172
  config,
171
173
  ...(await keysOf(config.signingKeys)),
172
- options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
173
- verified: UNVERIFIED,
174
+ options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
175
+ verified: START,
174
176
  reported: new Set(),
175
177
  since: null,
176
178
  superseded: null,
@@ -227,9 +229,6 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
227
229
  /** Why a change to a tampered member is refused. */
228
230
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
229
231
 
230
- /** The action of the vault's entry that signs a prefix of the log. */
231
- const CHECKPOINT = 'audit.checkpoint';
232
-
233
232
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
234
233
  const SETTINGS = 'settings.change';
235
234
 
@@ -289,13 +288,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
289
288
  /** Who asks for checkpoints: the app's scheduled job. */
290
289
  const SCHEDULER = 'system:coffre-scheduler';
291
290
 
292
- /**
293
- * How often a checkpoint recomputes the chain from its first entry: the
294
- * first of each hour, by the vault's clock. The others resume from the
295
- * prefix the last one signed (`#recompute`).
296
- */
297
- const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
298
-
299
291
  /** A refusal and the entries that record it. */
300
292
  class Refused {
301
293
  readonly refusal: Refusal;
@@ -495,6 +487,7 @@ class VaultService implements Vault {
495
487
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
496
488
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
497
489
  if (!late) await lockLogHead(tx);
490
+ // The clock once the locks are held, in a statement of its own: one that waited for a lock is decided as of when it got it.
498
491
  const d: Decision = {
499
492
  tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
500
493
  };
@@ -595,9 +588,13 @@ class VaultService implements Vault {
595
588
 
596
589
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
597
590
  #authentic(entry: StoredEntry): boolean {
591
+ return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) }).ok;
592
+ }
593
+
594
+ /** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
595
+ #keysFor(entry: StoredEntry): LogKey[] {
598
596
  const { logKeys, since } = this.#prepared;
599
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
600
- return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
597
+ return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
601
598
  }
602
599
 
603
600
  /**
@@ -1223,7 +1220,12 @@ class VaultService implements Vault {
1223
1220
  intentSeq = intent.seq;
1224
1221
  }
1225
1222
 
1226
- return this.#decide([principal], async (d) => {
1223
+ // Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
1224
+ // log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
1225
+ // reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
1226
+ // holding the head, the head is taken once the keys are open, and what the check read is read again under it.
1227
+ const late = call.remote;
1228
+ const decided = async (d: Decision) => {
1227
1229
  try {
1228
1230
  await check(d);
1229
1231
  } catch (error) {
@@ -1233,9 +1235,12 @@ class VaultService implements Vault {
1233
1235
  }
1234
1236
  throw error;
1235
1237
  }
1238
+ };
1239
+ return this.#decide([principal], async (d) => {
1240
+ await decided(d);
1236
1241
  // A reference ended while this decided refuses it, rather than a read logged after its end.
1237
1242
  const { vet } = call;
1238
- if (vet !== undefined) {
1243
+ if (late && vet !== undefined) {
1239
1244
  d.underLock.push(async (locked) => {
1240
1245
  const codes = await vet(locked);
1241
1246
  const first = codes.find((code) => code !== null);
@@ -1262,25 +1267,20 @@ class VaultService implements Vault {
1262
1267
  if (done.length < outcomes.length) {
1263
1268
  throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
1264
1269
  }
1265
- // Again under the log's head, the append's, taken a statement early: a
1266
- // deletion renames its place under it, so one that committed since the
1267
- // check above is seen here, and nothing is released after it.
1268
- // A read through a reference: its holder's place too, which a deletion may have taken.
1269
- await lockLogHead(d.tx);
1270
- // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1271
- // since the check above is seen here, and nothing it refuses now is released.
1272
- try {
1273
- await check(d);
1274
- } catch (error) {
1275
- if (error instanceof Refused) {
1276
- throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', error.entries[item].code ?? error.refusal.code)));
1270
+ if (late) {
1271
+ // Again under the log's head, the append's, taken a statement early: a
1272
+ // deletion renames its place under it, so one that committed since the
1273
+ // check above is seen here, and nothing is released after it.
1274
+ // A read through a reference: its holder's place too, which a deletion may have taken.
1275
+ await lockLogHead(d.tx);
1276
+ // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1277
+ // since the check above is seen here, and nothing it refuses now is released.
1278
+ await decided(d);
1279
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1280
+ const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1281
+ if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
1282
+ throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1277
1283
  }
1278
- throw error;
1279
- }
1280
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1281
- const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1282
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
1283
- throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1284
1284
  }
1285
1285
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1286
1286
  d.log.push(...released);
@@ -1291,7 +1291,7 @@ class VaultService implements Vault {
1291
1291
  } finally {
1292
1292
  for (const value of done) value.wipe();
1293
1293
  }
1294
- }, true);
1294
+ }, late);
1295
1295
  }
1296
1296
 
1297
1297
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
@@ -2052,7 +2052,7 @@ class VaultService implements Vault {
2052
2052
  */
2053
2053
  async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
2054
2054
  const entry = await newestAuthentic(
2055
- (before) => store.newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before),
2055
+ (limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
2056
2056
  this.#check(reports, null),
2057
2057
  );
2058
2058
  if (entry === 'none') return DEFAULT_SETTINGS;
@@ -2081,7 +2081,7 @@ class VaultService implements Vault {
2081
2081
  */
2082
2082
  async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2083
2083
  const entry = await newestAuthentic(
2084
- (before) => store.newestVaultEntries(db, POLICY, LOOK_BACK.page, before),
2084
+ (limit, before) => store.newestVaultEntries(db, POLICY, limit, before),
2085
2085
  this.#check(reports, null),
2086
2086
  );
2087
2087
  if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
@@ -2363,73 +2363,137 @@ class VaultService implements Vault {
2363
2363
 
2364
2364
  // --- checkpoints and the log --------------------------------------------------
2365
2365
 
2366
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
2367
- async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
2368
- const row = await store.latestVaultEntry(db, [CHECKPOINT]);
2369
- return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
2366
+ /**
2367
+ * Where a walk of the log resumes: the first entry (`start`), a
2368
+ * checkpoint's entry that keeps a tally, or the newest such entry, the
2369
+ * first entry while there is none. The entry must be the vault's, by its
2370
+ * MAC, and its tally whole: the members and grants it keeps, or the
2371
+ * earlier checkpoint it names keeps, match the digest it carries.
2372
+ *
2373
+ * Its signature is left to the walk: the entry comes after the prefix it
2374
+ * signs, so a walk from there reads it, as it reads every other.
2375
+ */
2376
+ async #resume(db: Queryable, from: 'start' | 'newest' | bigint): Promise<Resumed | Broken> {
2377
+ if (from === 'start') return FIRST_ENTRY;
2378
+ const entry = from === 'newest' ? await store.latestTallied(db) : (await store.entriesAt(db, [from])).get(from);
2379
+ if (entry === undefined) return from === 'newest' ? FIRST_ENTRY : { ok: false, failedAtSeq: Number(from), reason: 'no checkpoint at this entry' };
2380
+ return (await this.#resumeEach(db, [entry]))[0]!;
2381
+ }
2382
+
2383
+ /** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
2384
+ async #resumeEach(db: Queryable, entries: readonly StoredEntry[]): Promise<(Resumed | Broken)[]> {
2385
+ const reads = entries.map((entry) => this.#tallied(entry));
2386
+ const named = reads.flatMap((read) => (typeof read !== 'string' && 'at' in read.tally.access ? [BigInt(read.tally.access.at)] : []));
2387
+ const carriers = new Map([...(await store.entriesAt(db, named))].map(([seq, held]) => [seq, this.#tallied(held)]));
2388
+ return entries.map((entry, i): Resumed | Broken => {
2389
+ const broken = (reason: string): Broken => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
2390
+ const read = reads[i]!;
2391
+ if (typeof read === 'string') return broken(read);
2392
+ let carrier = entry.seq;
2393
+ let { access } = read.tally;
2394
+ if ('at' in access) {
2395
+ carrier = BigInt(access.at);
2396
+ const holder = carrier < entry.seq ? carriers.get(carrier) : undefined;
2397
+ if (holder === undefined || typeof holder === 'string' || 'at' in holder.tally.access) {
2398
+ return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
2399
+ }
2400
+ access = holder.tally.access;
2401
+ }
2402
+ const kept = { members: access.members, grants: access.grants, newest: access.newest };
2403
+ if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
2404
+ return {
2405
+ ok: true,
2406
+ entry: entry.seq,
2407
+ at: { seq: BigInt(read.seq), hash: Buffer.from(read.hash, 'hex') },
2408
+ tally: { ...read.tally, access: kept },
2409
+ digest: read.tally.access.digest,
2410
+ carrier,
2411
+ scrub: read.scrub === null ? null : { at: positionOf(read.scrub.at), end: positionOf(read.scrub.end) },
2412
+ };
2413
+ });
2414
+ }
2415
+
2416
+ /** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
2417
+ #tallied(entry: StoredEntry): TalliedCheckpoint | string {
2418
+ const not = "not a checkpoint of the vault's that keeps a tally";
2419
+ if (entry.author !== 'vault' || entry.action !== CHECKPOINT || entry.decision !== 'allow' || entry.relatedSeq === null) return not;
2420
+ const authentic = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) });
2421
+ if (!authentic.ok) return withCause(authentic.reason);
2422
+ const read = metadataOf(entry) as Partial<TalliedCheckpoint> | null;
2423
+ if (read?.tally?.v !== TALLY_VERSION || read.scrub === undefined || typeof read.seq !== 'number' || BigInt(read.seq) !== entry.relatedSeq) return not;
2424
+ return read as TalliedCheckpoint;
2425
+ }
2426
+
2427
+ /** Which of `heads` the log still holds where they were, in one read. */
2428
+ async #holding(db: Queryable, heads: readonly LogHead[]): Promise<(head: LogHead) => boolean> {
2429
+ const hashes = await store.hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
2430
+ return (head) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2370
2431
  }
2371
2432
 
2372
2433
  /**
2373
- * The chain recomputed to its last entry, for a checkpoint to sign: from
2374
- * its first entry at the first checkpoint of each hour, and otherwise from
2375
- * the prefix the last checkpoint signed, if that one was signed this hour.
2376
- *
2377
- * Resuming never signs what the vault did not recompute. The vault
2378
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
2379
- * hour's first checkpoint and onward at each one since, and the entry
2380
- * that holds it is the first one checked again, by its MAC. The next
2381
- * signature extends that same chain.
2434
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
2382
2435
  *
2383
- * What resuming does not do is recompute the rows of that prefix again.
2384
- * They can change while the hash stored at its end stays, so it first
2385
- * checks the two things that are cheap: that hash is still there, which a
2386
- * prefix rewritten and chained again changes, and every entry of the
2387
- * prefix is, which a cut changes. Either failing, it recomputes from entry
2388
- * 0, which names what broke. An entry edited in place, its hash left as
2389
- * it was, is the one change left to the next full pass, within the hour.
2436
+ * In one snapshot, and without the log's lock, so that no append waits on
2437
+ * it: every member's row checked, since rows changed around the vault
2438
+ * write nothing to the log; the prefix the newest checkpoint signed still
2439
+ * there, its hash where it was, which a rewrite chained again changes, and
2440
+ * every entry of it, which a cut changes; the chain recomputed from there,
2441
+ * every hash from content and every vault entry by its MAC, no further
2442
+ * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
2443
+ * before rehashed (`#scrub`). Then, under the lock, the entries appended
2444
+ * since the snapshot, so that it signs the head.
2390
2445
  *
2391
- * It resumes only from a checkpoint under the key the vault writes with
2392
- * now: once the log reaches that key, no other writes again (`forward`).
2393
- * After a rotation, the next pass is full.
2446
+ * A walk cut short signs where it got to, behind the head: the log after
2447
+ * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
2448
+ * from the first entry, one walk a call, and `/readyz` stays red until the
2449
+ * checkpoints reach the newest heartbeat.
2394
2450
  */
2395
- async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
2396
- const { logKeys } = this.#prepared;
2397
- const latest = await this.#latest(tx);
2398
- const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
2399
- const resumable =
2400
- latest !== null &&
2401
- latest.keyId === logKeys[0].keyId &&
2402
- latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
2403
- hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
2404
- (await carries(tx, latest.checkpoint)) &&
2405
- (await store.complete(tx, BigInt(latest.checkpoint.seq)));
2406
- if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
2407
- const { seq, hash } = latest.checkpoint;
2408
- const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
2409
- return verifyChain(tx, logKeys, [], signed);
2410
- }
2411
-
2412
- async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
2451
+ async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
2413
2452
  await this.#settled();
2414
2453
  // A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
2415
2454
  // own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
2416
- const { wrongKek } = this.#prepared;
2417
- // Then, in one snapshot and without the log's lock, so that no append
2418
- // waits on it: every member's row checked, since rows changed around the
2419
- // vault write nothing to the log; and the chain recomputed, every hash
2420
- // from content and every vault entry by its MAC, from the first entry
2421
- // once an hour, since an entry cut from the middle leaves the hashes
2422
- // around a later checkpoint as they were (`#recompute`).
2455
+ const { wrongKek, logKeys } = this.#prepared;
2456
+ const { walks } = this.#prepared.options;
2423
2457
  const found: NewEntry[] = [];
2424
- const whole = await this.#db.transaction(async (tx) => {
2458
+ // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2459
+ let seen: bigint | null = null;
2460
+ const walked = await this.#db.transaction(async (tx) => {
2461
+ seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2425
2462
  await this.#sweep(tx, found);
2426
- return this.#recompute(tx);
2463
+ const from = await this.#resume(tx, 'newest');
2464
+ if (!from.ok) return from;
2465
+ const signed = headOf(from.at);
2466
+ if (!(await this.#holding(tx, [signed]))(signed)) {
2467
+ return { ok: false as const, failedAtSeq: null, reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed` };
2468
+ }
2469
+ const gap = await missingEntry(tx, from.at.seq);
2470
+ if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2471
+ const walk = new Walk(from.tally, from.digest);
2472
+ const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
2473
+ if (!chain.ok) return chain;
2474
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
2475
+ if (unsigned !== null) return unsigned;
2476
+ return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
2427
2477
  }, SNAPSHOT);
2478
+ // The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
2479
+ // next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
2480
+ // throws, as anywhere else.
2481
+ let scrub: Scrubbed = { ok: true, next: walked.ok ? walked.from.scrub : null };
2482
+ let stalled: string | null = null;
2483
+ if (walked.ok) {
2484
+ try {
2485
+ scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
2486
+ } catch (error) {
2487
+ // Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
2488
+ if (!isUnreachable(error) && !timedOut(error)) throw error;
2489
+ stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2490
+ }
2491
+ }
2428
2492
  return this.#decide([], async (d) => {
2429
2493
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2430
2494
  // Checkpoints one at a time, each against the one before.
2431
2495
  const head = await lockLogHead(d.tx);
2432
- const latest = await this.#latest(d.tx);
2496
+ const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
2433
2497
  const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
2434
2498
  new Refused(refusal(code, message), [
2435
2499
  { actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
@@ -2437,33 +2501,104 @@ class VaultService implements Vault {
2437
2501
  // Logging the refusal would give the next call something to sign.
2438
2502
  if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
2439
2503
  if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
2440
- if (!whole.verification.ok) {
2441
- throw refused('log_broken', { failedAtSeq: whole.verification.failedAtSeq, reason: whole.verification.reason });
2504
+ if (!walked.ok) throw refused('log_broken', { failedAtSeq: walked.failedAtSeq, reason: walked.reason });
2505
+ if (!scrub.ok) throw refused('log_broken', { failedAtSeq: scrub.failedAtSeq, reason: scrub.reason });
2506
+ const newest = latest === undefined ? null : (JSON.parse(latest.metadata) as Checkpoint);
2507
+ // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2508
+ // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2509
+ // and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
2510
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
2511
+ const standing = await this.#standingRefusal(d.tx);
2512
+ if (standing !== null) throw refused('log_broken', standing);
2513
+ if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2442
2514
  }
2443
- // Nothing since the last one: it is still the newest prefix.
2444
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
2515
+ // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2516
+ if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
2445
2517
  // Under the lock, the rest: the prefix signed last still where it was,
2446
- // and every entry since the snapshot. A rewrite is never signed over.
2447
- if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
2448
- throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
2518
+ // and every entry since the snapshot, unless the walk is catching up,
2519
+ // when what it walked must still be there. A rewrite is never signed over.
2520
+ if (newest !== null && !(await carries(d.tx, newest))) {
2521
+ throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
2449
2522
  }
2450
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
2451
- if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
2452
- // It signs the entry it verified to, which the head, locked, must name.
2453
- if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
2454
- throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2523
+ const walk = new Walk(walked.tally, walked.digest);
2524
+ let { at } = walked;
2525
+ if (walked.done) {
2526
+ // Where the snapshot's walk ended still has the hash it found there: what follows links to it.
2527
+ if (!(await carries(d.tx, headOf(at)))) {
2528
+ throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2529
+ }
2530
+ const rest = await rehash(d.tx, logKeys, at, walk, {});
2531
+ if (!rest.ok) throw refused('log_broken', { failedAtSeq: rest.failedAtSeq, reason: rest.reason });
2532
+ const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
2533
+ if (unsigned !== null) throw refused('log_broken', { failedAtSeq: unsigned.failedAtSeq, reason: unsigned.reason });
2534
+ at = rest.at;
2535
+ // It signs the entry it verified to, which the head, locked, must name.
2536
+ if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) {
2537
+ throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2538
+ }
2539
+ } else if (!(await carries(d.tx, headOf(at)))) {
2540
+ throw refused('log_broken', { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2455
2541
  }
2456
- const signed = { seq: Number(verified.nextSeq - 1n), hash: verified.hash.toString('hex'), signedAt: iso(d.at) };
2542
+ // Another call signed as far, or further, while this one walked behind the head: that is the newest prefix, never one shorter.
2543
+ if (newest !== null && latest!.relatedSeq !== null && latest!.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
2544
+ this.#prepared.verified = further(this.#prepared.verified, at);
2545
+ const tally = walk.tally();
2546
+ // Its members and grants once, in the first checkpoint after they change; the others name that one.
2547
+ const digest = walk.digest();
2548
+ const { from } = walked;
2549
+ const access = digest === from.digest && from.carrier !== null ? { digest, at: Number(from.carrier) } : { digest, ...tally.access };
2550
+ const signed = { seq: Number(at.seq), hash: at.hash.toString('hex'), signedAt: iso(d.at) };
2457
2551
  const checkpoint = {
2458
2552
  ...signed,
2459
2553
  keyId: this.#prepared.signer.keyId,
2460
2554
  signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
2461
2555
  };
2462
- d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', metadata: JSON.stringify(checkpoint) });
2463
- return { checkpoint };
2556
+ const lap = scrub.next === null ? null : { at: headOf(scrub.next.at), end: headOf(scrub.next.end) };
2557
+ const kept: TalliedCheckpoint = { ...checkpoint, tally: { v: TALLY_VERSION, ...tally, access }, scrub: lap };
2558
+ d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2559
+ return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2464
2560
  });
2465
2561
  }
2466
2562
 
2563
+ /**
2564
+ * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
2565
+ * runs from the first entry to the prefix the newest checkpoint signed
2566
+ * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
2567
+ * hash recomputed from its entry and each link checked (`relink`); the
2568
+ * next lap runs to the prefix signed then. A checkpoint resumes from the
2569
+ * newest one's tally and never reads what it signed again, and an entry
2570
+ * before it edited in place, its stored hash left as it was, keeps the
2571
+ * chain: this is what finds it, within two laps at most, each the length
2572
+ * of the log when it began over `walks.scrub` beats, however fast the log
2573
+ * grows meanwhile. The lap as it goes on, null between laps.
2574
+ */
2575
+ async #scrub(db: Queryable, newest: Resumed): Promise<Scrubbed> {
2576
+ const lap = newest.scrub ?? { at: START, end: newest.at };
2577
+ if (lap.end.seq < 0n) return { ok: true, next: null };
2578
+ const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
2579
+ if (!relinked.ok) return relinked;
2580
+ if (relinked.at.seq < lap.end.seq) return { ok: true, next: { at: relinked.at, end: lap.end } };
2581
+ // A lap ends at the prefix a checkpoint signed, or it was not the chain the checkpoints signed.
2582
+ if (!relinked.at.hash.equals(lap.end.hash)) {
2583
+ return { ok: false, failedAtSeq: Number(lap.end.seq), reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed` };
2584
+ }
2585
+ return { ok: true, next: null };
2586
+ }
2587
+
2588
+ /**
2589
+ * The newest refusal for a broken log since the newest checkpoint was
2590
+ * signed, by the vault's MAC, as it said: it stands, for every
2591
+ * verification and checkpoint, until a checkpoint is signed, which takes
2592
+ * the check that found it running again and passing. A refusal of
2593
+ * another kind since, a wrong KEK, hides none.
2594
+ */
2595
+ async #standingRefusal(db: Queryable): Promise<{ failedAtSeq?: number | null; reason?: string } | null> {
2596
+ const signed = await store.latestVaultEntry(db, [CHECKPOINT]);
2597
+ const refused = await store.newestRefusal(db, CHECKPOINT, 'log_broken', signed?.seq ?? -1n);
2598
+ if (refused === undefined || !this.#authentic(refused)) return null;
2599
+ return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
2600
+ }
2601
+
2467
2602
  async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
2468
2603
  await this.#settled();
2469
2604
  const { signers, since } = this.#prepared;
@@ -2487,41 +2622,123 @@ class VaultService implements Vault {
2487
2622
  return { current: { kekProvider: provider, kekId: keyId }, checks };
2488
2623
  }
2489
2624
 
2490
- verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2491
- return this.#verifyAll(input.upTo ?? null);
2492
- }
2493
-
2494
2625
  /**
2495
- * The whole log, in one snapshot and one pass over the vault's entries:
2496
- * each by its MAC, the keys it moved through, and what the other checks
2497
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2498
- * heads that must still be there, the key batches accounted for, and the
2499
- * members and grants replayed.
2626
+ * The log from a checkpoint's tally, in one snapshot: the vault's
2627
+ * entries since, each by its MAC, the keys it moved through, and what the
2628
+ * other checks need of it (`Walk`); then every checkpoint walked, the
2629
+ * heads that must still be there, and, for a walk that ends at the
2630
+ * present, the key batches accounted for and the members and grants
2631
+ * replayed against their rows.
2500
2632
  *
2501
2633
  * With `upTo`, the head the app verified to, the links and hashes of the
2502
- * chain are the app's to recompute, as it just has, over its own snapshot,
2503
- * through that head; `carries(upTo)` ties this snapshot to that one. So
2504
- * the vault reads only its own entries, each checked against its own hash
2505
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
2506
- * the chain: the vault reads it all, and rehashes every link.
2634
+ * chain are the app's to recompute, as it just has, over its own
2635
+ * snapshot, through that head; holding `upTo` ties this snapshot to that
2636
+ * one. So the vault reads only its own entries, each checked against its
2637
+ * own hash and MAC over the link it names. Without `upTo`, nobody else
2638
+ * recomputed the chain: the vault reads it all, and rehashes every link,
2639
+ * no further than one walk (`walks.verify`).
2507
2640
  */
2508
- async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
2641
+ async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2509
2642
  await this.#settled();
2510
- return this.#db.transaction(async (tx) => {
2643
+ const upTo = input.upTo ?? null;
2644
+ const { logKeys } = this.#prepared;
2645
+ const { walks } = this.#prepared.options;
2646
+ return this.#db.transaction(async (tx): Promise<LogVerification> => {
2511
2647
  const remembered = this.#prepared.verified;
2512
- const findings = new Findings();
2513
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2514
- if (!read.ok) return read;
2515
- // Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
2516
- const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2517
- const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
2518
- if (rememberedHead !== null) heads.push(rememberedHead);
2519
- const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2520
- const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2648
+ const from = await this.#resume(tx, input.from === undefined ? 'newest' : input.from === 'start' ? 'start' : BigInt(input.from));
2649
+ if (!from.ok) return from;
2650
+ // Where a stretch ends, the last entry its checkpoint names: that checkpoint, and each it passes on the
2651
+ // way, are held to the walk as it gets there, so a fault on the way is named where it is.
2652
+ const to = input.to === undefined ? null : BigInt(input.to);
2653
+ const ends = to === null ? null : (await store.entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
2654
+ if (ends !== null && (ends <= from.at.seq || (upTo !== null && BigInt(upTo.seq) !== ends))) {
2655
+ return { ok: false, failedAtSeq: Number(to), reason: 'not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to' };
2656
+ }
2657
+ // The prefix the walk starts from still there, as it was signed, and whole: a rewrite chained again
2658
+ // changes its hash, and a cut its count.
2659
+ if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) {
2660
+ return { ok: false, failedAtSeq: Number(from.entry), reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten` };
2661
+ }
2662
+ const gap = await missingEntry(tx, from.at.seq);
2663
+ if (gap !== null) return { ok: false, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2664
+ // Every checkpoint after it whose prefix the walk may pass, a stretch's end among them, signed while
2665
+ // the app walked or not: each tally is held to the walk where it gets there (`Walk.expect`).
2666
+ const walk = new Walk(from.tally, from.digest);
2667
+ const later = await store.talliedAfter(tx, from.entry ?? -1n, walks.verify);
2668
+ const passed = later.filter(({ seq }) => to === null || seq <= to);
2669
+ if (to !== null && passed.at(-1)?.seq !== to) {
2670
+ return { ok: false, failedAtSeq: Number(to), reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner` };
2671
+ }
2672
+ // Each one's tally as what the walk must come to, a few at a time, so that members and grants kept whole
2673
+ // in many of them are never all in memory: the digest it keeps of them stands for them.
2674
+ // Each one's members and grants are as its digest says, kept whole in it or in the earlier checkpoint it
2675
+ // names, each such checkpoint read once: what a walk resuming from it would find.
2676
+ const holding = new Map<bigint, string | null>();
2677
+ const carried = async (carrier: bigint): Promise<string | null> => {
2678
+ if (!holding.has(carrier)) {
2679
+ const entry = (await store.entriesAt(tx, [carrier])).get(carrier);
2680
+ const read = entry === undefined ? 'missing' : this.#tallied(entry);
2681
+ holding.set(carrier, typeof read === 'string' || 'at' in read.tally.access ? null : keptDigest(read.tally.access));
2682
+ }
2683
+ return holding.get(carrier)!;
2684
+ };
2685
+ const expected: Expected[] = [];
2686
+ // The hash the stretch's own checkpoint signed, as it is read among the others.
2687
+ let endSigned: string | null = null;
2688
+ for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== undefined && after < last; ) {
2689
+ const batch = await store.talliedWithin(tx, after, last, walks.verify + 1);
2690
+ if (batch.length === 0) break;
2691
+ for (const entry of batch) {
2692
+ const read = this.#tallied(entry);
2693
+ const point = { entry: entry.seq, through: entry.relatedSeq! };
2694
+ if (typeof read === 'string') {
2695
+ expected.push({ ...point, unreadable: read });
2696
+ continue;
2697
+ }
2698
+ if (entry.seq === to) endSigned = read.hash;
2699
+ const { access } = read.tally;
2700
+ if (!('at' in access)) holding.set(entry.seq, keptDigest(access));
2701
+ const kept = 'at' in access ? (BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null) : holding.get(entry.seq)!;
2702
+ expected.push(kept === access.digest
2703
+ ? { ...point, key: expectedKey(read.tally) }
2704
+ : { ...point, 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" });
2705
+ }
2706
+ after = batch.at(-1)!.seq;
2707
+ }
2708
+ walk.expect(expected);
2709
+ // How far the walk got, the hash there, and whether that is the present: the rows and the key batches
2710
+ // are held to the log only when the walk read every entry of the vault's there is.
2711
+ let through = from.at.seq;
2712
+ let reached = headOf(from.at).hash;
2713
+ let present = false;
2714
+ if (upTo === null) {
2715
+ const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
2716
+ if (!chain.ok) return chain;
2717
+ [through, reached, present] = [chain.at.seq, headOf(chain.at).hash, chain.done && ends === null];
2718
+ this.#prepared.verified = further(this.#prepared.verified, chain.at);
2719
+ } else {
2720
+ // On past the app's head, to the vault's last entry in this snapshot: the rows are as they are at
2721
+ // this one's. The app's head bounds what is verified; the vault's own walk, what the rows meet.
2722
+ const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
2723
+ if (!own.ok) return own;
2724
+ // An app older than 0.10.1 names no `from`, and takes an ok for the whole of `upTo`: it gets one only for that.
2725
+ if (!own.done && input.from === undefined) {
2726
+ return { ok: false, failedAtSeq: null, reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app` };
2727
+ }
2728
+ // Short of its own last entry, it has not held the rows to the log: never say it verified all the app did.
2729
+ const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
2730
+ [through, reached, present] = [own.done ? BigInt(upTo.seq) : short, upTo.hash, own.done && ends === null];
2731
+ }
2732
+ walk.finish(ends ?? through);
2733
+ // Each head the log must still hold, in one read: the app's, every
2734
+ // checkpoint's walked, and the last this vault rehashed.
2735
+ const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
2736
+ const heads = [...(upTo === null ? [] : [upTo]), ...(rememberedHead === null ? [] : [rememberedHead])];
2737
+ const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
2521
2738
  if (upTo !== null && !holds(upTo)) {
2522
2739
  return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
2523
2740
  }
2524
- const unsigned = await this.#checkpointFault(findings, holds);
2741
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
2525
2742
  if (unsigned !== null) return unsigned;
2526
2743
  // What this vault last verified must still be there: whoever holds
2527
2744
  // its key can seal a rewrite, but cannot put back the head it saw. A
@@ -2529,57 +2746,46 @@ class VaultService implements Vault {
2529
2746
  if (rememberedHead !== null && !holds(rememberedHead)) {
2530
2747
  return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
2531
2748
  }
2749
+ if (walk.mismatch !== null) return { ok: false, failedAtSeq: Number(walk.mismatch.entry), reason: walk.mismatch.reason };
2750
+ if (ends !== null) {
2751
+ // The walk ends at the prefix the stretch's checkpoint signed, by its hash too: a chain made again from before it is not.
2752
+ if ((upTo === null && through !== ends) || endSigned !== reached) {
2753
+ return { ok: false, failedAtSeq: Number(to), reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten` };
2754
+ }
2755
+ return { ok: true, entries: walk.entries, through: Number(ends) };
2756
+ }
2757
+ // A checkpoint refused since the newest was signed, for a broken log: what it found, the stretch of the
2758
+ // past it rehashed among it, which no walk from a tally reads again.
2759
+ const standing = await this.#standingRefusal(tx);
2760
+ if (standing !== null) {
2761
+ return { ok: false, failedAtSeq: standing.failedAtSeq ?? null, reason: `the last checkpoint was refused: ${standing.reason ?? 'the log is broken'}` };
2762
+ }
2763
+ const verified = { ok: true as const, entries: walk.entries, through: Number(through) };
2764
+ if (!present) return verified;
2532
2765
  const at = await this.#now(tx);
2533
- const accounting = findings.accounting.result(at);
2766
+ const accounting = walk.accounting.result(at);
2534
2767
  if (!accounting.ok) return accounting;
2535
2768
  const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
2536
- const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
2769
+ const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
2537
2770
  if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
2538
- const verified = { ok: true as const, entries: findings.entries };
2539
2771
  return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
2540
2772
  }, SNAPSHOT);
2541
2773
  }
2542
2774
 
2543
2775
  /**
2544
- * The vault's own entries, oldest first: each against its own hash, and by
2545
- * its MAC, over the link it names, which the app checked; and the vault's
2546
- * keys moving only forward through them.
2776
+ * Every checkpoint walked, not only the newest: each signed by the key
2777
+ * whose log key wrote its entry, over a prefix the log still holds,
2778
+ * entry for entry, which one that keeps a tally names as related. Its
2779
+ * entry's MAC is checked by now, and one under a key the vault replaced
2780
+ * comes before the rotation.
2547
2781
  */
2548
- async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2549
- const { logKeys } = this.#prepared;
2550
- let keys: readonly string[] = [];
2551
- for (let after = -1n; ; ) {
2552
- const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
2553
- if (batch.length === 0) return { ok: true };
2554
- for (const entry of batch) {
2555
- const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
2556
- if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
2557
- }
2558
- const moved = forward(batch, keys, logKeys[0]!.keyId);
2559
- if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
2560
- keys = moved.keys;
2561
- for (const entry of batch) findings.add(entry);
2562
- after = batch[batch.length - 1]!.seq;
2563
- }
2564
- }
2565
-
2566
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
2567
- async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2568
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
2569
- for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
2570
- });
2571
- if (!verification.ok) return verification;
2572
- this.#prepared.verified = further(this.#prepared.verified, anchor);
2573
- return { ok: true };
2574
- }
2575
-
2576
- /**
2577
- * Every checkpoint, not only the newest: each signed by the key whose log
2578
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
2579
- * for entry. Its entry's MAC is checked by now, and one under a key the
2580
- * vault replaced comes before the rotation.
2581
- */
2582
- async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
2782
+ async #checkpointFault(
2783
+ db: Queryable,
2784
+ checkpoints: Walk['checkpoints'],
2785
+ known?: (head: LogHead) => boolean,
2786
+ ): Promise<Broken | null> {
2787
+ if (checkpoints.length === 0) return null;
2788
+ const holds = known ?? (await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint)));
2583
2789
  const { logKeys, signers } = this.#prepared;
2584
2790
  // Each signer's key imported once, for this check only.
2585
2791
  const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
@@ -2588,7 +2794,6 @@ class VaultService implements Vault {
2588
2794
  if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
2589
2795
  return verifier.then((verify) => verify(checkpoint));
2590
2796
  };
2591
- const { checkpoints } = findings;
2592
2797
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
2593
2798
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
2594
2799
  // The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
@@ -2596,7 +2801,7 @@ class VaultService implements Vault {
2596
2801
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2597
2802
  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`;
2598
2803
  if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
2599
- if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
2804
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || (entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq))) {
2600
2805
  return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
2601
2806
  }
2602
2807
  return null;
@@ -2609,8 +2814,8 @@ class VaultService implements Vault {
2609
2814
 
2610
2815
  /**
2611
2816
  * The first member whose row fails its MAC, or names an older access
2612
- * entry than the log's newest about them (`newest`, from the full check's
2613
- * pass, where each entry's MAC was checked).
2817
+ * entry than the log's newest about them (`newest`, from the walk, where
2818
+ * each entry's MAC was checked, on from the tally).
2614
2819
  */
2615
2820
  #unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
2616
2821
  for (const row of rows) {
@@ -2622,40 +2827,80 @@ class VaultService implements Vault {
2622
2827
  }
2623
2828
  }
2624
2829
 
2830
+ type Broken = Extract<LogVerification, { ok: false }>;
2831
+
2625
2832
  /**
2626
- * What one pass over the vault's entries gathers for the other checks of a
2627
- * full verification, so that the log is read once: the key batches, the
2628
- * access entries replayed, each member's newest, and the checkpoints. Each
2629
- * entry comes after its MAC was checked.
2833
+ * Where a walk of the log resumes: the checkpoint's entry (null for the
2834
+ * first entry), the end of the prefix it signed, the tally through there,
2835
+ * its members and grants' digest and the entry that keeps them, and where
2836
+ * the background rehash goes on from (`#scrub`).
2630
2837
  */
2631
- class Findings {
2632
- readonly accounting = new KeyAccounting();
2633
- readonly replayed: Replayed = { members: new Map(), held: new Map() };
2634
- /** The first access entry the replay could not apply. */
2635
- replayFault: AccessFault | null = null;
2636
- /** Each member's newest allowed access entry, by seq. */
2637
- readonly newestAccess = new Map<string, bigint>();
2638
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
2639
- readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
2640
- /** The vault's entries read. */
2641
- entries = 0;
2642
-
2643
- add(entry: StoredEntry): void {
2644
- this.entries += 1;
2645
- this.accounting.add(entry);
2646
- if (entry.decision !== 'allow') return;
2647
- if (ACCESS.has(entry.action)) {
2648
- this.replayFault ??= apply(this.replayed, entry);
2649
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
2650
- } else if (entry.action === CHECKPOINT) {
2651
- this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
2652
- }
2838
+ type Resumed = {
2839
+ ok: true;
2840
+ entry: bigint | null;
2841
+ at: Position;
2842
+ tally: Tally;
2843
+ digest: string;
2844
+ carrier: bigint | null;
2845
+ scrub: Lap | null;
2846
+ };
2847
+
2848
+ /** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
2849
+ type Lap = { at: Position; end: Position };
2850
+
2851
+ /** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
2852
+ function timedOut(error: unknown): boolean {
2853
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
2854
+ if ((cause as { code?: unknown }).code === '57014') return true;
2653
2855
  }
2856
+ return false;
2857
+ }
2858
+
2859
+ /** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
2860
+ type Scrubbed = { ok: true; next: Lap | null } | Broken;
2861
+
2862
+ const FIRST_ENTRY: Resumed = { ok: true, entry: null, at: START, tally: EMPTY_TALLY, digest: accessDigest(EMPTY_TALLY.access), carrier: null, scrub: null };
2863
+
2864
+ /** How far one call walks the log, in entries: a checkpoint's walk from the last, its rehash of the past, and a verification's own walk. */
2865
+ export type Walks = { checkpoint: number; scrub: number; verify: number };
2866
+
2867
+ /**
2868
+ * A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
2869
+ * with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
2870
+ * of catching up, about a second, and a team's instance writes fewer than
2871
+ * a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
2872
+ * and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
2873
+ */
2874
+ const WALKS: Walks = { checkpoint: 10_000, scrub: 100, verify: 10_000 };
2875
+
2876
+ /** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
2877
+ function keptDigest({ members, grants, newest }: AccessTally): string {
2878
+ return accessDigest({ members, grants, newest });
2879
+ }
2880
+
2881
+ /** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
2882
+ const parsed = new WeakMap<StoredEntry, unknown>();
2883
+ function metadataOf(entry: StoredEntry): unknown {
2884
+ if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
2885
+ return parsed.get(entry);
2654
2886
  }
2655
2887
 
2656
- const ACCESS = new Set<string>(ACCESS_ACTIONS);
2888
+ /** A head, as an entry keeps it, as a walk goes on from it. */
2889
+ function positionOf(head: LogHead): Position {
2890
+ return { seq: BigInt(head.seq), hash: Buffer.from(head.hash, 'hex') };
2891
+ }
2892
+
2893
+ /** A position as the log's heads are named, in hex. */
2894
+ function headOf(position: Position): LogHead {
2895
+ return { seq: Number(position.seq), hash: position.hash.toString('hex') };
2896
+ }
2897
+
2898
+ /** What a checkpoint's signature covers, and nothing it keeps beside it. */
2899
+ function signedPart({ seq, hash, signedAt, keyId, signature }: Checkpoint): Checkpoint {
2900
+ return { seq, hash, signedAt, keyId, signature };
2901
+ }
2657
2902
 
2658
- /** Checkpoint signatures checked at once, in a full verification. */
2903
+ /** Checkpoint signatures checked at once. */
2659
2904
  const SIGNATURES_AT_ONCE = 256;
2660
2905
 
2661
2906
  /** Settle every operation before releasing the member lock, including cancelled requests. */