Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/verify/check.mjs

13.4 KiB, 1 run

created by r2519314175:1005, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify/check.mjs — confirm a Daimond build is the published source.
2//
3// This is the trustworthy end of "Don't trust us, check": an independent
4// program, run from the source you cloned, that hashes a bundle and confirms it
5// matches the manifest AND that the manifest's bundle hash is in the public
6// transparency chain. It trusts nothing the server says beyond the bytes it
7// serves — the authority is the source in your hands and the chain in the repo.
8//
9// Build the source, then check the build you produced:
10// wasm-pack build --target web --out-dir www/pkg
11// node verify/check.mjs --dir www
12//
13// Or check the RUNNING site against the chain you cloned:
14// node verify/check.mjs --url https://daimond.oxedyne.com
15//
16// Green means: every covered file's hash matches the manifest, the bundle hash
17// is the manifest's own, and that bundle is a sealed entry in an unbroken chain.
18// Red names exactly what differs. Exit 0 on green, 1 on red — so CI can gate on
19// it. No dependencies; Node's fetch and crypto only.
20//
21// Guard against a roll-back (an older, still-sealed build served in place of a
22// newer one). By default a served bundle that is sealed but not the chain's tip
23// is a warning; make it strict, or pin an exact build:
24// node verify/check.mjs --url … --latest # fail if not the tip
25// node verify/check.mjs --url … --expect <bundlehash> # fail if not this one
26
27//
28// The machine hand is checked separately, and proves something weaker on purpose:
29// node verify/check.mjs --hand # is hand/ the sealed source?
30
31import { readFile } from 'node:fs/promises';
32import { join, normalize, resolve } from 'node:path';
33import { fileURLToPath } from 'node:url';
34import { hashTree, bundleHash, diffFiles, parseLog, verifyChain, sha256 } from './lib.mjs';
35
36const HERE = normalize(join(fileURLToPath(import.meta.url), '..'));
37const args = process.argv.slice(2);
38const opt = (name, def = null) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; };
39const LOG = normalize(opt('--log', join(HERE, 'transparency.jsonl')));
40
41// The build the caller expects to be served, if they name one. Being in the
42// chain proves a bundle was published SOMETIME; it does not prove it is the
43// build meant to be live now, so a server (or a stale CDN) could serve an older,
44// still-sealed, still-green build -- a rollback. `--expect <bundle>` closes that
45// for a caller who knows the current hash (fail on anything else); with no
46// `--expect`, a served bundle that is sealed but NOT the chain's tip is reported
47// as a warning, and `--latest` promotes that warning to a failure.
48const EXPECT = opt('--expect');
49const STRICT_LATEST = args.includes('--latest');
50
51const green = (s) => `\x1b[32m${s}\x1b[0m`;
52const red = (s) => `\x1b[31m${s}\x1b[0m`;
53const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
54
55/// Print a failure's problems, GROUPED, so no category can be crowded out by another.
56///
57/// This printed `problems.slice(0, 40)` off one flat list until 2026-08-17, and the list
58/// was built changed-then-missing-then-unexpected. So a bundle with more than forty
59/// changed files printed forty changed files and NOTHING ELSE: on that date `--dir www`
60/// reported "111 problem(s)", showed forty of them, and never mentioned the one file the
61/// manifest named that had been renamed away, or the SEVEN served files the manifest did
62/// not cover -- which were the whole social client. The reader saw "lots of files have
63/// changed", which is the ordinary state of a tree between deploys, and could not see
64/// that some of the code being served was outside the seal altogether.
65///
66/// `unexpected` goes FIRST and is never trimmed. A changed file is a file the seal has an
67/// opinion about and disagrees with; an unexpected one is code the seal says nothing
68/// whatever about, which is the more serious of the two and the one a delivery check
69/// exists to surface.
70///
71/// # Arguments
72/// * `structural` - Problems that are not about one file: the chain, the bundle, `--expect`.
73/// * `diff` - `diffFiles`'s three lists.
74/// * `unexpectedAs` - What an uncovered file is called here ("manifest" or "seal").
75function printProblems(structural, diff, unexpectedAs) {
76 const total = structural.length + diff.changed.length + diff.missing.length
77 + diff.unexpected.length;
78 console.log(red(`\n FAILED — ${total} problem(s):`));
79 for (const p of structural) console.log(red(` · ${p}`));
80 const groups = [
81 [diff.unexpected, `served but NOT IN THE ${unexpectedAs.toUpperCase()} — unattested code`, Infinity],
82 [diff.missing, `in the ${unexpectedAs} and not there`, 20],
83 [diff.changed, `changed`, 20],
84 ];
85 for (const [list, what, cap] of groups) {
86 if (!list.length) continue;
87 console.log(red(` ${list.length} ${what}:`));
88 for (const f of list.slice(0, cap)) console.log(red(` · ${f}`));
89 if (list.length > cap) console.log(red(` … and ${list.length - cap} more`));
90 }
91}
92
93/// Read and chain-verify the local transparency log. A broken chain is fatal on
94/// its own: if the history is not self-consistent, nothing it contains can be
95/// trusted to say what was shipped.
96async function loadChain() {
97 let text = '';
98 try { text = await readFile(LOG, 'utf8'); }
99 catch (e) { return { entries: [], chain: { ok: false, error: `no transparency log at ${LOG}` } }; }
100 const entries = parseLog(text);
101 return { entries, chain: verifyChain(entries) };
102}
103
104/// The manifest and the actual file hashes for a LOCAL directory.
105async function fromDir(dir) {
106 const root = normalize(dir);
107 const manifest = JSON.parse(await readFile(join(root, 'manifest.json'), 'utf8'));
108 const actual = await hashTree(root); // excludes manifest.json + build.json, as the manifest did
109 return { manifest, actual };
110}
111
112/// The manifest and the actual file hashes for a SERVED origin. Only the files
113/// the manifest lists can be fetched — a remote directory cannot be enumerated —
114/// so an extra file smuggled onto the server is invisible here; a changed one is
115/// not, which is what a delivery check is for.
116async function fromUrl(origin) {
117 const base = origin.replace(/\/+$/, '');
118 const manifest = await (await fetch(base + '/manifest.json')).json();
119 const actual = {};
120 for (const rel of Object.keys(manifest.files)) {
121 const res = await fetch(base + '/' + rel);
122 if (!res.ok) continue; // left absent, so it shows as "missing" below
123 actual[rel] = sha256(Buffer.from(await res.arrayBuffer()));
124 }
125 return { manifest, actual };
126}
127
128/// `remote` says the bundle was fetched rather than read off disk, and is passed rather
129/// than read from the module's `url`: a function that reaches forward to a `const`
130/// declared below it works only while nobody calls it earlier, and that exact shape had
131/// `dev/verify_conformance.mjs` crashing on the one path that had something to report.
132function report(where, manifest, actual, chain, entries, remote) {
133 const problems = [];
134 const warnings = [];
135
136 // 1. The manifest's own bundle hash must be the hash of its file list.
137 const recomputed = bundleHash(manifest.files);
138 if (recomputed !== manifest.bundle) {
139 problems.push(`the manifest's bundle hash does not match its own file list`);
140 }
141
142 // 2. Every served file must hash to what the manifest says. With (1) holding
143 // and no file missing, changed or unexpected, the served bundle IS the
144 // manifest's bundle -- the single figure the chain is keyed on -- so it is
145 // established here rather than recomputed separately.
146 const diff = diffFiles(manifest.files, actual);
147 const fileProblems = diff.changed.length + diff.missing.length + diff.unexpected.length;
148
149 // 3. That bundle must be a sealed entry in an unbroken chain.
150 const sealedAt = chain.ok ? entries.findIndex(e => e.bundle === manifest.bundle) : -1;
151 if (!chain.ok) {
152 problems.push(`transparency chain: ${chain.error}`);
153 } else if (sealedAt === -1) {
154 problems.push(`the manifest's bundle is not in the transparency log — it was never sealed`);
155 }
156
157 // 4. Freshness. A sealed build that is not the chain's tip is an OLDER
158 // published build being served in place of a newer one -- legitimate for a
159 // deliberate roll-back, but indistinguishable from a malicious one, so it
160 // is surfaced. `--expect` pins the exact bundle; `--latest` demands the tip.
161 if (EXPECT && manifest.bundle !== EXPECT) {
162 problems.push(`served bundle is not the expected one\n expected ${EXPECT}\n served ${manifest.bundle}`);
163 }
164 if (chain.ok && sealedAt !== -1 && sealedAt !== entries.length - 1) {
165 const tip = entries[entries.length - 1];
166 const msg = `a newer build has been sealed since this one — you are being served seq ${sealedAt}, but the chain tip is seq ${tip.seq} (bundle ${tip.bundle.slice(0, 16)}…). This is a roll-back unless it was intended.`;
167 if (STRICT_LATEST) problems.push(msg); else warnings.push(msg);
168 }
169
170 console.log(`\nDaimond delivery check — ${where}`);
171 console.log(` build ${manifest.build}`);
172 console.log(` bundle ${manifest.bundle}`);
173 console.log(` files ${Object.keys(manifest.files).length} covered`);
174 console.log(` chain ${chain.ok ? green(entries.length + ' entries, intact') : red(chain.error)}`);
175 if (EXPECT) console.log(` expect ${EXPECT === manifest.bundle ? green('matches') : red('MISMATCH')}`);
176 for (const w of warnings) console.log(yellow(` warning ${w}`));
177 if (problems.length === 0 && fileProblems === 0) {
178 console.log(green(`\n OK — this build is the published source, and it was sealed.\n`));
179 return true;
180 }
181 printProblems(problems, diff, 'manifest');
182 // A remote origin cannot be enumerated, so `unexpected` is always empty over `--url`
183 // and its absence there means nothing. Said out loud, because "0 served but not in the
184 // manifest" would otherwise read as a check that was made.
185 if (remote) {
186 console.log(yellow(` (over --url only the manifest's own files can be fetched, so a file`));
187 console.log(yellow(` smuggled onto the server is invisible here. Use --dir on a local copy.)`));
188 }
189 console.log('');
190 return false;
191}
192
193/// Check the machine hand's source against `verify/hand.json`.
194///
195/// Deliberately a different check from the one above, because a different thing is true of it.
196/// The bundle check ends at "the bytes your browser ran are the published source"; this one ends
197/// at "the files in your clone are the files that were sealed, and here is the toolchain and the
198/// command that build them". Nobody publishes a hand binary, so nothing here compares one, and
199/// saying so is part of the check rather than a footnote to it.
200async function checkHand(dir) {
201 let sealed;
202 try { sealed = JSON.parse(await readFile(join(HERE, 'hand.json'), 'utf8')); }
203 catch (e) {
204 console.error(red(`no hand seal at ${join(HERE, 'hand.json')} — this release did not seal the hand.`));
205 return false;
206 }
207 const root = resolve(dir);
208 let actual;
209 try { actual = await hashTree(root, { exclude: new Set(), excludeDirs: ['target/'], excludeSuffixes: [] }); }
210 catch (e) {
211 console.error(red(`could not read the hand at ${root}: ${e.message}`));
212 return false;
213 }
214
215 const problems = [];
216 if (bundleHash(sealed.files) !== sealed.source) {
217 problems.push(`the seal's source hash does not match its own file list`);
218 }
219 const diff = diffFiles(sealed.files, actual);
220 const fileProblems = diff.changed.length + diff.missing.length + diff.unexpected.length;
221
222 console.log(`\nDaimond machine-hand source check — ${root}`);
223 console.log(` source ${sealed.source}`);
224 console.log(` files ${Object.keys(sealed.files).length} covered`);
225 console.log(` toolchain ${sealed.toolchain || '(none pinned)'}`);
226 console.log(` build ${sealed.build}`);
227 if (problems.length === 0 && fileProblems === 0) {
228 console.log(green(`\n OK — this is the sealed source of the hand.`));
229 console.log(` Proved: the files here are the ones this release sealed, and the command above`);
230 console.log(` is how they become ${sealed.binary}.`);
231 console.log(` NOT proved: that any binary you were given came from them. Nobody ships one;`);
232 console.log(` build it yourself. A Rust release build is not byte-identical across toolchain`);
233 console.log(` versions, so there is no published binary hash to compare against.\n`);
234 return true;
235 }
236 printProblems(problems, diff, 'seal');
237 // The one honest failure, so it is not mistaken for a real one. `Cargo.toml` and `Cargo.lock`
238 // differ between the development tree and the mirror BY DESIGN -- path dependencies here, a
239 // git pin there -- so this check belongs in a clone of the public repository, which is where a
240 // reader runs it anyway.
241 if (diff.changed.includes('Cargo.toml') || diff.changed.includes('Cargo.lock')) {
242 console.log(yellow(`\n Cargo.toml and Cargo.lock differ by design between the development tree and`));
243 console.log(yellow(` the public mirror: path dependencies here, a git pin there. The seal covers`));
244 console.log(yellow(` the mirror's, so check a clone of the mirror.`));
245 }
246 console.log('');
247 return false;
248}
249
250if (args.includes('--hand')) {
251 const at = opt('--hand');
252 const dir = at && !at.startsWith('--') ? at : join(HERE, '..', 'hand');
253 process.exit(await checkHand(dir) ? 0 : 1);
254}
255
256const { entries, chain } = await loadChain();
257const dir = opt('--dir', args.includes('--url') ? null : 'www');
258const url = opt('--url');
259
260let src;
261try {
262 src = url ? await fromUrl(url) : await fromDir(dir);
263} catch (e) {
264 console.error(red(`could not read the bundle to check: ${e.message}`));
265 process.exit(2);
266}
267
268const ok = report(url || dir, src.manifest, src.actual, chain, entries, !!url);
269process.exit(ok ? 0 : 1);