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 | |
| 31 | import { readFile } from 'node:fs/promises'; |
| 32 | import { join, normalize, resolve } from 'node:path'; |
| 33 | import { fileURLToPath } from 'node:url'; |
| 34 | import { hashTree, bundleHash, diffFiles, parseLog, verifyChain, sha256 } from './lib.mjs'; |
| 35 | |
| 36 | const HERE = normalize(join(fileURLToPath(import.meta.url), '..')); |
| 37 | const args = process.argv.slice(2); |
| 38 | const opt = (name, def = null) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : def; }; |
| 39 | const 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. |
| 48 | const EXPECT = opt('--expect'); |
| 49 | const STRICT_LATEST = args.includes('--latest'); |
| 50 | |
| 51 | const green = (s) => `\x1b[32m${s}\x1b[0m`; |
| 52 | const red = (s) => `\x1b[31m${s}\x1b[0m`; |
| 53 | const 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"). |
| 75 | function 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. |
| 96 | async 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. |
| 105 | async 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. |
| 116 | async 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. |
| 132 | function 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. |
| 200 | async 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 | |
| 250 | if (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 | |
| 256 | const { entries, chain } = await loadChain(); |
| 257 | const dir = opt('--dir', args.includes('--url') ? null : 'www'); |
| 258 | const url = opt('--url'); |
| 259 | |
| 260 | let src; |
| 261 | try { |
| 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 | |
| 268 | const ok = report(url || dir, src.manifest, src.actual, chain, entries, !!url); |
| 269 | process.exit(ok ? 0 : 1); |