@coffre/vault 0.9.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/store.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre/core/access';
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
- import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
4
+ import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
5
  import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
6
6
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
7
  import { entryColumns, withinBytes } from '@coffre/db/log';
@@ -61,11 +61,12 @@ export async function boundLockWaits(tx: Transaction, ms: number): Promise<void>
61
61
 
62
62
  /** The database's clock, now. */
63
63
  export async function now(db: Queryable): Promise<number> {
64
- const { auditChainHead } = tablesOf(db);
65
- const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
64
+ const [{ at }] = await clock(db);
66
65
  return at;
67
66
  }
68
67
 
68
+ const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
69
+
69
70
  // --- members --------------------------------------------------------------------
70
71
 
71
72
  function memberColumns(db: Queryable) {
@@ -101,11 +102,15 @@ function storedChange(change: Partial<Omit<Member, 'principal'>>): Record<string
101
102
  }
102
103
 
103
104
  export async function member(db: Queryable, principal: string): Promise<Member | undefined> {
104
- const { vaultMembers } = tablesOf(db);
105
- const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
105
+ const [row] = await memberRow(db, { principal });
106
106
  return row === undefined ? undefined : memberOf(row as StoredMember);
107
107
  }
108
108
 
109
+ const memberRow = prepared((db) => {
110
+ const { vaultMembers } = tablesOf(db);
111
+ return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder('principal')));
112
+ });
113
+
109
114
  export async function allMembers(db: Queryable): Promise<Member[]> {
110
115
  const { vaultMembers } = tablesOf(db);
111
116
  return ((await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))) as StoredMember[]).map(memberOf);
@@ -312,24 +317,28 @@ export const RELEASE = 'secret.read';
312
317
  * several (`secrets`), a read logged once per place.
313
318
  */
314
319
  export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
320
+ const [{ n }] = await released(db, { principal, after });
321
+ return Number(n ?? 0);
322
+ }
323
+
324
+ const released = prepared((db) => {
315
325
  const { auditLog } = tablesOf(db);
316
326
  const listed = engineOf(db) === 'postgres'
317
327
  ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
318
328
  : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
319
- const [{ n }] = await db
329
+ return db
320
330
  .select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
321
331
  .from(auditLog)
322
332
  .where(
323
333
  and(
324
334
  eq(auditLog.author, 'vault'),
325
- eq(auditLog.actor, principal),
335
+ eq(auditLog.actor, sql.placeholder('principal')),
326
336
  eq(auditLog.action, RELEASE),
327
337
  eq(auditLog.decision, 'allow'),
328
- gt(auditLog.occurredAt, after),
338
+ gt(auditLog.occurredAt, sql.placeholder('after')),
329
339
  ),
330
340
  );
331
- return Number(n ?? 0);
332
- }
341
+ });
333
342
 
334
343
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
335
344
  function stored(rows: object[]): StoredEntry[] {
@@ -380,17 +389,6 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
380
389
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
381
390
  const HASHES_AT_ONCE = 5000;
382
391
 
383
- /**
384
- * Whether every entry from the first through `seq` is there. Seqs are the
385
- * primary key, so the count is `seq + 1` exactly when none is missing: one
386
- * pass over the key's index, never the entries.
387
- */
388
- export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
389
- const { auditLog } = tablesOf(db);
390
- const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
391
- return BigInt(n) === seq + 1n;
392
- }
393
-
394
392
  /** The entry at `seq`'s hash, or undefined when there is none. */
395
393
  export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
396
394
  const { auditLog } = tablesOf(db);
@@ -451,6 +449,13 @@ export async function endedReferences(db: Queryable, seqs: readonly bigint[]): P
451
449
  }
452
450
 
453
451
  /** The project each environment is in: a holder's ids, checked against each other. */
452
+ /** Whether a project of this id is there, and not deleted. */
453
+ export async function projectLive(db: Queryable, id: string): Promise<boolean> {
454
+ const { projects } = tablesOf(db);
455
+ const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
456
+ return rows.length > 0 && !rows[0]!.deleted;
457
+ }
458
+
454
459
  export async function projectsOfEnvironments(db: Queryable, ids: readonly string[]): Promise<Map<string, string>> {
455
460
  if (ids.length === 0) return new Map();
456
461
  const { environments } = tablesOf(db);
@@ -481,6 +486,43 @@ export async function secretsByIds(db: Queryable, ids: readonly string[]): Promi
481
486
  return new Map(rows.map(({ project, environment, key, ...row }) => [row.secretId, { ...row, path: `${project}/${environment}/${key}` }]));
482
487
  }
