@coffre/vault 0.14.4 → 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 (45) hide show
  1. package/README.md +6 -4
  2. package/dist/cloudflare.d.ts +343 -9
  3. package/dist/cloudflare.js +802 -6
  4. package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
  5. package/dist/index.d.ts +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/log-entry-BYWqj3O5.d.ts +364 -0
  8. package/dist/node.d.ts +182 -7
  9. package/dist/node.js +358 -16
  10. package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
  11. package/dist/vault-kmtojYnW.js +14623 -0
  12. package/package.json +4 -5
  13. package/src/bulk.ts +89 -0
  14. package/src/changes.ts +478 -0
  15. package/src/checkpointer-node.ts +342 -0
  16. package/src/checkpointer-workers.ts +514 -0
  17. package/src/checkpointer.ts +1165 -0
  18. package/src/cloudflare.ts +207 -13
  19. package/src/config.ts +15 -3
  20. package/src/credentials.postgres.ts +78 -0
  21. package/src/credentials.sqlite.ts +240 -0
  22. package/src/credentials.ts +630 -0
  23. package/src/cutover.ts +275 -0
  24. package/src/decide.ts +437 -2
  25. package/src/inputs.ts +499 -0
  26. package/src/local.ts +20 -4
  27. package/src/log-checkpointer.ts +116 -0
  28. package/src/log-entry.ts +113 -0
  29. package/src/log-replay.ts +188 -0
  30. package/src/log-store.ts +695 -0
  31. package/src/node.ts +73 -9
  32. package/src/objects.ts +141 -0
  33. package/src/reads.ts +502 -0
  34. package/src/retention.ts +229 -0
  35. package/src/snapshot.postgres.ts +39 -9
  36. package/src/snapshot.sqlite.ts +28 -3
  37. package/src/snapshot.ts +72 -12
  38. package/src/store.ts +862 -37
  39. package/src/use.postgres.ts +111 -0
  40. package/src/use.sqlite.ts +542 -0
  41. package/src/use.ts +1060 -0
  42. package/src/vault.ts +1513 -240
  43. package/src/verifier.ts +136 -0
  44. package/src/witness-workers.ts +163 -0
  45. package/dist/vault-BBdXsny3.js +0 -5077
