@coffre/vault 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
+ }