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. |
| 63 | import path from 'node:path'; |
| 64 | import os from 'node:os'; |
| 65 | import fs from 'node:fs'; |
| 66 | import { pathToFileURL } from 'node:url'; |
| 67 | |
| 68 | const PW = path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 69 | const { chromium } = await import(pathToFileURL(PW).href); |
| 70 | const CHROME = `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 71 | import { fileURLToPath } from 'node:url'; |
| 72 | const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); // this checkout, not one developer's home |
| 73 | import { 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. |
| 79 | import './display.mjs'; |
| 80 | const 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. |
| 83 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 84 | const PROFILE = path.join(SCRATCH, 'verify-ext'); |
| 85 | |
| 86 | const BREAK = (() => { |
| 87 | const i = process.argv.indexOf('--break'); |
| 88 | return i > 0 ? String(process.argv[i + 1] || '') : ''; |
| 89 | })(); |
| 90 | const die = (why) => { console.error('ABORT: ' + why); process.exit(2); }; |
| 91 | if (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. |
| 114 | async 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 | } |
| 162 | const PORT = Number(process.env.DAIMOND_PORT || 8777); |
| 163 | const EXT = await extForPort(PORT); |
| 164 | fs.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. |
| 167 | const APP = process.env.DAIMOND_APP || `http://localhost:${PORT}`; |
| 168 | |
| 169 | const ok = [], bad = []; |
| 170 | const check = (name, pass, detail) => { (pass ? ok : bad).push(name); console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); }; |
| 171 | const 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. |
| 180 | const FIX = `${ROOT}/www/_vtest_fixture`; |
| 181 | const FIX_BAD = `${ROOT}/www/_vtest_fixture_bad`; |
| 182 | const 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. |
| 185 | async 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 | } |
| 204 | const 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. |
| 209 | const fixLog = JSON.stringify(nextEntry(parseLog(''), { |
| 210 | ts: '2026-01-01T00:00:00.000Z', build: 'fixture', bundle: FIX_BUNDLE, note: 'verify_ext fixture', |
| 211 | })) + '\n'; |
| 212 | fs.writeFileSync(`${ROOT}/www/_vtest_fixlog.jsonl`, fixLog); |
| 213 | fs.writeFileSync(`${ROOT}/www/_vtest_log.jsonl`, fs.readFileSync(`${ROOT}/verify/transparency.jsonl`, 'utf8')); |
| 214 | fs.writeFileSync(`${ROOT}/www/_vtest_empty.jsonl`, ''); |
| 215 | const 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 | |
| 222 | fs.rmSync(PROFILE, { recursive: true, force: true }); |
| 223 | fs.mkdirSync(PROFILE, { recursive: true }); |
| 224 | |
| 225 | const 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 | }); |
| 229 | async 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. |
| 232 | const named = (v, re) => v && v.checks.find(c => re.test(c.name)); |
| 233 | |
| 234 | try { |
| 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 | |
| 332 | console.log('\n' + ok.length + ' ok, ' + bad.length + ' failed'); |
| 333 | if (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 | } |
| 338 | process.exit(bad.length ? 1 : 0); |