483
488
 
489
+ /**
490
+ * Each secret's nature, as its current version has it: plain where its
491
+ * value is stored as it is, sensitive where it is bound so, and otherwise
492
+ * secret, as is one with no value of its own.
493
+ */
494
+ export async function natures(db: Queryable, secretIds: readonly string[]): Promise<Map<string, 'plain' | 'secret' | 'sensitive'>> {
495
+ if (secretIds.length === 0) return new Map();
496
+ const { secrets, secretVersions } = tablesOf(db);
497
+ const rows = await db
498
+ .select({ id: secrets.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) })
499
+ .from(secrets)
500
+ .innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId))
501
+ .where(inArray(secrets.id, [...new Set(secretIds)]));
502
+ return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' : Boolean(row.plain) ? 'plain' : 'secret']));
503
+ }
504
+
505
+ /**
506
+ * For each version a rewrap would bring back into its secret, its nature
507
+ * as stored when it is an old value, not the secret's current one; null
508
+ * for the current one, which a mark wraps again with no value changing.
509
+ */
510
+ export async function restoredNatures(
511
+ db: Queryable,
512
+ items: readonly { secretId: string; from: string }[],
513
+ ): Promise<('plain' | 'secret' | 'sensitive' | null)[]> {
514
+ if (items.length === 0) return [];
515
+ const { secrets, secretVersions } = tablesOf(db);
516
+ const [current, versions] = await Promise.all([
517
+ db.select({ id: secrets.id, version: secrets.currentVersionId }).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])),
518
+ db.select({ id: secretVersions.id, sensitive: secretVersions.sensitive, plain: truth(isNotNull(secretVersions.plaintext)) }).from(secretVersions)
519
+ .where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))])),
520
+ ]);
521
+ const currentOf = new Map(current.map((row) => [row.id, row.version]));
522
+ const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? 'sensitive' as const : Boolean(row.plain) ? 'plain' as const : 'secret' as const]));
523
+ return items.map(({ secretId, from }) => (currentOf.get(secretId) === from ? null : natureOf.get(from) ?? 'secret'));
524
+ }
525
+
484
526
  /** The vault's newest entry of any of these actions, allowed, or undefined. */
485
527
  export async function latestVaultEntry(db: Queryable, actions: readonly string[]): Promise<StoredEntry | undefined> {
486
528
  const { auditLog } = tablesOf(db);
@@ -493,6 +535,82 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
493
535
  return row === undefined ? undefined : stored([row])[0];
494
536
  }
495
537
 
