Oregami
Repositories/oxedyne/daimond

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
13import { mkdtemp, writeFile, mkdir, rm, readFile } from 'node:fs/promises';
14import { tmpdir } from 'node:os';
15import { join } from 'node:path';
16import { spawnSync } from 'node:child_process';
17import { webcrypto } from 'node:crypto';
18import { fileURLToPath } from 'node:url';
19import {
20 hashTree, bundleHash, manifestText, verifyChain, nextEntry, entryHash, diffFiles, GENESIS_PREV,
21} from './lib.mjs';
22import * as extFp from './ext/fingerprint.js';
23
24const CHECK = fileURLToPath(new URL('./check.mjs', import.meta.url));
25const ok = [], bad = [];
26const 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.
33async 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
58const 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.
62const 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
187console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
188process.exit(bad.length ? 1 : 0);