oxedyne/daimond/verify/verify.test.mjs
9.5 KiB, 1 run
created by r2519314175:1031, 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/verify.test.mjs — the delivery-verify chain, proven. |
| 2 | // |
| 3 | // The claim these tools make is strong — "the code your browser runs is the |
| 4 | // published source" — so the tools themselves are tested hard: a good build |
| 5 | // passes, a single changed byte fails and is named, a build that was never |
| 6 | // sealed fails, a rewritten history is caught, and the fingerprint Node computes |
| 7 | // is byte-for-byte the one the browser's Web Crypto path computes. |
| 8 | // |
| 9 | // node verify/verify.test.mjs |
| 10 | // |
| 11 | // No dependencies; a throwaway fixture tree under the OS temp dir. |
| 12 | |
| 13 | import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises'; |
| 14 | import { tmpdir } from 'node:os'; |
| 15 | import { join } from 'node:path'; |
| 16 | import { spawnSync } from 'node:child_process'; |
| 17 | import { webcrypto } from 'node:crypto'; |
| 18 | import { fileURLToPath } from 'node:url'; |
| 19 | import { |
| 20 | hashTree, bundleHash, manifestText, verifyChain, nextEntry, entryHash, diffFiles, GENESIS_PREV, |
| 21 | } from './lib.mjs'; |
| 22 | import * as extFp from './ext/fingerprint.js'; |
| 23 | |
| 24 | const CHECK = fileURLToPath(new URL('./check.mjs', import.meta.url)); |
| 25 | const ok = [], bad = []; |
| 26 | const check = (name, pass, detail) => { |
| 27 | (pass ? ok : bad).push(name); |
| 28 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 29 | }; |
| 30 | |
| 31 | /// Build a small sealed fixture: files, a matching manifest.json, and a |
| 32 | /// transparency log with one entry that seals the bundle. |
| 33 | async function sealedFixture() { |
| 34 | // The served tree and the transparency log live in SEPARATE places, as they |
| 35 | // do in the repo (www/ and verify/) — the log must never be one of the files |
| 36 | // the manifest covers, or it would change its own fingerprint. |
| 37 | const base = await mkdtemp(join(tmpdir(), 'daimond-verify-')); |
| 38 | const dir = join(base, 'www'); |
| 39 | await mkdir(join(dir, 'js'), { recursive: true }); |
| 40 | await mkdir(join(dir, 'pkg'), { recursive: true }); |
| 41 | await writeFile(join(dir, 'index.html'), '<!doctype html><title>x</title>'); |
| 42 | await writeFile(join(dir, 'js', 'app.js'), 'console.log("hi");'); |
| 43 | await writeFile(join(dir, 'pkg', 'core.wasm'), Buffer.from([0, 1, 2, 3, 4, 5])); |
| 44 | // Excluded files must NOT change the fingerprint. |
| 45 | await writeFile(join(dir, 'build.json'), JSON.stringify({ build: 'abc123def456', note: 'x' })); |
| 46 | |
| 47 | const files = await hashTree(dir); |
| 48 | const bundle = bundleHash(files); |
| 49 | await writeFile(join(dir, 'manifest.json'), |
| 50 | JSON.stringify({ algo: 'sha-256', build: 'abc123def456', bundle, files })); |
| 51 | |
| 52 | const log = join(base, 'log.jsonl'); |
| 53 | const entry = nextEntry([], { ts: '2026-07-18T00:00:00Z', build: 'abc123def456', bundle }); |
| 54 | await writeFile(log, JSON.stringify(entry) + '\n'); |
| 55 | return { base, dir, log, bundle, files }; |
| 56 | } |
| 57 | |
| 58 | const run = (args) => spawnSync(process.execPath, [CHECK, ...args], { encoding: 'utf8' }); |
| 59 | /// `check.mjs` colours its output, so a test that matches a whole line has to take the |
| 60 | /// escapes off first. Without this every line-anchored assertion below passes or fails on |
| 61 | /// the presence of a colour code rather than on what was printed. |
| 62 | const plain = (s) => String(s).replace(/\u001b\[[0-9;]*m/g, ''); |
| 63 | |
| 64 | // ── The happy path ───────────────────────────────────────────────── |
| 65 | { |
| 66 | const { base, dir, log } = await sealedFixture(); |
| 67 | const r = run(['--dir', dir, '--log', log]); |
| 68 | check('a sealed, unchanged build passes', r.status === 0, r.stdout.trim().split('\n').pop()); |
| 69 | await rm(base, { recursive: true, force: true }); |
| 70 | } |
| 71 | |
| 72 | // ── A changed file is caught and named ────────────────────────────── |
| 73 | { |
| 74 | const { base, dir, log } = await sealedFixture(); |
| 75 | await writeFile(join(dir, 'js', 'app.js'), 'console.log("TAMPERED");'); |
| 76 | const r = run(['--dir', dir, '--log', log]); |
| 77 | check('a single changed byte fails the check', r.status === 1); |
| 78 | check('the changed file is named', /^\s+· js\/app\.js$/m.test(plain(r.stdout)), 'output did not name js/app.js'); |
| 79 | await rm(base, { recursive: true, force: true }); |
| 80 | } |
| 81 | |
| 82 | // ── A file the manifest does not cover is named even when a hundred others changed ── |
| 83 | // |
| 84 | // The failure this is written from: on 2026-08-17 `--dir www` reported 111 problems and |
| 85 | // printed forty, off ONE flat list ordered changed-then-missing-then-unexpected. The |
| 86 | // hundred-odd changed files used up the forty, so the file the manifest named and no |
| 87 | // longer had, and the seven served files it did not cover — the whole social client — |
| 88 | // were counted and never shown. The reader saw a wall of "changed", which is the normal |
| 89 | // state of a tree between deploys. |
| 90 | // |
| 91 | // So the assertion is not "something was printed". It is that the two categories a wall |
| 92 | // of changed files can bury are BOTH named, with more changed files than the old cap. |
| 93 | { |
| 94 | const { base, dir, log } = await sealedFixture(); |
| 95 | for (let i = 0; i < 60; i++) { |
| 96 | await writeFile(join(dir, 'js', `bulk${i}.js`), `// ${i}\n`); |
| 97 | } |
| 98 | // Seal THAT, so the sixty are covered and can then be changed rather than unexpected. |
| 99 | const files = await hashTree(dir); |
| 100 | const bundle = bundleHash(files); |
| 101 | await writeFile(join(dir, 'manifest.json'), |
| 102 | JSON.stringify({ algo: 'sha-256', build: 'abc123def456', bundle, files })); |
| 103 | await writeFile(log, |
| 104 | JSON.stringify(nextEntry([], { ts: '2026-07-18T00:00:00Z', build: 'abc123def456', bundle })) |
| 105 | + '\n'); |
| 106 | for (let i = 0; i < 60; i++) { |
| 107 | await writeFile(join(dir, 'js', `bulk${i}.js`), `// ${i} TAMPERED\n`); |
| 108 | } |
| 109 | await writeFile(join(dir, 'js', 'smuggled.js'), 'console.log("not in the manifest");'); |
| 110 | await rm(join(dir, 'index.html')); |
| 111 | |
| 112 | const r = run(['--dir', dir, '--log', log]); |
| 113 | check('sixty changed files, one smuggled and one removed: it fails', r.status === 1); |
| 114 | check('the smuggled file is named, not buried under the changed ones', |
| 115 | /^\s+· js\/smuggled\.js$/m.test(plain(r.stdout)), 'output did not name js/smuggled.js'); |
| 116 | check('the file the manifest names and no longer has is named too', |
| 117 | /^\s+· index\.html$/m.test(plain(r.stdout)), 'output did not name index.html'); |
| 118 | check('and the changed files are counted rather than all listed', |
| 119 | /60 changed:/.test(plain(r.stdout)) && /… and 40 more/.test(plain(r.stdout)), |
| 120 | 'the changed group was not counted and trimmed'); |
| 121 | await rm(base, { recursive: true, force: true }); |
| 122 | } |
| 123 | |
| 124 | // ── An unsealed build (not in the log) is caught ──────────────────── |
| 125 | { |
| 126 | const { base, dir, log } = await sealedFixture(); |
| 127 | await writeFile(log, ''); // empty chain: nothing was ever sealed |
| 128 | const r = run(['--dir', dir, '--log', log]); |
| 129 | check('a build that was never sealed fails', r.status === 1); |
| 130 | check('the failure says it was never sealed', /never sealed|not in the transparency log|no transparency log/.test(r.stdout)); |
| 131 | await rm(base, { recursive: true, force: true }); |
| 132 | } |
| 133 | |
| 134 | // ── An excluded file does not move the fingerprint ────────────────── |
| 135 | { |
| 136 | const { base, dir, log } = await sealedFixture(); |
| 137 | await writeFile(join(dir, 'build.json'), JSON.stringify({ build: 'abc123def456', note: 'CHANGED NOTE' })); |
| 138 | const r = run(['--dir', dir, '--log', log]); |
| 139 | check('changing build.json alone still passes (it is excluded)', r.status === 0); |
| 140 | await rm(base, { recursive: true, force: true }); |
| 141 | } |
| 142 | |
| 143 | // ── The chain ─────────────────────────────────────────────────────── |
| 144 | { |
| 145 | let entries = []; |
| 146 | for (let i = 0; i < 4; i++) { |
| 147 | entries.push(nextEntry(entries, { ts: `t${i}`, build: `b${i}`, bundle: `bundle${i}`.padEnd(64, '0') })); |
| 148 | } |
| 149 | check('a well-formed chain verifies', verifyChain(entries).ok); |
| 150 | check('the first entry chains onto genesis', entries[0].prev === GENESIS_PREV); |
| 151 | |
| 152 | // Tamper a past entry's bundle without fixing the hashes after it. |
| 153 | const forged = entries.map(e => ({ ...e })); |
| 154 | forged[1].bundle = 'evil'.padEnd(64, '0'); |
| 155 | check('a rewritten past entry breaks the chain', !verifyChain(forged).ok); |
| 156 | |
| 157 | // Even fixing entry 1's own hash is not enough — entry 2 still chains on the old one. |
| 158 | forged[1].entry = entryHash(forged[1]); |
| 159 | check('re-hashing the forged entry alone does not repair the chain', !verifyChain(forged).ok); |
| 160 | } |
| 161 | |
| 162 | // ── Node and the browser compute the SAME fingerprint ─────────────── |
| 163 | { |
| 164 | const files = { 'a.js': 'aa'.repeat(32), 'b/c.wasm': 'bb'.repeat(32) }; |
| 165 | const nodeHash = bundleHash(files); |
| 166 | // The browser path: crypto.subtle.digest over the identical manifest text. |
| 167 | const buf = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode(manifestText(files))); |
| 168 | const subtleHash = [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, '0')).join(''); |
| 169 | check('Node crypto and Web Crypto agree on the bundle hash', nodeHash === subtleHash, |
| 170 | `${nodeHash.slice(0, 12)} vs ${subtleHash.slice(0, 12)}`); |
| 171 | |
| 172 | // And the browser-extension's own copy of the algorithm agrees too — its |
| 173 | // whole value is being an independent checker, so it must compute the |
| 174 | // identical fingerprint or it verifies nothing. |
| 175 | const extHash = await extFp.bundleHash(files); |
| 176 | check('the verify extension agrees on the bundle hash', nodeHash === extHash, |
| 177 | `${nodeHash.slice(0, 12)} vs ${extHash.slice(0, 12)}`); |
| 178 | } |
| 179 | |
| 180 | // ── diffFiles reports each kind of difference ─────────────────────── |
| 181 | { |
| 182 | const d = diffFiles({ a: '1', b: '2', c: '3' }, { a: '1', b: 'X', d: '9' }); |
| 183 | check('diffFiles finds changed/missing/unexpected', |
| 184 | d.changed.includes('b') && d.missing.includes('c') && d.unexpected.includes('d')); |
| 185 | } |
| 186 | |
| 187 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 188 | process.exit(bad.length ? 1 : 0); |