538
+ /**
539
+ * The vault's newest checkpoint that keeps a tally, allowed, or undefined:
540
+ * one written since 0.10.1, which names the last entry of the prefix it
541
+ * signs as related (`related_seq`); those before name none.
542
+ */
543
+ export async function latestTallied(db: Queryable): Promise<StoredEntry | undefined> {
544
+ const { auditLog } = tablesOf(db);
545
+ const [row] = await db
546
+ .select(entryColumns(db))
547
+ .from(auditLog)
548
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq)))
549
+ .orderBy(desc(auditLog.seq))
550
+ .limit(1);
551
+ return row === undefined ? undefined : stored([row])[0];
552
+ }
553
+
554
+ /** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
555
+ export async function newestCheckpointEntry(db: Queryable): Promise<StoredEntry | undefined> {
556
+ const { auditLog } = tablesOf(db);
557
+ const [row] = await db
558
+ .select(entryColumns(db))
559
+ .from(auditLog)
560
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint')))
561
+ .orderBy(desc(auditLog.seq))
562
+ .limit(1);
563
+ return row === undefined ? undefined : stored([row])[0];
564
+ }
565
+
566
+ /**
567
+ * The vault's checkpoints that keep a tally after entry `after` and through
568
+ * `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
569
+ * holds, but always one. A reader asks again after the last.
570
+ */
571
+ export async function talliedWithin(db: Queryable, after: bigint, through: bigint, limit: number): Promise<StoredEntry[]> {
572
+ const { auditLog } = tablesOf(db);
573
+ const tallied = and(
574
+ eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq),
575
+ gt(auditLog.seq, after), lte(auditLog.seq, through),
576
+ );
577
+ const batch = await withinBytes(db, tallied, limit);
578
+ if (batch === null) return [];
579
+ const rows = await db.select(entryColumns(db)).from(auditLog)
580
+ .where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq));
581
+ return stored(rows);
582
+ }
583
+
584
+ /** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
585
+ export async function newestRefusal(db: Queryable, action: string, code: string, after: bigint): Promise<StoredEntry | undefined> {
586
+ const { auditLog } = tablesOf(db);
587
+ const [row] = await db
588
+ .select(entryColumns(db))
589
+ .from(auditLog)
590
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, 'deny'), eq(auditLog.code, code)))
591
+ .orderBy(desc(auditLog.seq))
592
+ .limit(1);
593
+ return row === undefined ? undefined : stored([row])[0];
594
+ }
595
+
596
+ /** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
597
+ export async function talliedAfter(db: Queryable, after: bigint, limit: number): Promise<{ seq: bigint; signs: bigint }[]> {
598
+ const { auditLog } = tablesOf(db);
599
+ const rows = await db
600
+ .select({ seq: auditLog.seq, signs: auditLog.relatedSeq })
601
+ .from(auditLog)
602
+ .where(and(
603
+ eq(auditLog.author, 'vault'),
604
+ eq(auditLog.action, 'audit.checkpoint'),
605
+ eq(auditLog.decision, 'allow'),
606
+ isNotNull(auditLog.relatedSeq),
607
+ gt(auditLog.seq, after),
608
+ ))
609
+ .orderBy(asc(auditLog.seq))
610
+ .limit(limit);
611
+ return rows.map(({ seq, signs }) => ({ seq, signs: signs! }));
612
+ }
613
+
496
614
  /** The vault's newest allowed access entry about each member who has one. */
497
615
  export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
498
616
  const { auditLog } = tablesOf(db);
@@ -531,6 +649,31 @@ export async function vaultEntriesOf(
531
649
  return stored(rows);
532
650
  }
533
651
 
652
+ /** Which of these secrets' newest versions are sensitive, by the versions' own order. */
653
+ export async function newestSensitive(db: Queryable, secretIds: readonly string[]): Promise<Set<string>> {
654
+ if (secretIds.length === 0) return new Set();
655
+ const { secretVersions } = tablesOf(db);
656
+ const rows = await db
657
+ .select({ secretId: secretVersions.secretId })
658
+ .from(secretVersions)
659
+ .where(and(
660
+ inArray(secretVersions.secretId, [...new Set(secretIds)]),
661
+ eq(secretVersions.sensitive, true),
662
+ sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
663
+ ));
664
+ return new Set(rows.map((row) => row.secretId));
665
+ }
666
+
667
+ /** How many grants name `role`: all of them, or those on environments. */
668
+ export async function grantsNaming(db: Queryable, role: string, filter: { onEnvironments?: boolean } = {}): Promise<number> {
669
+ const { vaultGrants } = tablesOf(db);
670
+ const rows = await db
671
+ .select({ count: sql<number | string>`count(*)` })
672
+ .from(vaultGrants)
673
+ .where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : undefined));
674
+ return Number(rows[0]?.count ?? 0);
675
+ }
676
+
534
677
  /**
535
678
  * A version that still holds its wrapped key. A deleted place's versions are
536
679
  * erased, their keys emptied: there is nothing left to open, or to try a
@@ -546,7 +689,16 @@ function sealed(wrappedDek: SQLWrapper): SQL {
546
689
  * one, as a read through a reference needs. An erased version outside a
547
690
  * deleted place is no version.
548
691
  */
549
- export type SecretVersion = { id: string; secret: SecretRef; wrapped: WrappedKey; deleted: boolean; current: boolean };
692
+ export type SecretVersion = {
693
+ id: string;
694
+ /** Its secret, `sensitive` as the version was written. */
695
+ secret: SecretRef;
696
+ wrapped: WrappedKey;
697
+ deleted: boolean;
698
+ current: boolean;
699
+ /** Whether its secret's newest version is sensitive: what a rewrap of it may not loosen. */
700
+ newestSensitive: boolean;
701
+ };
550
702
 
