@coffre/vault 0.1.0 → 0.1.2

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
@@ -31,6 +31,7 @@ import {
31
31
  type AccessFault,
32
32
  type AdmitInput,
33
33
  type Checkpoint,
34
+ type CheckpointKey,
34
35
  type Grant,
35
36
  type GrantChange,
36
37
  type LogHead,
@@ -50,7 +51,7 @@ import {
50
51
  } from '@coffre/core/vault';
51
52
  import type { Database, Queryable, Transaction } from '@coffre/db';
52
53
  import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
53
- import { appendEntries, lockLogHead, type NewEntry } from '@coffre/db/log';
54
+ import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
54
55
 
55
56
  import { verifyAccounting } from './accounting.ts';
56
57
  import { signer, type Signer } from './checkpoint.ts';
@@ -79,15 +80,29 @@ export type VaultOptions = {
79
80
  */
80
81
  export type PreparedVault = {
81
82
  config: ResolvedVaultConfig;
83
+ /** What signs checkpoints now: the first of `signers`. */
82
84
  signer: Signer;
85
+ /** A signer for each of its signing keys: a checkpoint is signed by the one whose log key wrote its entry. */
86
+ signers: Signer[];
87
+ /** What the vault's entries are MACed with now: the first of `logKeys`. */
83
88
  logKey: LogKey;
89
+ /** Each signing key's log key: those after the first verify only entries before `since`. */
90
+ logKeys: LogKey[];
84
91
  options: Required<VaultOptions>;
85
92
  /** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
86
93
  verified: Anchor;
87
94
  /** Root admins known to have a member row; rows are never deleted. */
88
95
  rooted: Set<string>;
89
- /** What member rows are sealed under; rows.ts. */
96
+ /** What member rows are sealed under now; rows.ts. */
90
97
  rowKey: Buffer;
98
+ /** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
99
+ rowKeys: Buffer[];
100
+ /** Its first entry under its current key, where the keys it replaced stop counting, once settled (`#settle`). */
101
+ since: bigint | null;
102
+ /** Why it writes nothing, once settled that it may not. */
103
+ superseded: string | null;
104
+ /** The settling under way. */
105
+ settling: Promise<void> | null;
91
106
  /** Tampering this process has logged already, so that a forged row is one entry, not one per request. */
92
107
  reported: Set<string>;
93
108
  /** Whether its KEKs open what they wrapped, once asked: `#kekMismatch`. */
@@ -99,18 +114,27 @@ export type PreparedVault = {
99
114
  export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
100
115
  return {
101
116
  config,
102
- signer: await signer(config.signingKey),
103
- logKey: vaultLogKey(config.signingKey),
117
+ ...(await keysOf(config.signingKeys)),
104
118
  options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
105
119
  verified: UNVERIFIED,
106
120
  rooted: new Set(),
107
- rowKey: rowKey(config.signingKey),
108
121
  reported: new Set(),
122
+ since: null,
123
+ superseded: null,
124
+ settling: null,
109
125
  kekCheck: null,
110
126
  wrongKek: null,
111
127
  };
112
128
  }
113
129
 
130
+ /** The vault's own keys, from each of its signing keys, the one it signs with first. */
131
+ async function keysOf(seeds: readonly Uint8Array[]) {
132
+ const signers = await Promise.all(seeds.map((seed) => signer(seed)));
133
+ const logKeys = seeds.map((seed) => vaultLogKey(seed));
134
+ const rowKeys = seeds.map((seed) => rowKey(seed));
135
+ return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
136
+ }
137
+
114
138
  /** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
115
139
  export function openVault(db: Database, prepared: PreparedVault): Vault {
116
140
  return new VaultService(db, prepared);
@@ -141,6 +165,9 @@ const KEY_CHECK_CONTEXT: SecretContext = { projectId: NIL, environmentId: NIL, s
141
165
  /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
142
166
  const KEY_CHECK_SAMPLE = 3;
143
167
 
168
+ /** The vault's first entry under a new key, which the keys it replaced count only before (`#settle`). */
169
+ const KEY_ROTATE = 'key.rotate';
170
+
144
171
  /** A new member row, before the decision seals it (`#seal`). */
145
172
  const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
146
173
 
@@ -289,6 +316,8 @@ class VaultService implements Vault {
289
316
  * fails the call.
290
317
  */
291
318
  async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
319
+ const superseded = await this.#settled();
320
+ if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
292
321
  const reports: NewEntry[] = [];
293
322
  for (let attempt = 1; ; attempt += 1) {
294
323
  try {
@@ -316,7 +345,7 @@ class VaultService implements Vault {
316
345
  // Each member's newest access entry, which their row names (rows.ts).
317
346
  const accessSeq = new Map<string, bigint>();
318
347
  if (entries.length > 0) {
319
- const appended = await appendEntries(tx, this.#prepared.logKey, entries);
348
+ const appended = await this.#append(tx, entries);
320
349
  at = appended.occurredAt;
321
350
  entries.forEach((entry, i) => {
322
351
  if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
@@ -338,7 +367,7 @@ class VaultService implements Vault {
338
367
  if (entries.length > 0) {
339
368
  await this.#db.transaction(async (tx) => {
340
369
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
341
- await appendEntries(tx, this.#prepared.logKey, entries);
370
+ await this.#append(tx, entries);
342
371
  });
343
372
  }
344
373
  this.#reported(reports);
@@ -376,7 +405,7 @@ class VaultService implements Vault {
376
405
  * could lock any member out.
377
406
  */
378
407
  async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
379
- if (row !== undefined && !sealed(this.#prepared.rowKey, row, grants)) {
408
+ if (row !== undefined && !this.#sealed(row, grants)) {
380
409
  this.#report(reports, principal, 'mac', row.mac.toString('hex'));
381
410
  return 'mac';
382
411
  }
@@ -398,8 +427,21 @@ class VaultService implements Vault {
398
427
  return null;
399
428
  }
400
429
 
430
+ /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
401
431
  #authentic(entry: StoredEntry): boolean {
402
- return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: [this.#prepared.logKey] }).ok;
432
+ const { logKeys, since } = this.#prepared;
433
+ const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
434
+ return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
435
+ }
436
+
437
+ /**
438
+ * Whether `row`, with these grants, carries the MAC of the vault's row
439
+ * key, or of one it replaced before the rotation sealed every row again
440
+ * (`#rotate`).
441
+ */
442
+ #sealed(row: Member, grants: readonly GrantRow[]): boolean {
443
+ const { rowKeys, since } = this.#prepared;
444
+ return (since === null ? rowKeys : rowKeys.slice(0, 1)).some((key) => sealed(key, row, grants));
403
445
  }
404
446
 
405
447
  /** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
@@ -429,11 +471,121 @@ class VaultService implements Vault {
429
471
 
430
472
  /** Reports found outside a decision, committed on their own. */
431
473
  async #record(reports: NewEntry[]): Promise<void> {
432
- if (reports.length === 0) return;
433
- await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, reports));
474
+ if (reports.length === 0 || (await this.#settled()) !== null) return;
475
+ await this.#db.transaction((tx) => this.#append(tx, reports));
434
476
  this.#reported(reports);
435
477
  }
436
478
 
479
+ // --- its keys over time -------------------------------------------------------
480
+
481
+ /**
482
+ * Null when the vault may write, once its keys are settled (`#settle`);
483
+ * otherwise why not. Settled by the first call of a process; one that
484
+ * fails leaves it to the next.
485
+ */
486
+ async #settled(): Promise<string | null> {
487
+ const prepared = this.#prepared;
488
+ if (prepared.since === null && prepared.superseded === null) {
489
+ prepared.settling ??= this.#settle().finally(() => {
490
+ prepared.settling = null;
491
+ });
492
+ await prepared.settling;
493
+ }
494
+ return prepared.superseded;
495
+ }
496
+
497
+ /**
498
+ * Which of its keys count where. Those it replaced, from the KEKs in
499
+ * previousKeks, verify only what came before its first entry under its
500
+ * current key, `since`, and vouch for no member row after it. The first
501
+ * call finds that entry, or makes it when every entry of the vault's is
502
+ * still under a key it replaced (`#rotate`). A vault whose key the log has
503
+ * moved on from, or that holds no key its newest entries are under,
504
+ * writes nothing: `superseded` says why.
505
+ */
506
+ async #settle(): Promise<void> {
507
+ const prepared = this.#prepared;
508
+ const current = prepared.logKey.keyId;
509
+ for (let attempt = 1; ; attempt += 1) {
510
+ const rotation = await store.latestVaultEntry(this.#db, [KEY_ROTATE]);
511
+ if (rotation?.keyId === current && this.#authentic(rotation)) {
512
+ prepared.since = rotation.seq;
513
+ return;
514
+ }
515
+ const [newest] = await store.vaultPage(this.#db, undefined, 1);
516
+ // A log the vault has not written to: nothing to settle until it does.
517
+ if (newest === undefined) return;
518
+ const first = await store.firstVaultEntryUnder(this.#db, current);
519
+ if (first !== undefined && newest.keyId === current) {
520
+ prepared.since = first;
521
+ return;
522
+ }
523
+ if (first === undefined && prepared.logKeys.some((key) => key.keyId === newest.keyId)) {
524
+ try {
525
+ prepared.since = await this.#rotate(newest.keyId);
526
+ return;
527
+ } catch (error) {
528
+ if (attempt < 3 && error instanceof Retry) continue;
529
+ throw error;
530
+ }
531
+ }
532
+ prepared.superseded =
533
+ first === undefined
534
+ ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong KEK or signing key, or a KEK it replaced is missing from previousKeks`
535
+ : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
536
+ return;
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Move the vault to its current key: every member row it can vouch for
542
+ * sealed again under the current row key, then a `key.rotate` entry, its
543
+ * first under the key, after which the keys it replaced count for
544
+ * nothing. One transaction, which locks every row as a decision does,
545
+ * then the log's head. Another instance that rotated first, or a member
546
+ * admitted since the rows were read, sends it back to `#settle`.
547
+ */
548
+ async #rotate(from: string): Promise<bigint> {
549
+ const reports: NewEntry[] = [];
550
+ const since = await this.#db.transaction(async (tx) => {
551
+ await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
552
+ const principals = (await store.allMembers(tx)).map((row) => row.principal);
553
+ const rows = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
554
+ await lockLogHead(tx);
555
+ const [newest] = await store.vaultPage(tx, undefined, 1);
556
+ if (newest?.keyId !== from || (await store.allMembers(tx)).length !== rows.size) throw new Retry();
557
+ const held = await store.grants(tx);
558
+ for (const row of rows.values()) {
559
+ const grants = held.filter((grant) => grant.principal === row.principal);
560
+ // A row that fails is reported, and left under the key it was sealed with, which fails it from now on.
561
+ if ((await this.#integrity(tx, row.principal, row, grants, reports)) === null) {
562
+ await store.updateMember(tx, row.principal, { mac: memberMac(this.#prepared.rowKey, row, grants) });
563
+ }
564
+ }
565
+ const rotated = { actor: VAULT_ACTOR, action: KEY_ROTATE, decision: 'allow' as const, metadata: JSON.stringify({ from }) };
566
+ return (await this.#append(tx, [rotated, ...reports])).seqStart;
567
+ });
568
+ this.#reported(reports);
569
+ return since;
570
+ }
571
+
572
+ /**
573
+ * Append under the vault's current key, unless the log's newest rotation
574
+ * is to a key it does not hold: a vault given a newer KEK has replaced
575
+ * this one, which writes nothing more. Checked under the head's lock, so
576
+ * that an instance still running with the replaced KEK, as during a
577
+ * deploy, cannot write after the rotation.
578
+ */
579
+ #append(tx: Transaction, entries: readonly NewEntry[]): Promise<Appended> {
580
+ const prepared = this.#prepared;
581
+ return appendEntries(tx, prepared.logKey, entries, async (locked) => {
582
+ const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
583
+ if (rotation === undefined || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
584
+ prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
585
+ throw new Error(prepared.superseded);
586
+ });
587
+ }
588
+
437
589
  // --- keys -------------------------------------------------------------------
438
590
 
439
591
  // Raw DEKs are cleared on every path. JSON and base64 leave strings that
@@ -656,7 +808,7 @@ class VaultService implements Vault {
656
808
  await check(d);
657
809
  await lockLogHead(d.tx);
658
810
  const at = await this.#now(d.tx);
659
- const appended = await appendEntries(d.tx, this.#prepared.logKey, [{
811
+ const appended = await this.#append(d.tx, [{
660
812
  actor: principal,
661
813
  action: 'key.intent',
662
814
  decision: 'allow',
@@ -739,11 +891,11 @@ class VaultService implements Vault {
739
891
  * anyone a root admin; the configuration does.
740
892
  */
741
893
  async #rootRow(principal: string): Promise<void> {
742
- if (this.#prepared.rooted.has(principal)) return;
894
+ if (this.#prepared.rooted.has(principal) || (await this.#settled()) !== null) return;
743
895
  await this.#db.transaction(async (tx) => {
744
896
  await lockLogHead(tx);
745
897
  if ((await store.member(tx, principal)) !== undefined) return;
746
- const appended = await appendEntries(tx, this.#prepared.logKey, [
898
+ const appended = await this.#append(tx, [
747
899
  {
748
900
  actor: VAULT_ACTOR,
749
901
  action: 'member.add',
@@ -820,6 +972,7 @@ class VaultService implements Vault {
820
972
  * looks like one.
821
973
  */
822
974
  async access(principal: string): Promise<Access> {
975
+ await this.#settled();
823
976
  if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
824
977
  const read = async (db: Queryable, reports: NewEntry[]) => {
825
978
  const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
@@ -859,7 +1012,7 @@ class VaultService implements Vault {
859
1012
  if (this.#isRootAdmin(row.principal)) continue;
860
1013
  const grants = held.filter((grant) => grant.principal === row.principal);
861
1014
  const entry = newest.get(row.principal);
862
- const holds = entry !== undefined && this.#authentic(entry) && sealed(this.#prepared.rowKey, row, grants) && entry.seq === row.accessSeq;
1015
+ const holds = entry !== undefined && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq;
863
1016
  if (!holds) await this.#integrity(db, row.principal, row, grants, reports);
864
1017
  }
865
1018
  }
@@ -1182,6 +1335,9 @@ class VaultService implements Vault {
1182
1335
  * A KEK that opens neither is not the one that wrapped the data.
1183
1336
  */
1184
1337
  async #checkKeks(): Promise<string | null> {
1338
+ // A vault that may not write cannot record a check either; why it may not is the answer.
1339
+ const superseded = await this.#settled();
1340
+ if (superseded !== null) return superseded;
1185
1341
  const checks = new Map<string, WrappedDek>();
1186
1342
  for (const entry of await store.vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1187
1343
  // A check in the vault's name that the vault did not write proves nothing either way.
@@ -1221,7 +1377,7 @@ class VaultService implements Vault {
1221
1377
  const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
1222
1378
  // The key it opened, if any, is in the log, as every key the vault opens is.
1223
1379
  await this.#db.transaction((tx) =>
1224
- appendEntries(tx, this.#prepared.logKey, [
1380
+ this.#append(tx, [
1225
1381
  { actor: VAULT_ACTOR, action: KEY_CHECK, decision: 'allow', metadata: JSON.stringify({ ...serialisable(wrapped), proof }) },
1226
1382
  ]),
1227
1383
  );
@@ -1238,6 +1394,7 @@ class VaultService implements Vault {
1238
1394
  }
1239
1395
 
1240
1396
  async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
1397
+ await this.#settled();
1241
1398
  // A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
1242
1399
  // own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
1243
1400
  const { wrongKek } = this.#prepared;
@@ -1250,7 +1407,7 @@ class VaultService implements Vault {
1250
1407
  const found: NewEntry[] = [];
1251
1408
  const whole = await this.#db.transaction(async (tx) => {
1252
1409
  await this.#sweep(tx, found);
1253
- return verifyChain(tx, this.#prepared.logKey, [], UNVERIFIED);
1410
+ return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
1254
1411
  }, SNAPSHOT);
1255
1412
  return this.#decide([], async (d) => {
1256
1413
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
@@ -1274,7 +1431,7 @@ class VaultService implements Vault {
1274
1431
  if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
1275
1432
  throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
1276
1433
  }
1277
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKey, [], whole.anchor);
1434
+ const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
1278
1435
  if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
1279
1436
  // It signs the entry it verified to, which the head, locked, must name.
1280
1437
  if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
@@ -1291,8 +1448,15 @@ class VaultService implements Vault {
1291
1448
  });
1292
1449
  }
1293
1450
 
1294
- async about(): Promise<{ publicKey: string; rootAdmins: string[] }> {
1295
- return { publicKey: this.#prepared.signer.publicKey, rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`) };
1451
+ async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; rootAdmins: string[] }> {
1452
+ await this.#settled();
1453
+ const { signers, since } = this.#prepared;
1454
+ // A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
1455
+ const until = Number(since ?? 0n);
1456
+ return {
1457
+ checkpointKeys: Object.fromEntries(signers.map(({ keyId, publicKey }, i) => [keyId, { publicKey, until: i === 0 ? null : until }])),
1458
+ rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`),
1459
+ };
1296
1460
  }
1297
1461
 
1298
1462
  verifyLog(input: VerifyLogInput): Promise<LogVerification> {
@@ -1301,7 +1465,7 @@ class VaultService implements Vault {
1301
1465
 
1302
1466
  /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
1303
1467
  async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
1304
- const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKey, shown, anchor);
1468
+ const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
1305
1469
  if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
1306
1470
  return verification;
1307
1471
  }
@@ -1314,7 +1478,8 @@ class VaultService implements Vault {
1314
1478
  * checkpoint (`through`), and the last checkpoint's; and the members and
1315
1479
  * grants replayed from it.
1316
1480
  */
1317
- #verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
1481
+ async #verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
1482
+ await this.#settled();
1318
1483
  return this.#db.transaction(async (tx) => {
1319
1484
  const remembered = this.#prepared.verified;
1320
1485
  const verification = await this.#verify(tx, shown, UNVERIFIED);
@@ -1343,9 +1508,10 @@ class VaultService implements Vault {
1343
1508
  }
1344
1509
 
1345
1510
  /**
1346
- * Every checkpoint, not only the newest: each signed by the vault's key,
1347
- * over a prefix the log still holds, entry for entry. The chain is
1348
- * verified by now, so each checkpoint entry is the vault's.
1511
+ * Every checkpoint, not only the newest: each signed by the key whose log
1512
+ * key wrote its entry, over a prefix the log still holds, entry for entry.
1513
+ * The chain is verified by now, so each checkpoint entry is the vault's,
1514
+ * and one under a key it replaced comes before the rotation.
1349
1515
  */
1350
1516
  async #checkpointFault(db: Queryable): Promise<Extract<LogVerification, { ok: false }> | null> {
1351
1517
  for (let after = -1n; ; ) {
@@ -1353,7 +1519,10 @@ class VaultService implements Vault {
1353
1519
  for (const entry of batch) {
1354
1520
  const checkpoint = JSON.parse(entry.metadata) as Checkpoint;
1355
1521
  const broken = (reason: string) => ({ ok: false as const, failedAtSeq: Number(entry.seq), reason });
1356
- if (!(await verifyCheckpoint(checkpoint, this.#prepared.signer.publicKey))) return broken('a checkpoint the vault did not sign');
1522
+ const { logKeys, signers } = this.#prepared;
1523
+ const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
1524
+ 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`);
1525
+ if (!(await verifyCheckpoint(checkpoint, by.publicKey))) return broken('a checkpoint the vault did not sign');
1357
1526
  if (BigInt(checkpoint.seq) >= entry.seq || !(await carries(db, checkpoint))) {
1358
1527
  return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
1359
1528
  }
@@ -1372,7 +1541,7 @@ class VaultService implements Vault {
1372
1541
  const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
1373
1542
  for (const row of rows) {
1374
1543
  const grants = held.filter((grant) => grant.principal === row.principal);
1375
- if (!sealed(this.#prepared.rowKey, row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
1544
+ if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
1376
1545
  if (newest.get(row.principal)?.seq !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
1377
1546
  }
1378
1547
  return null;