@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.
- package/README.md +6 -4
- package/dist/cloudflare.d.ts +343 -9
- package/dist/cloudflare.js +802 -6
- package/dist/{index-CIq4i_KT.d.ts → index-DQ-FKVF3.d.ts} +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/log-entry-BYWqj3O5.d.ts +364 -0
- package/dist/node.d.ts +182 -7
- package/dist/node.js +358 -16
- package/dist/{src-NM5NsYFs.js → src-DheG92EM.js} +9 -2
- package/dist/vault-kmtojYnW.js +14623 -0
- package/package.json +4 -5
- package/src/bulk.ts +89 -0
- package/src/changes.ts +478 -0
- package/src/checkpointer-node.ts +342 -0
- package/src/checkpointer-workers.ts +514 -0
- package/src/checkpointer.ts +1165 -0
- package/src/cloudflare.ts +207 -13
- package/src/config.ts +15 -3
- package/src/credentials.postgres.ts +78 -0
- package/src/credentials.sqlite.ts +240 -0
- package/src/credentials.ts +630 -0
- package/src/cutover.ts +275 -0
- package/src/decide.ts +437 -2
- package/src/inputs.ts +499 -0
- package/src/local.ts +20 -4
- package/src/log-checkpointer.ts +116 -0
- package/src/log-entry.ts +113 -0
- package/src/log-replay.ts +188 -0
- package/src/log-store.ts +695 -0
- package/src/node.ts +73 -9
- package/src/objects.ts +141 -0
- package/src/reads.ts +502 -0
- package/src/retention.ts +229 -0
- package/src/snapshot.postgres.ts +39 -9
- package/src/snapshot.sqlite.ts +28 -3
- package/src/snapshot.ts +72 -12
- package/src/store.ts +862 -37
- package/src/use.postgres.ts +111 -0
- package/src/use.sqlite.ts +542 -0
- package/src/use.ts +1060 -0
- package/src/vault.ts +1513 -240
- package/src/verifier.ts +136 -0
- package/src/witness-workers.ts +163 -0
- package/dist/vault-BBdXsny3.js +0 -5077
package/src/verifier.ts
ADDED
|
@@ -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
|
+
}
|