Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_ext.mjs

18.5 KiB, 1 run

created by r2519314175:403, 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_ext.mjs — the Daimond Verify extension, in a real browser.
2//
3// verify/ext/fingerprint.js is proven identical to verify/lib.mjs by
4// verify/verify.test.mjs. This loads the actual unpacked extension and drives
5// its service worker: its check.js runs a real fetch of a served manifest and
6// its files, and a real fetch of a transparency log, and returns the right verdict.
7//
8// WHAT THIS FILE ASSERTS ON, AND WHY IT MOVED.
9//
10// Until 2026-08-13 the extension was pointed at the development origin and the run
11// asserted that a sealed build verified green there — every served file matching
12// `www/manifest.json`. That is not a fact about the extension. It is a fact about
13// whether somebody has run `verify/manifest.mjs` since the last edit, and the answer
14// on any ordinary day of development is no: on the gate of that morning, twenty-four
15// files differed, and a central rebuild of `www/pkg` in the middle of the same session
16// moved the wasm again. A check that is red every day is a check people learn to
17// scroll past, and this one had been failing long enough to be listed as known.
18//
19// Resealing to make it green is worse than leaving it red. `verify/manifest.mjs`
20// appends to `verify/transparency.jsonl`, the public record of what has been SHIPPED;
21// sealing a working tree publishes a release that was never released. `--no-log`
22// avoids that and turns the seal check red instead. And either way the next edit
23// undoes it. Sealing a release is `dev/repro-check.sh`'s job, which builds from a
24// clean clone — the same division of labour `dev/gate.sh` states for the bundle it
25// borrows.
26//
27// So the assertions moved onto a FIXTURE BUNDLE this file builds, seals and serves
28// itself, where every input is controlled and each verdict can be forced:
29//
30// green a sealed fixture, in a chain that carries it -> ok
31// tampered one file changed AFTER sealing -> the file check fails, by name
32// unsealed the same fixture against an empty log -> the seal check fails
33//
34// The tampered case is the one the old shape never had: the file check had no red
35// case at all, so nothing here proved it could report a mismatch rather than merely
36// suffer one.
37//
38// The DEVELOPMENT origin keeps the two questions that are honestly about it, and they
39// are different questions:
40//
41// * Are the served bytes the bytes on disk? This is the tamper question at a live
42// origin, and it is independent of the seal — the seal says what was published,
43// this says whether anything sits between the file and the browser rewriting it.
44// It does not go red because somebody edited a file.
45// * Does the served tree still match its manifest? REPORTED, not asserted, and
46// named as what it is: a working tree that has moved on from its seal, with the
47// count and the first few files, and a reminder that a deploy must reseal.
48//
49// Between them, a served file that differs from a manifest CLAIMING to cover it is
50// still caught: by the fixture, where the manifest is current by construction, and at
51// the live origin whenever the wire disagrees with the disk.
52//
53// EACH NEW CHECK IS PROVED AGAINST A BROKEN INSTRUMENT FIRST:
54//
55// node dev/verify_ext.mjs --break blind # the extension stops noticing a changed file
56// node dev/verify_ext.mjs --break wire # the wire and the disk disagree on one file
57// node dev/verify_ext.mjs # and then, clean
58//
59// The log normally lives on GitHub (an origin the site cannot control). Here it is
60// served locally (serve.mjs) and passed in, so the check runs offline against the same
61// chain. Needs dev/serve.mjs (DAIMOND_PORT, default 8777). Headed: MV3 service workers
62// need a real browser, so run it under xvfb.
63import path from 'node:path';
64import os from 'node:os';
65import fs from 'node:fs';
66import { pathToFileURL } from 'node:url';
67
68const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
69const { chromium } = await import(pathToFileURL(PW).href);
70const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
71import { fileURLToPath } from 'node:url';
72const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home
73import { hashTree, bundleHash, coveredFiles, sha256, parseLog, nextEntry } from '../verify/lib.mjs';
74// Chromium's ozone platform is chosen by autodetection and prefers Wayland whenever
75// `WAYLAND_DISPLAY` is set -- which it is in every rc session on argonaut -- so a headed
76// run under `xvfb-run` still went to the compositor and opened a window on the owner's
77// desktop. Importing this strips the two variables from `process.env`, which is all a
78// launcher that spreads `process.env` needs. See dev/display.mjs.
79import './display.mjs';
80const EXT_SRC = `${ROOT}/verify/ext`;
81// Not /tmp -- see the SCRATCH note in harness.mjs. Kept inline rather than
82// imported, so this stays standalone and does not load the harness.
83const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
84const PROFILE = path.join(SCRATCH, 'verify-ext');
85
86const BREAK = (() => {
87 const i = process.argv.indexOf('--break');
88 return i > 0 ? String(process.argv[i + 1] || '') : '';
89})();
90const die = (why) => { console.error('ABORT: ' + why); process.exit(2); };
91if (BREAK && !['blind', 'wire'].includes(BREAK)) die(`no break called "${BREAK}"`);
92
93/// A copy of the verify extension that may reach the port this run is using.
94///
95/// THE FAILURE THIS FIXES, which was called something else for two sessions.
96/// `verify/ext/manifest.json` grants `localhost:8777` — the default dev port —
97/// and nothing else. Whenever the suite runs in a numbered world (`dev/world.sh`,
98/// 8781 and up, which is any run with more than one agent about) the extension
99/// has no permission for the origin under test, so the checker's `fetch` of
100/// `manifest.json` is refused and it reports **"this site served no
101/// manifest.json"**. That reads as a broken server, and the server is fine.
102///
103/// It was recorded as "environmental: needs a headed browser, Missing X server".
104/// It does need a display — `xvfb-run` supplies one and the service worker starts
105/// — but the display was never the whole story, and the manifest message sent two
106/// sessions looking at the seal instead of at the permissions.
107///
108/// The shipped file is not touched: a released extension holding permissions on a
109/// developer's machine is exactly what `dev/extdev.mjs` exists to prevent, and the
110/// same rule applies here. The copy is rebuilt every run so it cannot go stale.
111///
112/// # Arguments
113/// * `port` - The port `dev/serve.mjs` is bound to for this run.
114async function extForPort(port) {
115 const out = path.join(SCRATCH, `verify-ext-build-${port}`);
116 fs.rmSync(out, { recursive: true, force: true });
117 fs.mkdirSync(out, { recursive: true });
118 for (const name of fs.readdirSync(EXT_SRC)) {
119 if (name === 'manifest.json') continue;
120 fs.cpSync(path.join(EXT_SRC, name), path.join(out, name), { recursive: true });
121 }
122 // The port is in the SOURCE as well as the manifest: `background.js` decides
123 // which navigations to check with its own `MATCH` list, which names 8777. The
124 // manifest grants permission to fetch; this decides whether anything is
125 // fetched at all, so without it the extension has the run of the origin and
126 // simply never looks at it -- and the badge check reads "no verdict recorded".
127 //
128 // Rewritten here rather than made configurable in the shipped file: which
129 // origins an integrity checker will vouch for is precisely the thing that must
130 // not be settable from outside it.
131 if (port !== 8777) {
132 const bg = path.join(out, 'background.js');
133 const before = fs.readFileSync(bg, 'utf8');
134 const after = before.replace(/:8777/g, ':' + port);
135 if (after === before) {
136 throw new Error('verify/ext/background.js no longer names :8777, so the dev copy '
137 + 'cannot be pointed at port ' + port + '. Find what replaced MATCH and patch that.');
138 }
139 fs.writeFileSync(bg, after);
140 }
141 // A checker that no longer compares the files, so the tampered fixture below
142 // comes back green and the check that reads it goes red. The copy is damaged,
143 // never `verify/ext/`: an installed checker with its comparison removed is the
144 // one thing this whole directory exists to prevent.
145 if (BREAK === 'blind') {
146 const chk = path.join(out, 'check.js');
147 const src = fs.readFileSync(chk, 'utf8');
148 const hurt = src.replace("add('every served file matches the manifest', bad.length === 0,",
149 "add('every served file matches the manifest', true,");
150 if (hurt === src) die('the blind break did not reach the file comparison in check.js');
151 fs.writeFileSync(chk, hurt);
152 }
153 const m = JSON.parse(fs.readFileSync(path.join(EXT_SRC, 'manifest.json'), 'utf8'));
154 // Added, not substituted: the shipped origins stay, so the copy is the shipped
155 // extension plus this port rather than a different extension that happens to
156 // pass. Both spellings of loopback, because Chrome matches the origin string.
157 m.host_permissions = (m.host_permissions || [])
158 .concat([`http://127.0.0.1:${port}/*`, `http://localhost:${port}/*`]);
159 fs.writeFileSync(path.join(out, 'manifest.json'), JSON.stringify(m, null, '\t') + '\n');
160 return out;
161}
162const PORT = Number(process.env.DAIMOND_PORT || 8777);
163const EXT = await extForPort(PORT);
164fs.mkdirSync(PROFILE, { recursive: true });
165// The world's dev server -- see dev/world.sh. Kept inline rather than imported,
166// so this stays standalone and does not load the harness.
167const APP = process.env.DAIMOND_APP || `http://localhost:${PORT}`;
168
169const ok = [], bad = [];
170const check = (name, pass, detail) => { (pass ? ok : bad).push(name); console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); };
171const say = (line) => console.log(' · ' + line);
172
173// ── The fixture bundle ───────────────────────────────────────────────────
174//
175// A bundle small enough to read, sealed by the same `verify/lib.mjs` the real one
176// is sealed by, and served out of `www/` so the extension can reach it on the
177// origin it is permitted. `_vtest_` because `verify/manifest.mjs` REFUSES to seal
178// anything by that name: a run that dies before its cleanup cannot leak a
179// synthetic manifest into a release.
180const FIX = `${ROOT}/www/_vtest_fixture`;
181const FIX_BAD = `${ROOT}/www/_vtest_fixture_bad`;
182const FIX_FILE = { 'index.html': '<!doctype html><title>fixture</title>\n', 'js/app.js': 'export const n = 1;\n', 'css/app.css': ':root { --n: 1; }\n' };
183
184/// Write the fixture and seal it, returning its bundle hash.
185async function sealFixture() {
186 fs.rmSync(FIX, { recursive: true, force: true });
187 fs.rmSync(FIX_BAD, { recursive: true, force: true });
188 for (const [rel, body] of Object.entries(FIX_FILE)) {
189 const p = path.join(FIX, rel);
190 fs.mkdirSync(path.dirname(p), { recursive: true });
191 fs.writeFileSync(p, body);
192 }
193 const files = await hashTree(FIX);
194 const bundle = bundleHash(files);
195 fs.writeFileSync(path.join(FIX, 'manifest.json'),
196 JSON.stringify({ algo: 'sha-256', build: 'fixture', bundle, files }, null, 0) + '\n');
197 // The tampered copy carries the SAME manifest, so the only thing wrong with it
198 // is a file, and the seal check stays green. A fixture that failed both checks
199 // would not tell us which one did the noticing.
200 fs.cpSync(FIX, FIX_BAD, { recursive: true });
201 fs.writeFileSync(path.join(FIX_BAD, 'js', 'app.js'), 'export const n = 2;\n');
202 return bundle;
203}
204const FIX_BUNDLE = await sealFixture();
205
206// Three logs, served from www/ so the extension can fetch them from the app origin:
207// a chain that seals the fixture, the app's real chain, and an empty one that seals
208// nothing.
209const fixLog = JSON.stringify(nextEntry(parseLog(''), {
210 ts: '2026-01-01T00:00:00.000Z', build: 'fixture', bundle: FIX_BUNDLE, note: 'verify_ext fixture',
211})) + '\n';
212fs.writeFileSync(`${ROOT}/www/_vtest_fixlog.jsonl`, fixLog);
213fs.writeFileSync(`${ROOT}/www/_vtest_log.jsonl`, fs.readFileSync(`${ROOT}/verify/transparency.jsonl`, 'utf8'));
214fs.writeFileSync(`${ROOT}/www/_vtest_empty.jsonl`, '');
215const cleanup = () => {
216 for (const f of ['_vtest_fixlog.jsonl', '_vtest_log.jsonl', '_vtest_empty.jsonl']) {
217 try { fs.rmSync(`${ROOT}/www/${f}`); } catch (e) {}
218 }
219 for (const d of [FIX, FIX_BAD]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch (e) {} }
220};
221
222fs.rmSync(PROFILE, { recursive: true, force: true });
223fs.mkdirSync(PROFILE, { recursive: true });
224
225const b = await chromium.launchPersistentContext(PROFILE, {
226 executablePath: CHROME, headless: false,
227 args: ['--no-sandbox', '--disable-dev-shm-usage', `--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`],
228});
229async function waitSW() { for (let i = 0; i < 80 && !b.serviceWorkers().length; i++) await new Promise(r => setTimeout(r, 100)); return b.serviceWorkers()[0]; }
230
231/// One named check out of a verdict, or `undefined` if the checker never ran it.
232const named = (v, re) => v && v.checks.find(c => re.test(c.name));
233
234try {
235 const sw = await waitSW();
236 check('the extension service worker started', !!sw);
237 check('it exposes its checker to the worker scope', await sw.evaluate(() => typeof self.verifyOrigin === 'function'));
238
239 const run = (origin, log) => sw.evaluate(
240 async (a) => await self.verifyOrigin(a.origin, a.log), { origin, log });
241
242 // 1. A sealed fixture, checked against a chain that carries it → green.
243 const v1 = await run(`${APP}/_vtest_fixture`, `${APP}/_vtest_fixlog.jsonl`);
244 check('a sealed bundle verifies green', v1 && v1.ok === true,
245 v1 && v1.checks.map(c => c.name + '=' + c.ok).join(', '));
246 check('the "sealed in the public log" check passed',
247 !!(named(v1, /sealed in the public log/) || {}).ok);
248 check('every served file matched the manifest',
249 !!(named(v1, /every served file/) || {}).ok);
250
251 // 2. The SAME manifest, one file changed after it was sealed → the file check
252 // fails and says which. This is the case the old shape of this file never
253 // ran: nothing proved the comparison could report a mismatch.
254 const v2 = await run(`${APP}/_vtest_fixture_bad`, `${APP}/_vtest_fixlog.jsonl`);
255 const fileChk = named(v2, /every served file/);
256 check('a file changed after sealing is caught', v2 && v2.failed === true && fileChk && fileChk.ok === false,
257 fileChk ? fileChk.detail : 'the checker ran no file comparison at all');
258 check('and it names the file that differs', !!fileChk && /js\/app\.js/.test(fileChk.detail || ''),
259 fileChk ? fileChk.detail : '');
260 check('the seal is untouched by it — the failure is not blamed on the log',
261 !!(named(v2, /sealed in the public log/) || {}).ok);
262
263 // 3. The same fixture against an EMPTY log → red, and for the seal.
264 const v3 = await run(`${APP}/_vtest_fixture`, `${APP}/_vtest_empty.jsonl`);
265 check('a build absent from the log fails', v3 && v3.failed === true);
266 check('the failure is the seal check', !!(named(v3, /sealed in the public log/) && named(v3, /sealed in the public log/).ok === false));
267
268 // ── The development origin ───────────────────────────────────────
269 //
270 // 4. THE TAMPER QUESTION, asked of the live origin: are the bytes the browser
271 // is given the bytes on disk? Nothing between `www/` and the socket may
272 // rewrite a file. This is true of a working tree mid-edit, so it does not
273 // go red because somebody saved a file — and it is the one thing at this
274 // origin that a tampered delivery would break.
275 const rels = (await coveredFiles(`${ROOT}/www`)).filter(r => !r.startsWith('_vtest_'));
276 const wrong = [];
277 for (const rel of rels) {
278 let served;
279 try {
280 const res = await fetch(`${APP}/${rel}`, { cache: 'no-store' });
281 served = sha256(Buffer.from(await res.arrayBuffer()));
282 } catch (e) { wrong.push(`${rel} (not served)`); continue; }
283 // `--break wire` reads a DIFFERENT file off the disk for one path, so the
284 // two sides of the comparison genuinely disagree — which is what a rewrite
285 // on the way out looks like from here, and proves the naming as well as
286 // the comparison.
287 const from = (BREAK === 'wire' && rel === 'index.html') ? 'manifest.json' : rel;
288 const disk = sha256(fs.readFileSync(path.join(ROOT, 'www', from)));
289 if (served !== disk) wrong.push(rel);
290 }
291 check('every served file is the file on disk', wrong.length === 0,
292 wrong.length ? `${wrong.length} rewritten on the way out: ${wrong.slice(0, 4).join(', ')}`
293 : `${rels.length} files`);
294
295 // 5. And the seal, REPORTED. A working tree that has moved on from its manifest
296 // is the expected state between deploys; a tree that matches it is worth
297 // saying out loud too, because it means this checkout is the sealed one.
298 const vApp = await run(APP, `${APP}/_vtest_log.jsonl`);
299 const appFiles = named(vApp, /every served file/);
300 if (appFiles && appFiles.ok) {
301 say('the served tree still matches www/manifest.json: this checkout is the sealed build');
302 } else {
303 say(`the served tree has moved on from www/manifest.json (${(appFiles || {}).detail || 'no comparison ran'}).`);
304 say('That is the expected state of a working tree. `node verify/manifest.mjs` seals a');
305 say('deploy; it is NOT run to make this line go away.');
306 }
307
308 // 6. The badge path: a navigation records a verdict for the tab, and it is the
309 // verdict the checker gives for that origin. Asserted as AGREEMENT rather
310 // than as green: on an unsealed working tree the honest verdict is red, and
311 // a check demanding green here would be demanding a reseal again.
312 await sw.evaluate((url) => chrome.storage.local.set({ logUrl: url }), `${APP}/_vtest_log.jsonl`);
313 const page = await b.newPage();
314 await page.goto(APP + '/', { waitUntil: 'domcontentloaded' }).catch(() => {});
315 let recorded = null;
316 for (let i = 0; i < 60; i++) {
317 const vs = await sw.evaluate(() => self.__verdicts);
318 const vals = Object.values(vs || {});
319 if (vals.length) { recorded = vals[0]; break; }
320 await new Promise(r => setTimeout(r, 200));
321 }
322 check('a real navigation records a verdict for the tab', !!recorded,
323 recorded ? 'ok=' + recorded.ok : 'no verdict recorded');
324 check('and the recorded verdict is the one the checker gives for that origin',
325 !!recorded && !!vApp && recorded.ok === vApp.ok && recorded.bundle === vApp.bundle,
326 recorded && vApp ? `tab ok=${recorded.ok}, checker ok=${vApp.ok}` : '');
327} finally {
328 await b.close();
329 cleanup();
330}
331
332console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed');
333if (BREAK) {
334 if (bad.length) { console.log('the break was caught, as it should be'); process.exit(0); }
335 console.log('THE BREAK WAS NOT CAUGHT: this check proves nothing');
336 process.exit(1);
337}
338process.exit(bad.length ? 1 : 0);