@coffre/vault 0.14.5 → 0.15.0

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.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +341 -4
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/index.js +1 -1
  5. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  6. package/dist/node.d.ts +181 -6
  7. package/dist/node.js +358 -16
  8. package/dist/{src-Bg0ZeMJW.js → src-DheG92EM.js} +1 -1
  9. package/dist/vault-kmtojYnW.js +14623 -0
  10. package/package.json +4 -5
  11. package/src/bulk.ts +89 -0
  12. package/src/changes.ts +478 -0
  13. package/src/checkpointer-node.ts +342 -0
  14. package/src/checkpointer-workers.ts +514 -0
  15. package/src/checkpointer.ts +1165 -0
  16. package/src/cloudflare.ts +207 -13
  17. package/src/config.ts +3 -2
  18. package/src/credentials.postgres.ts +78 -0
  19. package/src/credentials.sqlite.ts +240 -0
  20. package/src/credentials.ts +630 -0
  21. package/src/cutover.ts +275 -0
  22. package/src/decide.ts +437 -2
  23. package/src/inputs.ts +499 -0
  24. package/src/local.ts +20 -4
  25. package/src/log-checkpointer.ts +116 -0
  26. package/src/log-entry.ts +113 -0
  27. package/src/log-replay.ts +188 -0
  28. package/src/log-store.ts +695 -0
  29. package/src/node.ts +73 -9
  30. package/src/objects.ts +141 -0
  31. package/src/reads.ts +502 -0
  32. package/src/retention.ts +229 -0
  33. package/src/snapshot.postgres.ts +39 -9
  34. package/src/snapshot.sqlite.ts +28 -3
  35. package/src/snapshot.ts +64 -10
  36. package/src/store.ts +832 -36
  37. package/src/use.postgres.ts +111 -0
  38. package/src/use.sqlite.ts +542 -0
  39. package/src/use.ts +1060 -0
  40. package/src/vault.ts +1366 -220
  41. package/src/verifier.ts +136 -0
  42. package/src/witness-workers.ts +163 -0
  43. package/dist/vault-Cbi45kLB.js +0 -5197