551
703
  export async function versions(db: Queryable, ids: readonly string[]): Promise<SecretVersion[]> {
552
704
  if (ids.length === 0) return [];
@@ -567,8 +719,10 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
567
719
  wrappedDek: secretVersions.wrappedDek,
568
720
  deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))!),
569
721
  currentVersionId: secrets.currentVersionId,
722
+ sensitive: secretVersions.sensitive,
570
723
  // The newest by the versions' own order: the app may write `current_version_id`, never a version.
571
724
  newest: sql<number | string>`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
725
+ newestSensitive: sql<boolean | number>`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`,
572
726
  })
573
727
  .from(secretVersions)
574
728
  .innerJoin(secrets, eq(secrets.id, secretVersions.secretId))
@@ -584,6 +738,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
584
738
  secretId: row.secretId,
585
739
  version: row.version,
586
740
  path: `${row.project}/${row.environment}/${row.key}`,
741
+ sensitive: row.sensitive,
587
742
  },
588
743
  wrapped: {
589
744
  kekProvider: row.kekProvider,
@@ -592,5 +747,7 @@ export async function versions(db: Queryable, ids: readonly string[]): Promise<S
592
747
  bytes: row.wrappedDek.toString('base64'),
593
748
  },
594
749
  current: row.currentVersionId === row.id && Number(row.newest) === row.version,
750
+ // SQLite answers a subquery's boolean as a number.
751
+ newestSensitive: Boolean(row.newestSensitive),
595
752
  }));
596
753
  }
package/src/tally.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import type { StoredEntry } from '@coffre/core/audit';
4
+ import type { AccessFault, Checkpoint, LogHead } from '@coffre/core/vault';
5
+
6
+ import { KeyAccounting, type AccountingState } from './accounting.ts';
7
+ import { forward } from './log.ts';
8
+ import { apply, placeKey, type LoggedMember, type Replayed } from './replay.ts';
9
+ import { ACCESS_ACTIONS, type GrantRow } from './store.ts';
10
+
11
+ /**
12
+ * What a walk of the vault's entries from the first has gathered by some
13
+ * entry: all a walk from there needs to go on as if it had started at the
14
+ * first. A checkpoint keeps the tally of the prefix it signs, so that the
15
+ * next checkpoint, and every verification, start from it and read only the
16
+ * entries since (docs/architecture.md, "The tally").
17
+ *
18
+ * - `entries`: the vault's entries so far;
19
+ * - `keys`: the vault's log keys, in the order its entries moved through
20
+ * them, which only moves forward (`forward` in log.ts);
21
+ * - `accounting`: the key batches still under way, and the first fault in
22
+ * their entries;
23
+ * - `access`: who the log says is a member, what each holds, and each one's
24
+ * newest access entry, which their row must name;
25
+ * - `replayFault`: the first access entry about someone never admitted.
26
+ *
27
+ * The faults are kept, once found, as a walk from the first entry would
28
+ * find them again each time.
29
+ */
30
+ export type Tally = {
31
+ entries: number;
32
+ keys: string[];
33
+ accounting: AccountingState;
34
+ access: AccessTally;
35
+ replayFault: AccessFault | null;
36
+ };
37
+
38
+ /** Members and grants as the log says they are, and each member's newest access entry, as plain JSON. */
39
+ export type AccessTally = {
40
+ members: (Omit<LoggedMember, 'accessSeq'> & { accessSeq: number })[];
41
+ grants: GrantRow[];
42
+ newest: [principal: string, seq: number][];
43
+ };
44
+
45
+ /**
46
+ * A tally as a checkpoint's entry holds it. Members and grants change far
47
+ * less often than every five minutes, so `access` is written whole only
48
+ * when it changed since the tally resumed from; otherwise the entry names
49
+ * the checkpoint entry that holds it (`at`). Either way with its digest.
50
+ */
51
+ export type StoredTally = Omit<Tally, 'access'> & { v: typeof TALLY_VERSION; access: { digest: string } & ({ at: number } | AccessTally) };
52
+
53
+ /**
54
+ * What a tally keeps, as 0.10.1 wrote it. A release that keeps more, or
55
+ * less, writes another version, and compares a tally of this one on what
56
+ * this one kept: a walk from the first entry passes every tally ever
57
+ * written, and an honest one must still match. A release rolled back to
58
+ * reads no tally newer than its own, and refuses the checkpoints that
59
+ * keep one: going back across a version is not a way back.
60
+ */
61
+ export const TALLY_VERSION = 1;
62
+
63
+ /**
64
+ * A checkpoint as the vault's entry holds it: the signed prefix, its
65
+ * tally, and the lap of the rehash of the past under way (`scrub`): the
66
+ * prefix it rehashes to, signed when the lap began, and the last entry it
67
+ * rehashed. Null between laps.
68
+ */
69
+ export type TalliedCheckpoint = Checkpoint & { tally: StoredTally; scrub: { at: LogHead; end: LogHead } | null };
70
+
71
+ /** Before the first entry. */
72
+ export const EMPTY_TALLY: Tally = {
73
+ entries: 0,
74
+ keys: [],
75
+ accounting: { intents: [], fault: null },
76
+ access: { members: [], grants: [], newest: [] },
77
+ replayFault: null,
78
+ };
79
+
80
+ /** The digest of what `access` says, over its canonical form. */
81
+ export function accessDigest(access: AccessTally): string {
82
+ return createHash('sha256').update(canonical(access)).digest('hex');
83
+ }
84
+
85
+ /**
86
+ * What two tallies are compared by, so that they match whatever order
87
+ * their maps were built in: the members and grants by their digest, which
88
+ * a stretch passing many checkpoints computes once a change; and a fault by
89
+ * where it was found, since the words for it may change from one release
90
+ * to the next.
91
+ */
92
+ function tallyKey({ accounting, entries, keys, replayFault }: Omit<Tally, 'access'>, digest: string): string {
93
+ return canonical({ entries, keys, replayFault, accounting: { ...accounting, fault: accounting.fault?.failedAtSeq ?? null }, access: digest });
94
+ }
95
+
96
+ /**
97
+ * A tally a walk must come to: the one a checkpoint keeps, `entry`, of the
98
+ * prefix through `through`; or why that checkpoint's tally cannot be read.
99
+ */
100
+ export type Expected = { entry: bigint; through: bigint; key: string } | { entry: bigint; through: bigint; unreadable: string };
101
+
102
+ /**
103
+ * What a walk must come to at a checkpoint, from the tally it keeps: its
104
+ * members and grants by the digest it keeps of them, never read.
105
+ */
106
+ export function expectedKey(stored: StoredTally): string {
107
+ return tallyKey(stored, stored.access.digest);
108
+ }
109
+
110
+ /**
111
+ * The vault's entries walked, oldest first, each after its MAC was checked,
112
+ * gathered into what the checks after the walk need: the tally it started
113
+ * from, carried on.
114
+ */
115
+ export class Walk {
116
+ readonly accounting: KeyAccounting;
117
+ readonly replayed: Replayed;
118
+ /** Each member's newest allowed access entry, by seq. */
119
+ readonly newestAccess: Map<string, bigint>;
120
+ /** The first access entry the replay could not apply. */
121
+ replayFault: AccessFault | null;
122
+ /** The vault's entries so far, those before the walk included. */
123
+ entries: number;
124
+ /** The vault's log keys, as the log moved through them. */
125
+ keys: readonly string[];
126
+ /** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
127
+ readonly checkpoints: { entry: { seq: bigint; keyId: string; relatedSeq: bigint | null }; checkpoint: Checkpoint }[] = [];
128
+
129
+ /** The tallies the walk must come to as it passes them, oldest first (`expect`). */
130
+ #expected: Expected[] = [];
131
+ /** The first it did not come to, and why. */
132
+ mismatch: { entry: bigint; reason: string } | null = null;
133
+ /** The digest of the members and grants as the walk has them, until an access entry changes them. */
134
+ #digest: string | null = null;
135
+
136
+ /** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
137
+ digest(): string {
138
+ return (this.#digest ??= accessDigest(this.tally().access));
139
+ }
140
+
141
+ /** The tally but for its members and grants, which `digest` stands for. */
142
+ #unaccessed(): Omit<Tally, 'access'> {
143
+ return { entries: this.entries, keys: [...this.keys], accounting: this.accounting.state(), replayFault: this.replayFault };
144
+ }
145
+
146
+ /** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
147
+ constructor(from: Tally, digest?: string) {
148
+ this.#digest = digest ?? null;
149
+ this.accounting = new KeyAccounting(from.accounting);
150
+ this.replayed = {
151
+ members: new Map(from.access.members.map((member) => [member.principal, { ...member, accessSeq: BigInt(member.accessSeq) }])),
152
+ held: new Map(),
153
+ };
154
+ for (const grant of from.access.grants) {
155
+ const held = this.replayed.held.get(grant.principal) ?? new Map<string, GrantRow>();
156
+ this.replayed.held.set(grant.principal, held);
157
+ held.set(placeKey(grant), { ...grant });
158
+ }
159
+ this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
160
+ this.replayFault = from.replayFault;
161
+ this.entries = from.entries;
162
+ this.keys = from.keys;
163
+ }
164
+
165
+ /**
166
+ * Hold the walk to these tallies, each as the walk stands once it has
167
+ * read every entry through the prefix's end: before the vault's next
168
+ * entry, since only the vault's change a tally, or at `finish`.
169
+ */
170
+ expect(expected: readonly Expected[]): void {
171
+ this.#expected = [...expected].sort((a, b) => (a.through < b.through ? -1 : 1));
172
+ }
173
+
174
+ /** The tallies expected through `through`, held to the walk. */
175
+ finish(through: bigint): void {
176
+ this.#reach(through + 1n);
177
+ }
178
+
179
+ #reach(before: bigint): void {
180
+ while (this.#expected.length > 0 && this.#expected[0]!.through < before) {
181
+ const expected = this.#expected.shift()!;
182
+ if (this.mismatch !== null) continue;
183
+ if ('unreadable' in expected) this.mismatch = { entry: expected.entry, reason: expected.unreadable };
184
+ else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) {
185
+ this.mismatch = { entry: expected.entry, reason: 'the tally this checkpoint keeps does not follow from the log before it' };
186
+ }
187
+ }
188
+ }
189
+
190
+ /**
191
+ * One of the vault's entries, its MAC checked, in the log's order: a
192
+ * fault when it is under a key the vault had moved past (`forward`).
193
+ */
194
+ add(entry: StoredEntry, current: string): { failedAtSeq: bigint; reason: string } | null {
195
+ this.#reach(entry.seq);
196
+ const moved = forward([entry], this.keys, current);
197
+ if ('failedAtSeq' in moved) return moved;
198
+ this.keys = moved.keys;
199
+ this.entries += 1;
200
+ this.accounting.add(entry);
201
+ if (entry.decision !== 'allow') return null;
202
+ if (ACCESS.has(entry.action)) {
203
+ this.#digest = null;
204
+ this.replayFault ??= apply(this.replayed, entry);
205
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
206
+ } else if (entry.action === CHECKPOINT) {
207
+ const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata) as Checkpoint;
208
+ this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId, relatedSeq: entry.relatedSeq }, checkpoint: { seq, hash, signedAt, keyId, signature } });
209
+ }
210
+ return null;
211
+ }
212
+
213
+ /** Where the walk stands, to resume from. */
214
+ tally(): Tally {
215
+ const byPrincipal = <T>(entries: Iterable<[string, T]>) => [...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
216
+ return {
217
+ entries: this.entries,
218
+ keys: [...this.keys],
219
+ accounting: this.accounting.state(),
220
+ access: {
221
+ members: byPrincipal(this.replayed.members).map(([, member]) => ({ ...member, accessSeq: Number(member.accessSeq) })),
222
+ grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
223
+ newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)]),
224
+ },
225
+ replayFault: this.replayFault,
226
+ };
227
+ }
228
+ }
229
+
230
+ /** The action of the vault's entry that signs a prefix of the log. */
231
+ export const CHECKPOINT = 'audit.checkpoint';
232
+
233
+ const ACCESS = new Set<string>(ACCESS_ACTIONS);
234
+
235
+ /** JSON with every object's keys sorted: one text for one value, however it was built. */
236
+ function canonical(value: unknown): string {
237
+ return JSON.stringify(value, (_, inner: unknown) =>
238
+ inner !== null && typeof inner === 'object' && !Array.isArray(inner)
239
+ ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
240
+ : inner);
241
+ }