@@ -0,0 +1,136 @@
1
+ import { pairSigners, pairVerifiers, verifyLog, type LogEntry, type PairNotes, type PairVerifiers } from '@coffre/core/merkle';
2
+ import type { TreeVerification } from '@coffre/core/vault';
3
+ import type { Database } from '@coffre/db';
4
+ import { SNAPSHOT } from '@coffre/db/dialect';
5
+
6
+ import { loggedChange } from './changes.ts';
7
+ import { logOrigin } from './checkpointer.ts';
8
+ import { authentic, logMacKey, retentionCut, type LogMacKey } from './log-entry.ts';
9
+ import { purposeOf } from './log-replay.ts';
10
+ import { logStore } from './log-store.ts';
11
+ import { requestsUnique } from './store.ts';
12
+
13
+ /**
14
+ * 0.15's verifier (docs/design/0.15-plan.md, PR 8): what holds 0.15's log
15
+ * to itself, for the audit page, `coffre verify instance` and conformance,
16
+ * on every visit (R25), and keeps nothing between calls. It checks the
17
+ * newest signed pair, its signatures and the pairs held outside (the
18
+ * signer's memory), then every entry above it by its MAC and its request:
19
+ * a tick's worth of entries, whatever the log's size, in three statements,
20
+ * and never more than `ABOVE_NEWEST` of them.
21
+ *
22
+ * Below the newest pair, the pair vouches for what it sealed: it is not
23
+ * walked again. A row a decision reads is held to its object's newest
24
+ * change by every use (decide.ts, `stale`), never by this check, which
25
+ * reads no row. What is not checked, by design (D145): an edit a database
26
+ * writer makes to old entries, or to rows nobody uses, until an offline
27
+ * scan exists; the signed pairs let one rebuild the trees later without the
28
+ * MAC key. There is no walk of the whole log on a request: its cost is
29
+ * shaped by what the database holds, which its writer chooses, and a
30
+ * Worker's call has a limit on its CPU time. Nor can this check see an
31
+ * entry deleted or reordered before its tick (R51's window; a change
32
+ * answers only once sealed, so this is a use's).
33
+ */
34
+
35
+ /** The keys a check needs: the trees' public keys, and the entries' MAC key. */
36
+ export type VerifierKeys = { verifiers: PairVerifiers; mac: LogMacKey };
37
+
38
+ /** The verifier's keys from the vault's first signing seed, as the checkpointer has them (log-checkpointer.ts). */
39
+ export async function verifierKeys(seeds: readonly Uint8Array[]): Promise<VerifierKeys> {
40
+ const seed = seeds[0]!;
41
+ const signers = await pairSigners(await logOrigin(seed), seed);
42
+ return {
43
+ verifiers: await pairVerifiers({ changes: signers.changes.verifierKey, uses: signers.uses.verifierKey }),
44
+ mac: logMacKey(seed),
45
+ };
46
+ }
47
+
48
+ export type VerifyOptions = {
49
+ /**
50
+ * Pairs held outside the database: the signer's memory, a witness's
51
+ * (`VaultOptions.heldOutside`). Each check must reach each: a database
52
+ * rolled back whole to an earlier pair, rows with it, otherwise passes
53
+ * until the signer's next tick refuses it.
54
+ */
55
+ anchors?: readonly PairNotes[];
56
+ };
57
+
58
+ /**
59
+ * The most entries above the newest pair a check reads: four ticks' worth
60
+ * at R44's 1,000 uses a second, 14 ms measured on a developer's machine
61
+ * (verifier-cost.test.ts). More is a checkpointer behind, or rows a writer
62
+ * planted, and is said (`capped`), never read (reviews r1, r4).
63
+ */
64
+ export const ABOVE_NEWEST = 2_000;
65
+
66
+ /** The verdict as the vault gives it, and whether the database ended behind a pair held outside (`Verdict.behind`). */
67
+ export type Walked = Extract<TreeVerification, { kept: true }> & { behind: boolean };
68
+
69
+ export async function verifyTrees(db: Database, keys: VerifierKeys, options: VerifyOptions): Promise<Walked> {
70
+ const walked = await db.transaction(async (raw) => {
71
+ const tx = raw as unknown as Database;
72
+ const store = logStore(tx);
73
+ // From the newest pair, which its signatures and the pairs held outside vouch for. It walks no use below it, so
74
+ // retention's cut is not its to check: the newest pair and the signer's are at or above every cut.
75
+ const newest = await store.newest();
76
+ // Each entry's request, seen once (RequestIdChecked): a change's by its id, a use's by its time and id, which its MAC
77
+ // covers. A copy below the start is what the database's unique indexes refuse, which every check holds to standing.
78
+ const requests = new Set<string>();
79
+ const macHolds = authentic(keys.mac);
80
+ const check = (entry: LogEntry): string | null => {
81
+ if (!macHolds(entry)) return 'its MAC does not hold';
82
+ const request = entry.tree === 'changes' ? `c:${String(entry.columns[1])}` : `u:${entry.at}:${String(entry.columns[1])}`;
83
+ if (requests.has(request)) return 'a second entry under the request of another: a replay';
84
+ requests.add(request);
85
+ return malformed(entry);
86
+ };
87
+ const verdict = await verifyLog(store, {
88
+ verifiers: keys.verifiers,
89
+ check,
90
+ ...(newest === null ? {} : { newest }),
91
+ ...(options.anchors === undefined ? {} : { anchors: options.anchors }),
92
+ above: ABOVE_NEWEST,
93
+ });
94
+ const problems = [...verdict.problems];
95
+ if (!(await requestsUnique(tx))) problems.push('the indexes that keep each request to one entry are gone: a copy of an entry would pass');
96
+ return { verdict, problems };
97
+ }, SNAPSHOT);
98
+ const { verdict, problems } = walked;
99
+ const head = verdict.headPair;
100
+ return {
101
+ kept: true,
102
+ ok: problems.length === 0 && !verdict.capped,
103
+ problems,
104
+ // The genesis pair seals nothing and is signed at time 0: no checkpoint yet.
105
+ checkpoint: head === null || head.upto === 0n ? null : { upto: head.upto.toString(), at: head.at },
106
+ unsealed: verdict.unsealed,
107
+ capped: verdict.capped,
108
+ behind: verdict.behind,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Why an entry is none a release writes, from the columns its tree seals
114
+ * (log-entry.ts): a change whose payload names no object, or another than
115
+ * its own; a use of no purpose. Null for one that reads, a retention
116
+ * change, the log's own, included.
117
+ */
118
+ function malformed(entry: LogEntry): string | null {
119
+ const c = entry.columns;
120
+ if (retentionCut(entry) !== null) return null;
121
+ if (entry.tree === 'changes') {
122
+ // [at, request, object, actor, payload, seal, mac]
123
+ const [object, payload, seal] = [c[2], c[4], c[5]];
124
+ let change = null;
125
+ try {
126
+ if (typeof object === 'string' && typeof payload === 'string' && (seal === null || seal instanceof Uint8Array)) {
127
+ change = loggedChange({ number: entry.number, at: entry.at, object, payload, seal: seal === null ? null : Buffer.from(seal) });
128
+ }
129
+ } catch {}
130
+ return change === null ? 'a change whose payload names no object, or another than its own' : null;
131
+ }
132
+ // [at, request, credential, secret, purpose, place, mac]
133
+ const [credential, secret, code, place] = [c[2], c[3], c[4], c[5]];
134
+ const purpose = typeof code === 'number' ? purposeOf(code) : null;
135
+ return purpose === null || typeof credential !== 'string' || typeof secret !== 'string' || (place !== null && typeof place !== 'string') ? 'a use no release writes' : null;
136
+ }
@@ -0,0 +1,163 @@
1
+ // Witnesses on Workers for 0.15's log (docs/design/audit-tree.md,
2
+ // "Witnesses"; R40: the deployment opts in). The checkpointer hands each
3
+ // pair it signs to every witness the vault's config lists:
4
+ //
5
+ // - `durableWitness(env.WITNESS)`: a Durable Object of its own that holds
6
+ // the newest pair it cosigned and cosigns a new one only if both trees
7
+ // extend it (`witnessPair`), so the log cannot show two histories and get
8
+ // both witnessed. Its own key cosigns; deployments that want a witness
9
+ // outside their Cloudflare account point a C2SP witness at each tree.
10
+ // - `r2Archive(env.ARCHIVE)`: the pairs it is sent kept in an R2 bucket (the pump sends the newest one, so a pair a
11
+ // slow put skipped over is not kept; the next one, which extends it, is). It checks
12
+ // nothing: the verifier holds the rows to it later. Under a bucket lock
13
+ // rule a Worker or a database writer cannot overwrite or delete what it
14
+ // holds; an admin of the Cloudflare account can lift the rule (R54).
15
+ import { decodePair, encodePair, noteSigner, openPair, pairVerifiers, witnessPair, type PairNotes, type TreeName, type WitnessAnswer, type WitnessState } from '@coffre/core/merkle';
16
+
17
+ import type { Witness } from './checkpointer.ts';
18
+ import type { ObjectNamespace, ObjectState } from './checkpointer-workers.ts';
19
+
20
+ export type WitnessSettings = {
21
+ /** The witness's own name, its cosignatures' key name: `witness.acme.example`, say. */
22
+ name: string;
23
+ /** Its own Ed25519 seed, 32 bytes, base64. */
24
+ key: string;
25
+ /** The log's two verifier keys, as the vault's checkpointer signs them. */
26
+ log: Record<TreeName, string>;
27
+ };
28
+
29
+ type Publication = { notes: PairNotes; from: Record<TreeName, number>; proofs: Record<TreeName, string[]> };
30
+
31
+ /** The witness's Durable Object class. A new name for it is a new witness, which takes the next pair it is sent as given. */
32
+ export function witnessObject<Env>(settings: (env: Env) => WitnessSettings | Promise<WitnessSettings>) {
33
+ return class CoffreWitness {
34
+ readonly #state: ObjectState;
35
+ readonly #env: Env;
36
+
37
+ constructor(state: ObjectState, env: Env) {
38
+ this.#state = state;
39
+ this.#env = env;
40
+ }
41
+
42
+ async fetch(request: Request): Promise<Response> {
43
+ // Read, check and write as one, nothing else delivered meanwhile: a Durable Object's handlers interleave at every
44
+ // await but storage's, so two forks sent at once would each be checked against the pair held, and both cosigned.
45
+ try {
46
+ const body = request.method === 'GET' ? null : ((await request.json()) as Publication);
47
+ return await this.#state.blockConcurrencyWhile(() => this.#answer(body));
48
+ } catch (error) {
49
+ return Response.json({ ok: false, reason: `the witness failed: ${(error as Error).message}` }, { status: 500 });
50
+ }
51
+ }
52
+
53
+ /** Every failure answered from inside the gate, never thrown out of it, which would reset the object. */
54
+ async #answer(p: Publication | null): Promise<Response> {
55
+ try {
56
+ return await this.#answerOr(p);
57
+ } catch (error) {
58
+ return Response.json({ ok: false, reason: `the witness failed: ${(error as Error).message}` }, { status: 500 });
59
+ }
60
+ }
61
+
62
+ async #answerOr(p: Publication | null): Promise<Response> {
63
+ const s = await settings(this.#env);
64
+ const log = await pairVerifiers(s.log);
65
+ const self = await noteSigner(s.name, Buffer.from(s.key, 'base64'));
66
+ const stored = await this.#state.storage.get<string>('pair');
67
+ const notes = stored === undefined ? null : decodePair(stored);
68
+ const pair = notes === null ? null : await openPair(notes, log);
69
+ // A pair held that no longer opens (the log's keys changed in its settings, a record damaged) is never forgotten
70
+ // into a fresh witness: that would cosign whatever comes next. A new witness is a new name.
71
+ if (stored !== undefined && pair === null) return Response.json({ ok: false, reason: 'the pair this witness holds does not open under the log\'s keys: name a new witness' }, { status: 500 });
72
+ const held: WitnessState = pair === null ? null : { pair, notes: notes! };
73
+ const sizes = { changes: held?.pair.changes.size ?? 0, uses: held?.pair.uses.size ?? 0 };
74
+ if (p === null) return Response.json({ held: sizes });
75
+ // Proofs from other sizes than the ones held prove nothing here: the sender asks again, from these.
76
+ if (p.from.changes !== sizes.changes || p.from.uses !== sizes.uses) return Response.json({ held: sizes }, { status: 409 });
77
+ const proofs = { changes: p.proofs.changes.map((h) => Buffer.from(h, 'base64')), uses: p.proofs.uses.map((h) => Buffer.from(h, 'base64')) };
78
+ const r = await witnessPair(held, p.notes, proofs, log, self);
79
+ if (!r.answer.ok) return Response.json(r.answer, { status: 422 });
80
+ await this.#state.storage.put('pair', encodePair(p.notes));
81
+ return Response.json(r.answer);
82
+ }
83
+ };
84
+ }
85
+
86
+ /** The witness's object as the checkpointer's `Witness`: the pair and each tree's proof from the sizes the object holds. */
87
+ export function durableWitness(namespace: ObjectNamespace, options: { name?: string } = {}): Witness {
88
+ const name = options.name ?? 'witness';
89
+ let held: Record<TreeName, number> | null = null;
90
+ return {
91
+ name,
92
+ async publish(notes, proofs, signal) {
93
+ if (typeof namespace?.idFromName !== 'function') return { ok: false, reason: 'there is no WITNESS binding' };
94
+ const object = namespace.get(namespace.idFromName(name));
95
+ for (let tries = 0; tries < 2; tries += 1) {
96
+ if (held === null) {
97
+ // The sizes it holds, or why it answers none: a witness that refuses (one whose pair no longer opens says to
98
+ // name a new one) is a failed try with its reason, and nothing is cached.
99
+ const response = await object.fetch(new Request('https://witness/', { method: 'GET', signal }));
100
+ const body = (await response.json().catch(() => ({}))) as { held?: unknown; reason?: string };
101
+ const sizes = sizesIn(body.held);
102
+ if (!response.ok || sizes === null) return { ok: false, reason: body.reason ?? `the witness answered ${response.status}` };
103
+ held = sizes;
104
+ }
105
+ const from = held;
106
+ const p = await proofs(from);
107
+ const body: Publication = { notes, from, proofs: { changes: p.changes.map((h) => h.toString('base64')), uses: p.uses.map((h) => h.toString('base64')) } };
108
+ const response = await object.fetch(new Request('https://witness/', { method: 'POST', body: JSON.stringify(body), signal }));
109
+ const answer = (await response.json().catch(() => ({ ok: false }))) as WitnessAnswer & { held?: unknown };
110
+ if (response.status === 409) {
111
+ // The sizes it holds, sent from again; none that make sense, and the next try asks for them afresh.
112
+ held = sizesIn(answer.held);
113
+ if (held === null) return { ok: false, reason: `the witness answered 409 without sizes it holds` };
114
+ continue;
115
+ }
116
+ if (!answer.ok) return { ok: false, reason: answer.reason ?? `the witness answered ${response.status}` };
117
+ held = sizesOf(notes);
118
+ return { ok: true, cosigned: answer.cosigned };
119
+ }
120
+ return { ok: false, reason: 'the witness kept holding other sizes' };
121
+ },
122
+ };
123
+ }
124
+
125
+ /** Sizes a witness says it holds, if they are sizes: whole numbers, none below zero. */
126
+ function sizesIn(value: unknown): Record<TreeName, number> | null {
127
+ const sizes = value as Record<TreeName, unknown> | null | undefined;
128
+ const size = (n: unknown) => Number.isSafeInteger(n) && (n as number) >= 0;
129
+ return sizes != null && size(sizes.changes) && size(sizes.uses) ? { changes: sizes.changes as number, uses: sizes.uses as number } : null;
130
+ }
131
+
132
+ /** Each tree's size, read from its note (the checkpointer's own, just signed). */
133
+ const sizesOf = (notes: PairNotes): Record<TreeName, number> => ({ changes: Number(notes.changes.split('\n')[1]), uses: Number(notes.uses.split('\n')[1]) });
134
+
135
+ /** The part of an R2 bucket binding the archive uses. */
136
+ export type ArchiveBucket = {
137
+ put(key: string, value: string, options?: { onlyIf?: { etagDoesNotMatch?: string } }): Promise<unknown | null>;
138
+ get(key: string): Promise<{ text(): Promise<string> } | null>;
139
+ };
140
+
141
+ /**
142
+ * Each pair it is sent, kept in an R2 bucket under `<prefix><upto, 20 digits>.json`.
143
+ * A place already archived with the same pair is fine (a pair sent again);
144
+ * one archived with another is refused, and said.
145
+ */
146
+ export function r2Archive(bucket: ArchiveBucket, options: { prefix?: string } = {}): Witness {
147
+ const prefix = options.prefix ?? 'audit/';
148
+ return {
149
+ name: 'r2-archive',
150
+ async publish(notes) {
151
+ const upto = notes.changes.split('\n')[3]?.slice(5) ?? '';
152
+ if (!/^\d{1,19}$/.test(upto)) return { ok: false, reason: 'not a checkpoint pair' };
153
+ const key = `${prefix}${upto.padStart(20, '0')}.json`;
154
+ const record = encodePair(notes);
155
+ // An object already there is answered by R2 with null, or, under a bucket lock, perhaps with an error: either way,
156
+ // it is the pair sent again or another one, which the read below tells apart.
157
+ const put = await bucket.put(key, record, { onlyIf: { etagDoesNotMatch: '*' } }).catch(() => null);
158
+ if (put !== null) return { ok: true };
159
+ const kept = await (await bucket.get(key))?.text();
160
+ return kept === record ? { ok: true } : { ok: false, reason: `the archive holds another pair up to ${upto}` };
161
+ },
162
+ };
163
+ }