@coffre/vault 0.1.11 → 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/src/vault.ts CHANGED
@@ -4,13 +4,12 @@ import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
6
  isRole,
7
- isSyncPrincipal,
8
7
  mayManageAccess,
9
8
  type Holdings,
10
9
  type Permission,
11
10
  type Role,
12
11
  } from '@coffre/core/access';
13
- import { verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
12
+ import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
14
13
  import { checkContext, type SecretContext } from '@coffre/core/envelope';
15
14
  import {
16
15
  DEK_BYTES,
@@ -27,7 +26,7 @@ import {
27
26
  } from '@coffre/core/kek';
28
27
  import {
29
28
  checkpointMessage,
30
- verifyCheckpoint,
29
+ checkpointVerifier,
31
30
  describeAccessFault,
32
31
  type Access,
33
32
  type AccessChange,
@@ -57,11 +56,11 @@ import type { Database, Queryable, Transaction } from '@coffre/db';
57
56
  import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
58
57
  import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
59
58
 
60
- import { verifyAccounting } from './accounting.ts';
59
+ import { KeyAccounting } from './accounting.ts';
61
60
  import { signer, type Signer } from './checkpoint.ts';
62
61
  import type { ResolvedVaultConfig } from './config.ts';
63
- import { carries, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, type Anchor } from './log.ts';
64
- 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';
65
64
  import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
66
65
  import * as store from './store.ts';
67
66
  import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
@@ -149,7 +148,9 @@ const KEY_BUDGET_MS = 5_000;
149
148
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
150
149
  const LOCK_TIMEOUT_MS = 15_000;
151
150
 
151
+ // Historical principals remain valid audit subjects and sealed rows.
152
152
  const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
153
+ const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
153
154
 
154
155
  /** Who acts for the vault itself, as when it gives a root admin a member row. */
155
156
  const VAULT_ACTOR = 'system:vault';
@@ -937,6 +938,7 @@ class VaultService implements Vault {
937
938
  const grants = row === undefined ? [] : await store.grants(db, principal);
938
939
  const fault = await this.#integrity(db, principal, row, grants, reports);
939
940
  if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
941
+ if (!LIVE_PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
940
942
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
941
943
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
942
944
  const isOwner = row.owner && principal.startsWith('user:');
@@ -954,10 +956,10 @@ class VaultService implements Vault {
954
956
  if (this.#isRootAdmin(principal)) {
955
957
  return { principal, status: 'active', generation: row?.generation ?? 0, isRootAdmin: true, isOwner: true, grants: [], since: null, by: null };
956
958
  }
957
- const active = !tampered && row?.status === 'active';
959
+ const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
958
960
  return {
959
961
  principal,
960
- status: tampered ? 'tampered' : (row?.status ?? 'unknown'),
962
+ status: tampered ? 'tampered' : !LIVE_PRINCIPAL.test(principal) ? 'unknown' : (row?.status ?? 'unknown'),
961
963
  generation: row?.generation ?? 0,
962
964
  isRootAdmin: false,
963
965
  isOwner: active && row.owner && principal.startsWith('user:'),
@@ -1033,7 +1035,7 @@ class VaultService implements Vault {
1033
1035
  }]);
1034
1036
  return this.#decide([actor, principal], async (d) => {
1035
1037
  validateCorrelation(input);
1036
- if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
1038
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a principal: ${principal}`);
1037
1039
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1038
1040
  const places = new Set<string>();
1039
1041
  for (const change of input.changes) {
@@ -1061,32 +1063,13 @@ class VaultService implements Vault {
1061
1063
  }
1062
1064
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1063
1065
  if (acting.status === 'tampered') throw refused('tampered');
1064
- if (!input.changes.every((change) => mayManageAccess(acting.live, principal, change))) throw refused('not_allowed');
1066
+ if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused('not_allowed');
1065
1067
 
1066
1068
  const row = d.members.get(principal);
1067
1069
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1068
1070
  if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
1069
1071
  if (row?.status === 'removed') throw refused('removed');
1070
- if (row === undefined) {
1071
- // A sync is a member from its first grant; anyone else is admitted first.
1072
- if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) {
1073
- throw refused('not_a_member');
1074
- }
1075
- d.log.push(accessEntry(actor, 'member.add', principal, 'allow', input, { owner: false }));
1076
- d.writes.push(async (at) => {
1077
- await store.insertMember(d.tx, {
1078
- principal,
1079
- status: 'active',
1080
- owner: false,
1081
- generation: 0,
1082
- createdAt: at,
1083
- createdBy: actor,
1084
- statusChangedAt: at,
1085
- statusChangedBy: actor,
1086
- ...UNSEALED,
1087
- });
1088
- });
1089
- }
1072
+ if (row === undefined) throw refused('not_a_member');
1090
1073
  // The grants the check verified, not a second read: what the decision changes, and seals.
1091
1074
  const held = subject.stored;
1092
1075
  d.grants.set(principal, [...held]);
@@ -1156,7 +1139,7 @@ class VaultService implements Vault {
1156
1139
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1157
1140
  if (acting.status === 'tampered') throw refused('tampered');
1158
1141
  if (!acting.live.isOwner) throw refused('not_allowed', 'only owners may add or restore members');
1159
- if (!PRINCIPAL.test(principal) || isSyncPrincipal(principal)) throw refused('invalid', `not a member: ${principal}`);
1142
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
1160
1143
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1161
1144
  if (input.owner === true && !principal.startsWith('user:')) {
1162
1145
  throw refused('invalid', 'service accounts cannot be owners');
@@ -1208,6 +1191,7 @@ class VaultService implements Vault {
1208
1191
  new Refused(refusal(code, message), [accessEntry(actor, 'member.remove', principal, 'deny', input, {}, code)]);
1209
1192
  return this.#decide([actor, principal], async (d) => {
1210
1193
  validateCorrelation(input);
1194
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${principal}`);
1211
1195
  if (this.#isRootAdmin(principal)) throw refused('root_admin');
1212
1196
  const row = d.members.get(principal);
1213
1197
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
@@ -1219,15 +1203,7 @@ class VaultService implements Vault {
1219
1203
  if (!holder.isOwner) throw refused('not_allowed', 'only owners may remove a member whose record failed its check');
1220
1204
  return this.#startOver(d, actor, principal, row, subject.fault!, input, refused);
1221
1205
  }
1222
- // Owners remove anyone. Removing a sync only takes access away, so
1223
- // whoever may take away one of its grants, or manage it at its
1224
- // source, may remove it, and anyone may remove one that holds nothing.
1225
- const places = input.source === undefined ? held : [...held, input.source];
1226
- const may =
1227
- holder.isOwner ||
1228
- (isSyncPrincipal(principal) &&
1229
- (held.length === 0 || places.some((place) => mayManageAccess(holder, principal, { ...place, role: null }))));
1230
- if (!may) throw refused('not_allowed', 'only owners may remove members');
1206
+ if (!holder.isOwner) throw refused('not_allowed', 'only owners may remove members');
1231
1207
  if (row?.status !== 'active') throw refused(row === undefined ? 'not_a_member' : 'removed');
1232
1208
 
1233
1209
  const revoked = held.filter((grant) => live(grant, d.at));
@@ -1480,94 +1456,176 @@ class VaultService implements Vault {
1480
1456
  }
1481
1457
 
1482
1458
  verifyLog(input: VerifyLogInput): Promise<LogVerification> {
1483
- return this.#verifyAll([], input.upTo ?? null);
1484
- }
1485
-
1486
- /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
1487
- async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
1488
- const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
1489
- if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
1490
- return verification;
1459
+ return this.#verifyAll(input.upTo ?? null);
1491
1460
  }
1492
1461
 
1493
1462
  /**
1494
- * `shown`, and the chain from its first entry, in one snapshot: every
1495
- * link and hash, the vault's MACs; then the heads that must still be
1496
- * there: the one the app verified up to (`upTo`), so both authors are
1497
- * checked over the same entries, the one the app last recorded from a
1498
- * checkpoint (`through`), and the last checkpoint's; and the members and
1499
- * 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.
1500
1475
  */
1501
- async #verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
1476
+ async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
1502
1477
  await this.#settled();
1503
1478
  return this.#db.transaction(async (tx) => {
1504
1479
  const remembered = this.#prepared.verified;
1505
- const verification = await this.#verify(tx, shown, UNVERIFIED);
1506
- if (!verification.ok) return verification;
1507
- 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)) {
1508
1490
  return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
1509
1491
  }
1510
- const unsigned = await this.#checkpointFault(tx);
1492
+ const unsigned = await this.#checkpointFault(findings, holds);
1511
1493
  if (unsigned !== null) return unsigned;
1512
1494
  // What this vault last verified must still be there: whoever holds
1513
1495
  // its key can seal a rewrite, but cannot put back the head it saw. A
1514
1496
  // checkpoint names an earlier break, so it is reported first.
1515
- if (remembered.nextSeq > 0n) {
1516
- const seq = remembered.nextSeq - 1n;
1517
- if (!(await carries(tx, { seq: Number(seq), hash: remembered.hash.toString('hex') }))) {
1518
- return { ok: false, failedAtSeq: Number(seq), reason: 'changed since the vault last verified it' };
1519
- }
1497
+ if (rememberedHead !== null && !holds(rememberedHead)) {
1498
+ return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
1520
1499
  }
1521
1500
  const at = await this.#now(tx);
1522
- const accounting = await verifyAccounting(tx, at);
1501
+ const accounting = findings.accounting.result(at);
1523
1502
  if (!accounting.ok) return accounting;
1524
- 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);
1525
1505
  if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
1526
- 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 };
1527
1508
  }, SNAPSHOT);
