@coffre/vault 0.10.0 → 0.10.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.
@@ -0,0 +1,191 @@
1
+ import type { StoredEntry } from '@coffre/core/audit';
2
+ import type { Queryable, Transaction } from '@coffre/db';
3
+ import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
4
+ import { engineOf, rowsOf } from '@coffre/db/dialect';
5
+ import type { GrantRow } from '@coffre/db/grants';
6
+
7
+ import { snapshotQuery as postgres } from './snapshot.postgres.ts';
8
+ import { snapshotQuery as sqlite } from './snapshot.sqlite.ts';
9
+ import * as store from './store.ts';
10
+ import type { EnvironmentRow, Member } from './store.ts';
11
+
12
+ /**
13
+ * What a decision reads, or `access`, in one statement: one round trip, and
14
+ * one snapshot of the database, so its parts agree with each other.
15
+ *
16
+ * In a decision, the same statement locks the log's head, but only if it
17
+ * is free (`SKIP LOCKED`). Postgres takes a statement's snapshot when it
18
+ * starts, so one that waited for the lock would have read everything but
19
+ * the head as it was before the wait: a policy changed, a place deleted, a
20
+ * grant written around the vault while it waited would be missed. A
21
+ * statement that never waits reads all of it as of the lock. When another
22
+ * transaction holds the head, the decision waits for it in a statement of
23
+ * its own, then reads again, holding it. The read checks that the head names
24
+ * the last entry it saw, too (`current`): one taken between the snapshot and
25
+ * the lock, by an append that committed in that instant, is read again. That
26
+ * check sees a commit only through the head it moved, which holds because
27
+ * every change a decision reads commits with an entry under the head: the
28
+ * vault's own decisions, and the app's writes to places, each `audited`
29
+ * with its entry.
30
+ */
31
+ export type Snapshot = {
32
+ /** The database's clock, milliseconds since the epoch. */
33
+ at: number;
34
+ /** The rows of `asked.members`, by principal; those with none are not in the map. */
35
+ members: Map<string, Member>;
36
+ /** Every grant of each asked principal, lapsed ones too. */
37
+ grants: Map<string, GrantRow[]>;
38
+ /** The newest allowed access entry in the vault's name about each asked principal, unchecked. */
39
+ newestAccess: Map<string, StoredEntry>;
40
+ /** The newest allowed `policy.change` in the vault's name, unchecked. */
41
+ newestPolicy: StoredEntry | null;
42
+ /**
43
+ * The asked environments, the environments each personal config among
44
+ * them is a config of, and those the newest `policy.change` names, by id.
45
+ */
46
+ environments: Map<string, EnvironmentRow>;
47
+ /** Of the asked `reference.create` entries, those a `reference.end` of the vault's names, allowed. */
48
+ ended: Set<bigint>;
49
+ /**
50
+ * How many distinct keys `asked.released.principal` holds released within
51
+ * its window once it is given `asked.released.keys` too: a key released
52
+ * again, or asked for again, counts once.
53
+ */
54
+ released: number;
55
+ /** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
56
+ head: LockedHead | null;
57
+ };
58
+
59
+ export type LockedHead = {
60
+ nextSeq: bigint;
61
+ headHash: Buffer;
62
+ /** The log's last entry, as this statement's snapshot has it. */
63
+ last: { seq: bigint; hash: Buffer } | null;
64
+ /** The vault's newest `key.rotate`, allowed, unchecked. */
65
+ rotation: StoredEntry | null;
66
+ };
67
+
68
+ /** What to read. */
69
+ export type Asked = {
70
+ /** Whose grants and newest access entry; with `members`, their rows too, unlocked. */
71
+ principals: readonly string[];
72
+ members?: boolean;
73
+ environments?: readonly string[];
74
+ references?: readonly bigint[];
75
+ /**
76
+ * One principal's releases within the last `windowMs` of the clock, moved
77
+ * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides.
78
+ */
79
+ released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
80
+ /** Lock the log's head first. */
81
+ head?: boolean;
82
+ };
83
+
84
+ /** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
85
+ export async function snapshot(tx: Transaction, asked: Asked): Promise<Snapshot>;
86
+ export async function snapshot(db: Queryable, asked: Asked & { head?: false }): Promise<Snapshot>;
87
+ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
88
+ const read = () => readOnce(db, asked);
89
+ const first = await read();
90
+ if (asked.head !== true || (first.head !== null && current(first.head))) return first;
91
+ // Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
92
+ // Asked for the head, `db` is a transaction (the overloads above).
93
+ if (first.head === null) await lockLogHead(db as Transaction);
94
+ const again = await read();
95
+ if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
96
+ // Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
97
+ if (!current(again.head)) {
98
+ throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? 'empty' : `at entry ${again.head.last.seq}`}, or with another hash`);
99
+ }
100
+ return again;
101
+ }
102
+
103
+ /** Whether the head names the last entry the same statement saw: nothing was appended between its snapshot and its lock. */
104
+ export function current(head: LockedHead): boolean {
105
+ if (head.last === null) return head.nextSeq === 0n;
106
+ return head.nextSeq === head.last.seq + 1n && head.headHash.equals(head.last.hash);
107
+ }
108
+
109
+ async function readOnce(db: Queryable, asked: Asked): Promise<Snapshot> {
110
+ const query = (engineOf(db) === 'postgres' ? postgres : sqlite)(asked);
111
+ const [row] = await rowsOf<{ snapshot: string | Raw }>(db, query);
112
+ const raw: Raw = typeof row!.snapshot === 'string' ? (JSON.parse(row!.snapshot) as Raw) : row!.snapshot;
113
+ return {
114
+ at: Number(raw.at),
115
+ members: new Map(raw.members.map((member) => [member.principal, memberOf(member)])),
116
+ grants: new Map(asked.principals.map((principal) => [principal, raw.grants.filter((grant) => grant.principal === principal).map(grantOf)])),
117
+ newestAccess: new Map(raw.access.map((entry) => [entry.subjectPrincipal!, entryOf(entry)])),
118
+ newestPolicy: raw.policy === null ? null : entryOf(raw.policy),
119
+ environments: new Map(raw.environments.map(({ id, ...environment }) => [id, {
120
+ ...environment,
121
+ deleted: Boolean(environment.deleted),
122
+ archived: Boolean(environment.archived),
123
+ }])),
124
+ ended: new Set(raw.ended.map((seq) => BigInt(seq))),
125
+ released: Number(raw.released ?? 0),
126
+ head: raw.head === null ? null : {
127
+ nextSeq: BigInt(raw.head.nextSeq),
128
+ headHash: Buffer.from(raw.head.hash, 'hex'),
129
+ last: raw.last === null ? null : { seq: BigInt(raw.last.seq), hash: Buffer.from(raw.last.hash, 'hex') },
130
+ rotation: raw.rotation === null ? null : entryOf(raw.rotation),
131
+ },
132
+ };
133
+ }
134
+
135
+ /** Whole numbers come as JSON numbers or text, bytes as hex, as each dialect's query writes them. */
136
+ type Int = number | string;
137
+
138
+ /** An entry's columns, as both dialects' queries write them (`entryJson`). */
139
+ export type RawEntry = {
140
+ seq: Int; author: string; keyId: string; occurredAt: Int; actor: string; action: string; decision: string; code: string | null;
141
+ subjectPrincipal: string | null; projectId: string | null; environmentId: string | null; secretId: string | null;
142
+ secretVersionId: string | null; operationId: string | null; requestId: string | null; sourceIp: string | null;
143
+ relatedSeq: Int | null; metadata: string; prevHash: string; mac: string; hash: string;
144
+ };
145
+
146
+ type Raw = {
147
+ at: Int;
148
+ head: { nextSeq: Int; hash: string } | null;
149
+ last: { seq: Int; hash: string } | null;
150
+ rotation: RawEntry | null;
151
+ members: {
152
+ principal: string; status: 'active' | 'removed'; owner: boolean | number; role: string | null; scope: string | null; generation: Int;
153
+ createdAt: Int; createdBy: string; statusChangedAt: Int; statusChangedBy: string; accessSeq: Int; mac: string;
154
+ }[];
155
+ grants: { principal: string; projectId: string | null; environmentId: string | null; environmentSlug: string | null; role: string; expiresAt: Int | null; grantedAt: Int; grantedBy: string }[];
156
+ access: RawEntry[];
157
+ policy: RawEntry | null;
158
+ environments: { id: string; projectId: string; slug: string; owner: string | null; parentId: string | null; deleted: boolean | number; archived: boolean | number }[];
159
+ ended: Int[];
160
+ released: Int | null;
161
+ };
162
+
163
+ /** An entry as the chain's codec reads it, from what `entryJson` wrote. */
164
+ export function entryOf(raw: RawEntry): StoredEntry {
165
+ return {
166
+ ...raw,
167
+ seq: BigInt(raw.seq),
168
+ author: raw.author as StoredEntry['author'],
169
+ occurredAt: Number(raw.occurredAt),
170
+ relatedSeq: raw.relatedSeq === null ? null : BigInt(raw.relatedSeq),
171
+ prevHash: Buffer.from(raw.prevHash, 'hex'),
172
+ mac: Buffer.from(raw.mac, 'hex'),
173
+ hash: Buffer.from(raw.hash, 'hex'),
174
+ };
175
+ }
176
+
177
+ function grantOf(raw: Raw['grants'][number]): GrantRow {
178
+ return { ...raw, expiresAt: raw.expiresAt === null ? null : Number(raw.expiresAt), grantedAt: Number(raw.grantedAt) };
179
+ }
180
+
181
+ function memberOf({ owner, ...raw }: Raw['members'][number]): Member {
182
+ return store.memberOf({
183
+ ...raw,
184
+ owner: owner === true || owner === 1,
185
+ generation: Number(raw.generation),
186
+ createdAt: Number(raw.createdAt),
187
+ statusChangedAt: Number(raw.statusChangedAt),
188
+ accessSeq: BigInt(raw.accessSeq),
189
+ mac: Buffer.from(raw.mac, 'hex'),
190
+ });
191
+ }
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) {
@@ -87,10 +88,10 @@ function memberColumns(db: Queryable) {
87
88
  }
88
89
 
89
90
  /** A row as stored: `role` and `scope` as their columns hold them. */
90
- type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
91
+ export type StoredMember = Omit<Member, 'role' | 'scope'> & { owner: boolean; role: string | null; scope: string | null };
91
92
 
92
93
  /** A member as their row says (`storedRole`). */
93
- function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
94
+ export function memberOf({ owner, role, scope, ...row }: StoredMember): Member {
94
95
  return { ...row, ...storedRole({ owner, role, scope }) };
95
96
  }
96
97
 
@@ -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);
@@ -303,34 +308,6 @@ export async function everyProjectHolders(db: Queryable): Promise<string[]> {
303
308
 
304
309
  // --- the log --------------------------------------------------------------------
305
310
 
306
- /** The action of an entry that releases a key, which the bulk limit counts. */
307
- export const RELEASE = 'secret.read';
308
-
309
- /**
310
- * How many keys the vault has released to `principal` since `after`: one
311
- * for an entry about one secret, and as many as it lists for one about
312
- * several (`secrets`), a read logged once per place.
313
- */
314
- export async function releasesSince(db: Queryable, principal: string, after: number): Promise<number> {
315
- const { auditLog } = tablesOf(db);
316
- const listed = engineOf(db) === 'postgres'
317
- ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')`
318
- : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
319
- const [{ n }] = await db
320
- .select({ n: sql<string | number | null>`SUM(COALESCE(${listed}, 1))` })
321
- .from(auditLog)
322
- .where(
323
- and(
324
- eq(auditLog.author, 'vault'),
325
- eq(auditLog.actor, principal),
326
- eq(auditLog.action, RELEASE),
327
- eq(auditLog.decision, 'allow'),
328
- gt(auditLog.occurredAt, after),
329
- ),
330
- );
331
- return Number(n ?? 0);
332
- }
333
-
334
311
  /** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
335
312
  function stored(rows: object[]): StoredEntry[] {
336
313
  return rows as StoredEntry[];
@@ -380,17 +357,6 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
380
357
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
381
358
  const HASHES_AT_ONCE = 5000;
382
359
 
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
360
  /** The entry at `seq`'s hash, or undefined when there is none. */
395
361
  export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
396
362
  const { auditLog } = tablesOf(db);
@@ -537,6 +503,82 @@ export async function latestVaultEntry(db: Queryable, actions: readonly string[]
537
503
  return row === undefined ? undefined : stored([row])[0];
538
504
  }
539
505
 
506
+ /**
507
+ * The vault's newest checkpoint that keeps a tally, allowed, or undefined:
508
+ * one written since 0.10.1, which names the last entry of the prefix it
509
+ * signs as related (`related_seq`); those before name none.
510
+ */
511
+ export async function latestTallied(db: Queryable): Promise<StoredEntry | undefined> {
512
+ const { auditLog } = tablesOf(db);
513
+ const [row] = await db
514
+ .select(entryColumns(db))
515
+ .from(auditLog)
516
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq)))
517
+ .orderBy(desc(auditLog.seq))
518
+ .limit(1);
519
+ return row === undefined ? undefined : stored([row])[0];
520
+ }
521
+
522
+ /** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
523
+ export async function newestCheckpointEntry(db: Queryable): Promise<StoredEntry | undefined> {
524
+ const { auditLog } = tablesOf(db);
525
+ const [row] = await db
526
+ .select(entryColumns(db))
527
+ .from(auditLog)
528
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint')))
529
+ .orderBy(desc(auditLog.seq))
530
+ .limit(1);
531
+ return row === undefined ? undefined : stored([row])[0];
532
+ }
533
+
534
+ /**
535
+ * The vault's checkpoints that keep a tally after entry `after` and through
536
+ * `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
537
+ * holds, but always one. A reader asks again after the last.
538
+ */
539
+ export async function talliedWithin(db: Queryable, after: bigint, through: bigint, limit: number): Promise<StoredEntry[]> {
540
+ const { auditLog } = tablesOf(db);
541
+ const tallied = and(
542
+ eq(auditLog.author, 'vault'), eq(auditLog.action, 'audit.checkpoint'), eq(auditLog.decision, 'allow'), isNotNull(auditLog.relatedSeq),
543
+ gt(auditLog.seq, after), lte(auditLog.seq, through),
544
+ );
545
+ const batch = await withinBytes(db, tallied, limit);
546
+ if (batch === null) return [];
547
+ const rows = await db.select(entryColumns(db)).from(auditLog)
548
+ .where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq));
549
+ return stored(rows);
550
+ }
551
+
552
+ /** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
553
+ export async function newestRefusal(db: Queryable, action: string, code: string, after: bigint): Promise<StoredEntry | undefined> {
554
+ const { auditLog } = tablesOf(db);
555
+ const [row] = await db
556
+ .select(entryColumns(db))
557
+ .from(auditLog)
558
+ .where(and(eq(auditLog.author, 'vault'), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, 'deny'), eq(auditLog.code, code)))
559
+ .orderBy(desc(auditLog.seq))
560
+ .limit(1);
561
+ return row === undefined ? undefined : stored([row])[0];
562
+ }
563
+
564
+ /** 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. */
565
+ export async function talliedAfter(db: Queryable, after: bigint, limit: number): Promise<{ seq: bigint; signs: bigint }[]> {
566
+ const { auditLog } = tablesOf(db);
567
+ const rows = await db
568
+ .select({ seq: auditLog.seq, signs: auditLog.relatedSeq })
569
+ .from(auditLog)
570
+ .where(and(
571
+ eq(auditLog.author, 'vault'),
572
+ eq(auditLog.action, 'audit.checkpoint'),
573
+ eq(auditLog.decision, 'allow'),
574
+ isNotNull(auditLog.relatedSeq),
575
+ gt(auditLog.seq, after),
576
+ ))
577
+ .orderBy(asc(auditLog.seq))
578
+ .limit(limit);
579
+ return rows.map(({ seq, signs }) => ({ seq, signs: signs! }));
580
+ }
581
+
540
582
  /** The vault's newest allowed access entry about each member who has one. */
541
583
  export async function newestAccessEntries(db: Queryable): Promise<Map<string, StoredEntry>> {
542
584
  const { auditLog } = tablesOf(db);
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
+ }