@@ -0,0 +1,113 @@
1
+ import { createHmac, hkdfSync, timingSafeEqual } from 'node:crypto';
2
+
3
+ import { macData, type Cut, type LeafValue, type LogEntry } from '@coffre/core/merkle';
4
+
5
+ import { logObject } from './inputs.ts';
6
+
7
+ /**
8
+ * 0.15's entries as the trees seal them (docs/design/audit-tree.md, "A
9
+ * leaf"): a row of `log_changes` or `log_uses` (migration 0022), every
10
+ * column in the table's order but its number, which the leaf holds apart,
11
+ * and its MAC, which comes last. The MAC is the vault's, 16 bytes (R53):
12
+ * the tag of the key that made it, four bytes, then HMAC-SHA256 over the
13
+ * tree and every column but the number and itself (`macData`), cut to
14
+ * twelve (D162). The writer of an entry (the change path, PR 4; the use
15
+ * path, PR 5) computes it with `entryMac`, and the checkpointer checks it
16
+ * with `authentic` before it seals anything. The tag is public and names
17
+ * the key, so that once the vault's signing key rotates (T168), the
18
+ * database can refuse an entry under a key the log moved on from, and a
19
+ * reader knows which key to check it under.
20
+ */
21
+
22
+ /** A change as `log_changes` holds it, `object` a uuid in lower case. */
23
+ export type ChangeRow = {
24
+ number: bigint; at: number; requestId: string; object: string; actor: string; payload: string; seal: Buffer | null; mac: Buffer;
25
+ };
26
+
27
+ /** A use as `log_uses` holds it (the slim row, R53), uuids in lower case. */
28
+ export type UseRow = {
29
+ number: bigint; at: number; requestId: bigint; credential: string; secret: string; purpose: number; mac: Buffer; place: string | null;
30
+ };
31
+
32
+ export const MAC_BYTES = 16;
33
+ /** The key's tag, the first bytes of every MAC: public, one per key. */
34
+ export const TAG_BYTES = 4;
35
+
36
+ /** The key that MACs log entries, and its tag: both from the vault's signing seed, each under a label of its own. */
37
+ export type LogMacKey = { tag: Buffer; key: Buffer };
38
+
39
+ export function logMacKey(seed: Uint8Array): LogMacKey {
40
+ return {
41
+ tag: Buffer.from(hkdfSync('sha256', seed, new Uint8Array(0), 'coffre.log.tag.v1', TAG_BYTES)),
42
+ key: Buffer.from(hkdfSync('sha256', seed, new Uint8Array(0), 'coffre.log.mac.v1', 32)),
43
+ };
44
+ }
45
+
46
+ /** The MAC of a tree's columns: the key's tag, then the HMAC cut to what is left of 16 bytes. */
47
+ const mac = (key: LogMacKey, tree: LogEntry['tree'], columns: readonly LeafValue[]) =>
48
+ Buffer.concat([key.tag, createHmac('sha256', key.key).update(macData(tree, columns)).digest().subarray(0, MAC_BYTES - TAG_BYTES)]);
49
+
50
+ const changeColumns = (r: Omit<ChangeRow, 'number' | 'mac'>): LeafValue[] => [BigInt(r.at), r.requestId, r.object, r.actor, r.payload, r.seal];
51
+ const useColumns = (r: Omit<UseRow, 'number' | 'mac'>): LeafValue[] => [BigInt(r.at), r.requestId, r.credential, r.secret, r.purpose, r.place];
52
+
53
+ /** An entry's MAC, as its writer stores it. */
54
+ export function entryMac(key: LogMacKey, entry: { tree: 'changes'; row: Omit<ChangeRow, 'number' | 'mac'> } | { tree: 'uses'; row: Omit<UseRow, 'number' | 'mac'> }): Buffer {
55
+ return mac(key, entry.tree, entry.tree === 'changes' ? changeColumns(entry.row) : useColumns(entry.row));
56
+ }
57
+
58
+ /** A change's row as the trees take it: every column in the table's order, the MAC last (`log_changes`'s own order puts it there too). */
59
+ export function changeEntry(r: ChangeRow): LogEntry {
60
+ return { tree: 'changes', number: r.number, at: r.at, columns: [...changeColumns(r), r.mac] };
61
+ }
62
+
63
+ /** A use's row as the trees take it: its columns in the table's order, the MAC moved last (in `log_uses` it comes before `place`). */
64
+ export function useEntry(r: UseRow): LogEntry {
65
+ return { tree: 'uses', number: r.number, at: r.at, columns: [...useColumns(r), r.mac] };
66
+ }
67
+
68
+ /** Whether an entry's MAC holds under `key`, its tag included: the checkpointer's `authentic`. */
69
+ export function authentic(key: LogMacKey): (entry: LogEntry) => boolean {
70
+ return (entry) => {
71
+ const stored = entry.columns.at(-1);
72
+ if (!(stored instanceof Uint8Array) || stored.length !== MAC_BYTES) return false;
73
+ return timingSafeEqual(mac(key, entry.tree, entry.columns.slice(0, -1)), stored);
74
+ };
75
+ }
76
+
77
+ /**
78
+ * A retention change (plan PR 7, migration 0027's `log_retention`): an entry
79
+ * of the changes tree, kept for good, that cuts the uses tree. Its object is
80
+ * the log's own, its actor the system's, and its payload names the day it
81
+ * drops and the cut: the pair at or past the day's end, by its upto, its time
82
+ * and its uses head, which the kept tiles at the cut give a verifier.
83
+ */
84
+ export const RETENTION_OBJECT = logObject('log', 'retention');
85
+ export const RETENTION_ACTOR = 'system:retention';
86
+
87
+ /** The log's own object for the cutover's change from 0.14 (plan PR 9; cutover.ts), its marker: one, ever. */
88
+ export const CUTOVER_OBJECT = logObject('log', 'cutover');
89
+
90
+ /** Who makes the cutover's changes. */
91
+ export const CUTOVER_ACTOR = 'system:vault';
92
+
93
+ export function retentionPayload(day: string, cut: Cut): string {
94
+ return JSON.stringify({ op: 'retention', day, cut: { upto: String(cut.upto), at: cut.at, uses: cut.uses.size, root: cut.uses.root.toString('hex') } });
95
+ }
96
+
97
+ /** The cut a retention change names, from its entry; null for any other entry, or one whose payload is not a retention's. */
98
+ export function retentionCut(entry: LogEntry): Cut | null {
99
+ if (entry.tree !== 'changes' || entry.columns[2] !== RETENTION_OBJECT || typeof entry.columns[4] !== 'string') return null;
100
+ let payload: unknown;
101
+ try {
102
+ payload = JSON.parse(entry.columns[4]);
103
+ } catch {
104
+ return null;
105
+ }
106
+ const c = (payload as { op?: unknown; cut?: { upto?: unknown; at?: unknown; uses?: unknown; root?: unknown } } | null);
107
+ const cut = c?.op === 'retention' ? c.cut : undefined;
108
+ if (typeof cut?.upto !== 'string' || !/^\d{1,19}$/.test(cut.upto) || !Number.isSafeInteger(cut.at) || !Number.isSafeInteger(cut.uses) || (cut.uses as number) < 0
109
+ || typeof cut.root !== 'string' || !/^[0-9a-f]{64}$/.test(cut.root)) {
110
+ return null;
111
+ }
112
+ return { upto: BigInt(cut.upto), at: cut.at as number, uses: { size: cut.uses as number, root: Buffer.from(cut.root, 'hex') } };
113
+ }
@@ -0,0 +1,188 @@
1
+ import { useCode, useOfCode, USE_PURPOSES, USE_WAYS } from '@coffre/core/vault';
2
+ import type { Queryable } from '@coffre/db';
3
+
4
+ import { bulkStep, type BulkRows } from './bulk.ts';
5
+ import { loggedChange } from './changes.ts';
6
+ import type { BulkLimit } from './config.ts';
7
+ import { decideUse, useEnvironments, type Read, type UseCode, type UseRequest, type UseView, type Way, type WayInput } from './decide.ts';
8
+ import { applyOp, objectKey, POLICY_REF, ROLES_REF, sealed, sealKey, type Input, type Kind, type Op, type Ref, type SealKeys } from './inputs.ts';
9
+ import { CUTOVER_OBJECT, RETENTION_OBJECT } from './log-entry.ts';
10
+ import { loggedRows } from './store.ts';
11
+
12
+ /**
13
+ * The 0.15 log replayed (plan, PR 3; the verifier of PR 8 walks it): its
14
+ * changes applied in number order with the same `applyOp` the change path
15
+ * applies, and each use decided again on the state of the entries numbered
16
+ * before it, at its own time. The log stores no outcome: this is the only
17
+ * record of one, and it must be the live answer (R53).
18
+ *
19
+ * Live, a use reads rows; replayed, it reads the objects the log built,
20
+ * each with the seal its newest change left. `readUse` is both reads: the
21
+ * use's statement (PR 5) over a map of rows, so that a replay and the rows
22
+ * it should match are read the same way.
23
+ */
24
+
25
+ /** A change as logged: its number, its time, its object, what it did, and the seal it left (null: none, the object gone). */
26
+ export type LoggedChange = { kind: 'change'; number: bigint; at: number; ref: Ref; op: Op; seal: Buffer | null };
27
+ /** A use as logged: the request (UseRequest), numbered. */
28
+ export type LoggedUse = { kind: 'use'; number: bigint; request: UseRequest };
29
+ export type Logged = LoggedChange | LoggedUse;
30
+
31
+ /** Rows by object key: each input's row as stored, with the seal on it. */
32
+ export type Rows = ReadonlyMap<string, { row: Input; seal: Buffer | null }>;
33
+
34
+ /**
35
+ * What a use's one statement reads, from `rows`, with each object's newest
36
+ * seal from the changes (`newest`), in one snapshot: the credential, its
37
+ * principal, the roles and the policy, the key and a reference's source,
38
+ * and the environments the decision weighs. It moves the cap as the
39
+ * statement does, on `bulk`'s rows of the principal, with `limit`, the
40
+ * count and window the policy's newest change logged (HotPathOne's
41
+ * `CapSealed`, `CapFromPolicy`): never the policy's row, which the
42
+ * statement reads before anyone checks its seal, so a row put back would
43
+ * move the cap otherwise than the replay (`BulkReplayAgrees`). Every request
44
+ * the cap admits moves it, however it is decided after, `stale` included
45
+ * (`HotPathOneStaleBulk`).
46
+ */
47
+ export function readUse(rows: Rows, newest: ReadonlyMap<string, Buffer | null>, limit: BulkLimit | null, bulk: Map<string, BulkRows>, request: UseRequest): UseView {
48
+ const read = <K extends Kind>(kind: K, id: string): Read<Extract<Input, { kind: K }>> => {
49
+ const key = objectKey({ kind, id });
50
+ // Each kind is a table of its own: a key's row is one of its kind or none.
51
+ const stored = rows.get(key)?.row.kind === kind ? rows.get(key)! : undefined;
52
+ return { row: (stored?.row ?? null) as Extract<Input, { kind: K }> | null, seal: stored?.seal ?? null, newest: newest.get(key) ?? null };
53
+ };
54
+ // The way in's object, of its own kind; behind Access none, the person being the one the request's key names (T147).
55
+ const way = request.way ?? 'credential';
56
+ const credential: Read<WayInput> = way === 'access' ? { row: null, seal: null, newest: null } : read(way, request.credential);
57
+ const person = way === 'access' && rows.get(request.credential)?.row.kind === 'principal' ? (rows.get(request.credential)!.row as Extract<Input, { kind: 'principal' }>).principal : null;
58
+ const secret = read('secret', request.secretId);
59
+ const policy = read('policy', POLICY_REF.id);
60
+ const holder = way === 'access' ? person : credential.row?.principal ?? null;
61
+ const principal = holder === null ? null : read('principal', holder);
62
+ const source = secret.row?.source == null ? null : read('secret', secret.row.source);
63
+ const environments = new Map<string, Read<Extract<Input, { kind: 'environment' }>>>();
64
+ // Each environment the rows read so far name, until no new one: the statement's joins, in order.
65
+ for (let named = useEnvironments({ secret, source, environments }, request); named.some((id) => !environments.has(id)); named = useEnvironments({ secret, source, environments }, request)) {
66
+ for (const id of named) if (!environments.has(id)) environments.set(id, read('environment', id));
67
+ }
68
+ let cap = null;
69
+ if (holder !== null) {
70
+ const held = bulk.get(holder) ?? new Map<string, number>();
71
+ bulk.set(holder, held);
72
+ // No policy in the log, no cap to move: the decision refuses the use (`no_policy`).
73
+ cap = limit === null ? { inSet: false, others: 0 } : bulkStep(held, request.secretId, request.at, limit);
74
+ }
75
+ return { credential, principal, roles: read('roles', ROLES_REF.id), policy, secret, source, environments, bulk: cap };
76
+ }
77
+
78
+ /**
79
+ * A use's purpose, keepsSensitive and way in as its row stores them, in
80
+ * one smallint (schema 0022, 0046): core's codes (`USE_PURPOSES`), which
81
+ * the audit pages read back too (T166).
82
+ */
83
+ export const PURPOSES = USE_PURPOSES satisfies readonly UseRequest['purpose'][];
84
+
85
+ /** Each way in (decide.ts, `Way`), by its code: a credential's, 0, as every use before T147. */
86
+ export const WAYS = USE_WAYS satisfies readonly Way[];
87
+
88
+ export function purposeCode(request: Pick<UseRequest, 'purpose' | 'keepsSensitive' | 'way'>): number {
89
+ return useCode(request);
90
+ }
91
+
92
+ /** The purpose, keepsSensitive and way in a row's code stores; null for a code no release writes. */
93
+ export function purposeOf(code: number): Pick<UseRequest, 'purpose' | 'keepsSensitive' | 'way'> | null {
94
+ return useOfCode(code);
95
+ }
96
+
97
+ /** The log replayed: the rows it says the tables hold, each object's newest seal, every principal's bulk rows, and what does not hold. */
98
+ export type Replayed = {
99
+ /** Each object, with the seal its newest change left. */
100
+ rows: Map<string, { row: Input; seal: Buffer | null }>;
101
+ newest: Map<string, Buffer | null>;
102
+ /** The cap the policy's newest change logged: what moves the bulk rows. */
103
+ limit: BulkLimit | null;
104
+ bulk: Map<string, BulkRows>;
105
+ /** The highest entry number seen: a replay resumed on this state goes on from it. */
106
+ last: bigint;
107
+ /** Entries out of number order, changes that do nothing, and seals that are not their object's. */
108
+ faults: string[];
109
+ };
110
+
111
+ export function emptyReplay(): Replayed {
112
+ return { rows: new Map(), newest: new Map(), limit: null, bulk: new Map(), last: -1n, faults: [] };
113
+ }
114
+
115
+ /**
116
+ * Replay `entries`, in the order given, on `replayed` (an empty replay for
117
+ * a whole log), calling `decided` with each use's decision.
118
+ */
119
+ export function replay(
120
+ keys: SealKeys,
121
+ entries: Iterable<Logged>,
122
+ decided?: (use: LoggedUse, code: UseCode | null) => void,
123
+ replayed: Replayed = emptyReplay(),
124
+ ): Replayed {
125
+ for (const entry of entries) {
126
+ // Against the highest number seen: after 10, both 5 and 7 are out of order.
127
+ if (entry.number <= replayed.last) replayed.faults.push(`${entry.number}: after ${replayed.last}, out of number order`);
128
+ else replayed.last = entry.number;
129
+ if (entry.kind === 'change') {
130
+ // The log's own changes (a retention's, the cutover's) change no input: passed over.
131
+ if (!LOGS_OWN.has((entry.op as { op: string }).op)) change(keys, replayed, entry);
132
+ continue;
133
+ }
134
+ const code = decideUse(keys, readUse(replayed.rows, replayed.newest, replayed.limit, replayed.bulk, entry.request), entry.request);
135
+ decided?.(entry, code);
136
+ }
137
+ return replayed;
138
+ }
139
+
140
+ /** The ops of the log's own changes, which change no input: a retention's (PR 7), the cutover's (PR 9). */
141
+ const LOGS_OWN = new Set(['retention', 'cutover']);
142
+
143
+ function change(keys: SealKeys, replayed: Replayed, entry: LoggedChange): void {
144
+ const key = objectKey(entry.ref);
145
+ const after = applyOp(replayed.rows.get(key)?.row ?? null, entry.ref, entry.op);
146
+ if (after === undefined) {
147
+ replayed.faults.push(`${entry.number}: ${entry.op.op} on ${entry.ref.kind} ${entry.ref.id} does nothing`);
148
+ return;
149
+ }
150
+ // The seal the vault wrote must be the vault's over the object the replay has: a gone object leaves none.
151
+ if (after === null ? entry.seal !== null : !sealed(keys, entry.ref, after, entry.seal)) {
152
+ replayed.faults.push(`${entry.number}: its seal is not the vault's over ${entry.ref.kind} ${entry.ref.id} as the replay has it`);
153
+ }
154
+ if (after === null) replayed.rows.delete(key);
155
+ else replayed.rows.set(key, { row: after, seal: entry.seal });
156
+ if (after?.kind === 'policy' && entry.ref.id === POLICY_REF.id) replayed.limit = after.bulk;
157
+ replayed.newest.set(key, entry.seal);
158
+ }
159
+
160
+ /**
161
+ * The log as stored, read whole and replayed with the seal keys of
162
+ * `signingKeys` (the vault's): each use's decision by its entry number,
163
+ * and what does not hold, an entry that names no object or no purpose
164
+ * among `replay`'s faults. The verifier of plan PR 8 walks the log a
165
+ * stretch at a time; this reads all of it, for the suites that hold every
166
+ * live answer to it (R53).
167
+ */
168
+ export async function replayStored(db: Queryable, signingKeys: readonly Uint8Array[]): Promise<{ decided: Map<string, UseCode | null>; faults: string[] }> {
169
+ const { changes, uses } = await loggedRows(db);
170
+ const faults: string[] = [];
171
+ const entries: Logged[] = [];
172
+ for (const row of changes) {
173
+ // The log's own changes (a retention's, the cutover's), which name no input, `replay` passes over.
174
+ if (row.object === RETENTION_OBJECT || row.object === CUTOVER_OBJECT) continue;
175
+ const change = loggedChange({ number: BigInt(row.number), at: Number(row.at), object: row.object, payload: row.payload, seal: row.seal === null ? null : Buffer.from(row.seal) });
176
+ if (change === null) faults.push(`${row.number}: names no object it is of`);
177
+ else entries.push(change);
178
+ }
179
+ for (const row of uses) {
180
+ const purpose = purposeOf(Number(row.purpose));
181
+ if (purpose === null) faults.push(`${row.number}: no purpose ${row.purpose}`);
182
+ else entries.push({ kind: 'use', number: BigInt(row.number), request: { credential: row.credential, secretId: row.secret, place: row.place, at: Number(row.at), ...purpose } });
183
+ }
184
+ entries.sort((a, b) => (a.number < b.number ? -1 : a.number > b.number ? 1 : 0));
185
+ const decided = new Map<string, UseCode | null>();
186
+ const replayed = replay(signingKeys.map(sealKey), entries, (use, code) => decided.set(use.number.toString(), code));
187
+ return { decided, faults: [...faults, ...replayed.faults] };
188
+ }