1528
1509
  }
1529
1510
 
1530
1511
  /**
1531
- * Every checkpoint, not only the newest: each signed by the key whose log
1532
- * key wrote its entry, over a prefix the log still holds, entry for entry.
1533
- * The chain is verified by now, so each checkpoint entry is the vault's,
1534
- * 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.
1535
1515
  */
1536
- 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[] = [];
1537
1519
  for (let after = -1n; ; ) {
1538
- const batch = await store.vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
1520
+ const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
1539
1521
  for (const entry of batch) {
1540
- const checkpoint = JSON.parse(entry.metadata) as Checkpoint;
1541
- const broken = (reason: string) => ({ ok: false as const, failedAtSeq: Number(entry.seq), reason });
1542
- 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 }) => {
1543
1564
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
1544
- 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`);
1545
- if (!(await verifyCheckpoint(checkpoint, by.publicKey))) return broken('a checkpoint the vault did not sign');
1546
- if (BigInt(checkpoint.seq) >= entry.seq || !(await carries(db, checkpoint))) {
1547
- 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`;
1548
1569
  }
1549
- }
1550
- if (batch.length < VERIFY_BATCH) return null;
1551
- 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]! };
1552
1574
  }
1575
+ return null;
1553
1576
  }
1554
1577
 
1555
1578
  /**
1556
1579
  * The first member whose row fails its MAC, or names an older access
1557
- * entry than the log's newest about them. The chain is verified by now,
1558
- * 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).
1559
1582
  */
1560
- async #unsealed(db: Queryable): Promise<AccessFault | null> {
1561
- 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 {
1562
1584
  for (const row of rows) {
1563
1585
  const grants = held.filter((grant) => grant.principal === row.principal);
1564
1586
  if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
1565
- 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' };
1566
1588
  }
1567
1589
  return null;
1568
1590
  }
1569
1591
  }
1570
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
+
1571
1629
  /** Settle every operation before releasing the member lock, including cancelled requests. */
1572
1630
  async function settle<T extends { wipe: () => void }>(
1573
1631
  operations: ((operation: KeyOperation) => Promise<T | null>)[],