From 769f6a52893c92c081bddf60980a6ef97216c210 Mon Sep 17 00:00:00 2001 From: clawbot Date: Sun, 23 Aug 2026 16:13:22 +0200 Subject: [PATCH] release: produce a versioned per-browser artifact and pin the Chrome extension id (closes #310) manifest/chrome.json now carries a fixed public key, so the extension id and the chrome.storage.local partition holding the wallet stay stable across checkout moves and re-clones instead of being derived from the absolute path. A release entrypoint produces a self-contained versioned artifact per browser, including the files that sit at dist/ root outside both browser directories. One version source of truth, enforced: the build fails naming the culprit when the two manifests and package.json disagree, and BUILD_COMMIT now marks a dirty tree as dirty. Firefox ships an unsigned XPI; the README states that release Firefox and ESR refuse it, that Developer Edition or Unbranded is required, and that Remove is irreversible except from the recovery phrase, which is asserted by test. --- .dockerignore | 1 + .gitignore | 3 + .prettierignore | 1 + Makefile | 7 +- README.md | 153 +++++++++++- TODO.md | 34 +++ build.js | 60 +++-- manifest/chrome.json | 1 + script/lib/package.js | 294 ++++++++++++++++++++++ script/lib/version.js | 74 ++++++ script/lib/zip.js | 274 +++++++++++++++++++++ script/package | 38 +++ script/test-e2e | 16 ++ script/test-e2e-firefox | 13 + tests/e2e/firefox/Dockerfile | 7 +- tests/e2e/firefox/driver.js | 61 +++-- tests/e2e/firefox/reinstall.js | 438 +++++++++++++++++++++++++++++++++ tests/e2e/storagePartition.js | 303 +++++++++++++++++++++++ tests/extensionId.test.js | 119 +++++++++ tests/packaging.test.js | 190 ++++++++++++++ tests/version.test.js | 97 ++++++++ 21 files changed, 2142 insertions(+), 42 deletions(-) create mode 100644 script/lib/package.js create mode 100644 script/lib/version.js create mode 100644 script/lib/zip.js create mode 100755 script/package create mode 100644 tests/e2e/firefox/reinstall.js create mode 100644 tests/e2e/storagePartition.js create mode 100644 tests/extensionId.test.js create mode 100644 tests/packaging.test.js create mode 100644 tests/version.test.js diff --git a/.dockerignore b/.dockerignore index 12efda1..cdbac77 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ node_modules .DS_Store dist +release diff --git a/.gitignore b/.gitignore index fc1b418..0420c87 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ node_modules/ # Build output dist/ +# Release artifacts (make package). Derived from dist/, never committed. +release/ + # Yarn .yarn-integrity package-lock.json diff --git a/.prettierignore b/.prettierignore index 020918d..c8d2800 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ node_modules/ yarn.lock dist/ +release/ .claude/ diff --git a/Makefile b/Makefile index 6be020f..881f8e6 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug vendor-blocklist clean dev +.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug package vendor-blocklist clean dev # Standard targets are thin shims; the implementations live in script/ # per the scripts-to-rule-them-all pattern (see the Entrypoints section @@ -41,6 +41,9 @@ check: check-censored: @script/check-censored +package: + @script/package + docker: @script/docker @@ -102,7 +105,7 @@ vendor-blocklist: @script/vendor-blocklist clean: - @rm -rf dist/ + @rm -rf dist/ release/ dev: @echo "Building in watch mode..." diff --git a/README.md b/README.md index 10b7fdf..6b09000 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,107 @@ Load the extension: - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click "Load unpacked", and select the `dist/chrome/` directory. - **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - Temporary Add-on", and select `dist/firefox/manifest.json`. + Temporary Add-on", and select `dist/firefox/manifest.json`. Read + [Installing on Firefox](#installing-on-firefox) before relying on this: a + temporary add-on does not survive closing the browser. + +### Release Artifacts + +`make package` runs `make build` and then writes one self-contained, versioned +archive per browser into `release/`, plus a `SHA256SUMS` for them: + +```bash +make package +``` + +``` +release/autistmask-chrome-.zip +release/autistmask-firefox-.xpi +release/SHA256SUMS +``` + +Nothing is published by this. Tagging, CRX packing and any upload are +outward-facing acts and are the owner's alone. + +The archives are deterministic — entries sorted, timestamps fixed, compression +level fixed — so two builds of one commit produce byte-identical files and the +recorded digest is a property of the input rather than of the clock. + +**Self-containment is checked, not assumed.** `build.js` writes the compiled +Tailwind output to `dist/styles.css` at the `dist/` ROOT, outside both browser +directories, and copies it into each of them as `src/popup/styles.css`; a naive +`zip -r dist/chrome` is therefore correct only by accident. So the packager +resolves every path referenced by the manifest and by every HTML document in the +archive, requires each to be inside the archive, and fails on any reference that +climbs out of the extension root. Files left at the `dist/` root are printed as +deliberately not shipped rather than dropped by a glob. The archive is then read +back off disk and compared member by member against the directory it was built +from: an archive nobody opened is a claim, not an artifact. + +There is one version, and the build enforces it. `package.json`, +`manifest/chrome.json` and `manifest/firefox.json` each declare one and none is +derived from another — the manifests are copied to `dist/` verbatim, which is +what `tests/manifest.test.js` asserts — so `script/lib/version.js` requires all +three to agree and **fails the build when they do not**, naming each file and +what it said. `tests/version.test.js` covers that rule in `make check`. + +### Installing on Chrome + +`manifest/chrome.json` carries a fixed `key`: the public half of an RSA keypair, +base64-encoded DER. It exists for one reason. An unpacked Chrome extension with +no `key` gets an extension id derived from the **absolute path it was loaded +from**, and `chrome.storage.local` — which is where the wallet lives — is +partitioned by that id. Move the checkout, re-clone it, or load a second copy +from anywhere else, and the extension comes up on a fresh, empty storage +partition: the wallet is simply gone, with no error and nothing in the UI to say +so. With the `key` in place the id is derived from the key instead, and follows +the extension wherever it is loaded from. That id is +`gipbhkogfopeahplcjhipkgpcimdpkip`, pinned in `tests/extensionId.test.js` and +observed against a real Chrome in `tests/e2e/storagePartition.js`. + +**Changing `key` changes the extension id, and orphans every wallet stored under +the old one.** It is a migration, not an edit. + +The **private** half is a credential. It is not in this repository, no target +generates one into the working tree, `*.pem` and `*.key` are gitignored, and +`tests/extensionId.test.js` fails if such a file is ever committed. It is not +needed to build, load or test anything here — it signs a CRX, and this repo does +not pack one. A packer would take it from outside the repo, e.g. +`chrome --pack-extension=dist/chrome --pack-extension-key=`. + +### Installing on Firefox + +**The XPI this repo produces is UNSIGNED, and release Firefox and Firefox ESR +will refuse to install it.** Those builds enforce add-on signing with no working +override — `xpinstall.signatures.required` does nothing on them — so a permanent +install needs Firefox Developer Edition, Nightly, or an Unbranded build, with +`xpinstall.signatures.required` set to `false` in `about:config`. + +Signing means submitting to AMO (self-distribution is enough, and does not +require listing), which needs credentials this repository does not have and is +the owner's decision. + +The other route is `about:debugging#/runtime/this-firefox` -> "Load Temporary +Add-on", which works on every Firefox including release. **A temporary add-on is +unloaded when Firefox exits**, so daily use means re-adding it by hand on every +browser start. + +**The wallet survives the restart.** `manifest/firefox.json` declares a fixed +`browser_specific_settings.gecko.id`, and Firefox keys the extension's storage +area on that id rather than on the install, so adding the temporary add-on again +in the same profile finds the vault where it left it. That is asserted, not +assumed: `tests/e2e/firefox/reinstall.js` installs the packaged XPI in a real +Firefox, creates a wallet through the UI, quits the browser, starts it again on +the same profile, adds the add-on again, and decrypts the vault with the +original password back to the original recovery phrase. The `moz-extension://` +origin the popup is served from is _not_ stable across installs and does not +need to be — nothing durable is keyed on it. + +**Removing the add-on does not.** An explicit uninstall — about:addons "Remove" +— destroys the extension's storage, and the vault with it. That is ordinary, +correct browser behaviour and it is observed in the same suite, but for a wallet +it is worth saying out loud: **on Firefox, Remove is irreversible, and the +recovery phrase is the only way back.** ### Debug Builds @@ -146,6 +246,11 @@ provide: fails anywhere else. Part of `make check`, which inspects `dist/` when there is one and says loudly when there is not; `make build` re-runs it with `--require-dist`, so a build artifact is always covered +- `script/package` — produce the release artifacts: `make build` first, so the + archives can only ever be made from a `dist/` that has been verified against + that build's own receipt as a RELEASE build, then one self-contained, + versioned archive per browser into `release/` (see + [Release Artifacts](#release-artifacts)). It packages and does not publish - `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from its upstream, pinned to a commit and to the sha256 of the bytes that commit serves. Run deliberately, never as part of a build: the output is committed @@ -198,7 +303,7 @@ The Makefile shims to those. It also carries a few targets that have no - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a debug build, and keeping its `dist/` on failure (see [Debug Builds](#debug-builds)) -- `make clean` — remove `dist/` +- `make clean` — remove `dist/` and `release/` - `make dev` — build in watch mode ## End-to-End Tests @@ -334,6 +439,23 @@ class before a browser is involved, so this suite is no longer the only thing standing between it and a release — but a static rule only sees identifiers, and the runtime errors this suite catches are broader than one rule. +`make test-e2e` then runs a second program in the same image, +`tests/e2e/storagePartition.js`, which answers where `chrome.storage.local` +lives. It loads the built extension from one temporary directory in a fresh +profile, writes a sentinel key the extension itself never touches, closes the +browser, and loads a **copy at a different path into the same profile** — then +does it again with `key` stripped out of the manifest, and reports what each +pair actually did rather than what it expected. It needs its own browser +sessions, four of them, because the whole subject is what happens ACROSS loads. + +The observed behaviour, on the pinned Chromium: **with `key`, both paths get the +same extension id and the second load reads the first load's storage. Without +`key`, the two paths get different ids and the second load sees an empty +partition** — the wallet, silently gone. Loading both keyed copies at once in +one profile yields a single extension id, not two: Chrome does not load a second +copy of an id it already has. The assertions are annotated as observations, so a +Chrome that ever changes this fails the run instead of passing it. + ### Firefox (`make test-e2e-firefox`) `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a @@ -351,6 +473,33 @@ runner rather than believed from the extension, and the stub node has to answer `eth_sendRawTransaction` with the hash `ethers` computes for the artifact it sent, or `provider.broadcastTransaction()` refuses the answer. +`make test-e2e-firefox` then runs a second program in the same image, +`tests/e2e/firefox/reinstall.js`, and it installs the **packaged XPI** rather +than the unpacked directory — the only place a real Firefox is asked to load the +artifact that would actually be handed to someone. It runs two browsers over one +profile, because it asks two questions that have different answers: + +- **Restart.** Install, create a wallet through the UI, record the vault, quit + the browser, start it again on the same profile, add the add-on again. The + extension must not come up as a fresh install; the vault, xpub and first + address must be unchanged; and the vault must still **decrypt** with the + original password to the original recovery phrase, through the real Show + Recovery Phrase screen. "The ciphertext is still in storage" and "the wallet + still works" are different claims and only the second one is worth anything. + This is what a user does every day, because a temporary add-on is unloaded + when Firefox exits. +- **Removal.** Then, in the same browser, an explicit uninstall and a fresh + install. Observed: **Firefox destroys the extension's storage on uninstall**, + so the vault is gone. Recorded as an assertion, so a Firefox that ever changes + it fails the run, and stated in + [Installing on Firefox](#installing-on-firefox) because for a wallet it means + Remove is irreversible except from the recovery phrase. + +It also pins down something worth knowing about the harness: the +`moz-extension://` uuid the popup is served from is not stable across installs, +and navigating to a stale one does not fail — it hangs. The program reads the +live uuid out of `extensions.webextensions.uuids` after every install. + Both suites build their own image, each with the repo and a fresh extension build baked in; what differs is the base. The Chrome image layers those on top of a published Playwright image, whereas this one is assembled from a `node` diff --git a/TODO.md b/TODO.md index d7f171d..9f3d1b7 100644 --- a/TODO.md +++ b/TODO.md @@ -84,6 +84,40 @@ but the review is broader than any of them. lists (`src/shared/transactions.js`) keep the unfloored rule, which is out of scope by the issue's definition of done. `README.md`'s Display Consistency section records the exception. +- 2026-08-23: The extension can be installed once and kept + ([#310](https://git.eeqj.de/sneak/AutistMask/issues/310)). There was no + packaging target anywhere, no artifact, and `manifest/chrome.json` carried no + `key` — so an unpacked Chrome load derived its extension id, and therefore its + `chrome.storage.local` partition, from the absolute checkout path: moving or + re-cloning the checkout presented an empty wallet with no error. The manifest + now carries a fixed `key` (public half only; the private half is a credential + and is not in this repo, and no target generates one into the tree), pinning + the id to `gipbhkogfopeahplcjhipkgpcimdpkip`. `make package` produces + `release/autistmask-chrome-.zip` and + `release/autistmask-firefox-.xpi` plus `SHA256SUMS`, + deterministically and via `make build` so the archives can only be made from a + `dist/` already verified against that build's receipt as a release build; + every path the manifests and the popup HTML reference is resolved and required + to be inside the archive, and the archive is read back off disk and compared + member by member — `dist/styles.css` sits at the `dist/` root outside both + browser directories and is reported as deliberately not shipped rather than + dropped by a glob. One version: `script/lib/version.js` fails the build when + `package.json` and the two manifests disagree, instead of reading from one of + them. `BUILD_COMMIT` now carries `-dirty` when the working tree does not match + `HEAD` (and `-unknown` when git cannot say), while the full hash behind the + About screen's commit link stays clean so the link still resolves. Two real + browser observations back it: `tests/e2e/storagePartition.js` loads the + extension from two different paths in one Chrome profile with and without + `key` and records what each does, and `tests/e2e/firefox/reinstall.js` + installs the packaged XPI in a real Firefox, creates a wallet, restarts the + browser on the same profile, adds the add-on again and decrypts the vault back + to the original recovery phrase — and then observes that an explicit uninstall + DESTROYS that storage, which is correct browser behaviour but means Remove is + irreversible for a wallet, now stated in README.md. Deliberately not done: AMO + signing, CRX packing, tagging and any upload — the Firefox artifact is + UNSIGNED and README.md now states that release Firefox and ESR refuse it, that + Developer Edition or an Unbranded build is required, and that a temporary + add-on does not survive a browser restart. - 2026-08-20: A second extension page can no longer silently delete a wallet ([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote the entire state blob, and every extension page — the toolbar popup, a dApp diff --git a/build.js b/build.js index 2eae29d..5cefa60 100644 --- a/build.js +++ b/build.js @@ -3,6 +3,7 @@ const path = require("path"); const crypto = require("crypto"); const { execSync } = require("child_process"); const esbuild = require("esbuild"); +const { resolveVersion } = require("./script/lib/version"); const DIST = path.join(__dirname, "dist"); const DIST_CHROME = path.join(DIST, "chrome"); @@ -160,28 +161,53 @@ function isDebugBuild() { return process.env.AUTISTMASK_DEBUG === "1"; } +// A short git output, or null when git cannot answer. Distinguishing "git said +// nothing" from "git could not be asked" matters below: a working tree whose +// state is unknown must not be stamped as clean. +function git(args) { + try { + return execSync(`git ${args}`, { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + // not a git repo, or git not available + return null; + } +} + +// The working-tree state, as a suffix for the displayed commit: "" when the +// tree matches HEAD, "-dirty" when it does not, "-unknown" when git answered +// the hash but not the status. Without this a build from a modified tree +// stamped a clean hash, so the About screen named a commit whose contents were +// not what was running — the one thing that stamp exists to establish. +// +// git status --porcelain honours .gitignore, so dist/ and node_modules/ do not +// make every build dirty; an untracked file that is NOT ignored does, and +// correctly: it may well be in the bundle. +function worktreeSuffix() { + const status = git("status --porcelain"); + if (status === null) return "-unknown"; + return status === "" ? "" : "-dirty"; +} + function getBuildInfo() { const pkg = JSON.parse( fs.readFileSync(path.join(__dirname, "package.json"), "utf8"), ); - let commitHash = "unknown"; - try { - commitHash = execSync("git rev-parse --short HEAD", { - encoding: "utf8", - }).trim(); - } catch { - // not a git repo or git not available - } - let commitHashFull = "unknown"; - try { - commitHashFull = execSync("git rev-parse HEAD", { - encoding: "utf8", - }).trim(); - } catch { - // not a git repo or git not available - } + const commitHashFull = git("rev-parse HEAD") || "unknown"; + const shortHash = git("rev-parse --short HEAD") || "unknown"; + // The full hash is left clean because it is the href of the commit link in + // the About screen, and "abc123-dirty" is not a commit anyone can fetch. + // The displayed short hash carries the marker, so the screen says the tree + // was modified while still linking somewhere real. + const commitHash = + shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix(); return { - version: pkg.version, + // Fails the build when package.json and the two manifests disagree; + // see script/lib/version.js. Called before anything is emitted, so a + // tree with no single version never reaches dist/. + version: resolveVersion(__dirname), license: pkg.license, author: pkg.author, commitHash, diff --git a/manifest/chrome.json b/manifest/chrome.json index 3ac5b7a..991618e 100644 --- a/manifest/chrome.json +++ b/manifest/chrome.json @@ -3,6 +3,7 @@ "name": "AutistMask", "version": "0.1.0", "description": "Minimal Ethereum wallet for Chrome", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUUYzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kLWoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB", "permissions": ["storage", "activeTab", "alarms"], "host_permissions": [""], "content_security_policy": { diff --git a/script/lib/package.js b/script/lib/package.js new file mode 100644 index 0000000..3db9793 --- /dev/null +++ b/script/lib/package.js @@ -0,0 +1,294 @@ +// Turn a verified dist/ into the two distributable archives. +// +// Invoked by script/package, which runs `make build` first so that dist/ has +// already been checked against the build's own receipt (see the Build Receipts +// section of README.md). This program does not build anything and does not +// write into dist/: it reads the emitted tree and writes release/. +// +// release/autistmask-chrome-.zip loaded via chrome://extensions +// release/autistmask-firefox-.xpi an UNSIGNED add-on, see README +// release/SHA256SUMS +// +// Self-containment is checked rather than assumed, because the layout invites +// exactly one mistake: build.js emits dist/styles.css at the dist/ ROOT, +// outside both browser directories, and copies it into each of them as +// src/popup/styles.css. A naive `zip -r dist/chrome` is therefore correct only +// by accident, and would stop being correct the moment a reference pointed up +// and out. So every path the manifest and the popup HTML reference is resolved +// and required to be inside the archive, a reference that escapes the browser +// directory is a hard failure, and anything sitting at the dist/ root is +// listed as deliberately not shipped rather than silently dropped. +// +// The archive is then read back and compared byte for byte against the +// directory it was built from. An archive nobody opened is a claim, not an +// artifact. + +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const { readZip, writeZip } = require("./zip"); +const { resolveVersion } = require("./version"); + +const ROOT = path.resolve(__dirname, "..", ".."); +const DIST = path.join(ROOT, "dist"); +const RELEASE = path.join(ROOT, "release"); + +const TARGETS = [ + { dir: "chrome", ext: "zip" }, + // .xpi rather than .zip: it is the same container, but Firefox's install + // flow keys off the extension. + { dir: "firefox", ext: "xpi" }, +]; + +// Strings in a manifest that name a file the extension loads. Matched by +// shape, not by a list of manifest keys, so a key added in a later manifest +// version is covered the day it appears rather than the day someone remembers +// to extend a list here. Nothing else in either manifest looks like this: the +// CSP strings, "", the version and the base64 key all fail it. +const MANIFEST_PATH_RE = + /^[A-Za-z0-9._][A-Za-z0-9._/-]*\.(?:js|css|html|json|png|svg|woff2?)$/; + +// Local references out of an HTML document. Enough for what this repo emits — +// one stylesheet link and one script tag — and anything it does not understand +// is reported rather than passed over, see htmlReferences(). +const HTML_REF_RE = /(?:src|href)\s*=\s*["']([^"']+)["']/gi; + +function fail(message) { + throw new Error(message); +} + +function sha256(buf) { + return crypto.createHash("sha256").update(buf).digest("hex"); +} + +// Every regular file under dir, as archive-root-relative forward-slashed +// paths. A symlink is refused rather than followed: build.js emits regular +// files only, so a link under dist/ is not something the build produced, and +// dereferencing one would put bytes from outside dist/ into the artifact. +function listFiles(dir, prefix = "") { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) { + fail( + `${dir}/${entry.name} is a symlink. The build emits regular ` + + `files only, so this is not something it produced and it ` + + `will not be archived.`, + ); + } else if (entry.isDirectory()) { + out.push(...listFiles(path.join(dir, entry.name), rel)); + } else if (entry.isFile()) { + out.push(rel); + } else { + fail( + `${dir}/${entry.name} is neither a regular file nor a ` + + `directory, so it is not something the build emitted`, + ); + } + } + return out.sort(); +} + +// Collect every string anywhere in the manifest that looks like a file it +// loads, plus every string that tries to reach outside the extension root. +// The second half is the point: "../styles.css" never matches +// MANIFEST_PATH_RE, so without an explicit check an escaping reference would +// read as "not a path" and the missing file would be found only by a user +// whose popup rendered unstyled. +function manifestReferences(value, found = new Set()) { + if (typeof value === "string") { + if (value.split("/").includes("..")) { + fail( + `the manifest references ${JSON.stringify(value)}, which ` + + `points outside the extension root. Everything the ` + + `browser loads has to be inside the archive; nothing ` + + `above it is shipped.`, + ); + } + if (MANIFEST_PATH_RE.test(value)) found.add(value); + } else if (Array.isArray(value)) { + for (const v of value) manifestReferences(v, found); + } else if (value && typeof value === "object") { + for (const v of Object.values(value)) manifestReferences(v, found); + } + return found; +} + +// Local references out of one HTML member, resolved against that member's own +// directory and returned archive-relative. Absolute URLs, data: URIs and +// in-page anchors are not files and are skipped; a relative reference that +// climbs out of the archive root is a failure for the same reason as above. +function htmlReferences(member, text) { + const base = path.posix.dirname(member); + const out = new Set(); + for (const match of text.matchAll(HTML_REF_RE)) { + const ref = match[1].trim(); + if (ref === "" || ref.startsWith("#") || ref.startsWith("//")) continue; + if (/^[a-z][a-z0-9+.-]*:/i.test(ref)) continue; + if (ref.startsWith("/")) { + fail( + `${member} references ${JSON.stringify(ref)} from the ` + + `extension root. Nothing here emits root-absolute ` + + `references and this packager does not resolve them.`, + ); + } + const resolved = path.posix.normalize(path.posix.join(base, ref)); + if (resolved.startsWith("..")) { + fail( + `${member} references ${JSON.stringify(ref)}, which resolves ` + + `outside the extension root. build.js copies the ` + + `compiled stylesheet into each browser directory for ` + + `exactly this reason: dist/styles.css lives at the dist/ ` + + `root and is not part of either archive.`, + ); + } + out.add(resolved); + } + return out; +} + +// Everything the browser is told to load, and the assertion that all of it is +// in the archive. +function checkSelfContained(target, members, read) { + if (!members.includes("manifest.json")) { + fail(`dist/${target} has no manifest.json at its root`); + } + + const manifest = JSON.parse(read("manifest.json").toString("utf8")); + const referenced = new Set(manifestReferences(manifest)); + + for (const member of members) { + if (!member.endsWith(".html")) continue; + for (const ref of htmlReferences( + member, + read(member).toString("utf8"), + )) { + referenced.add(ref); + } + } + + const missing = [...referenced].filter((r) => !members.includes(r)); + if (missing.length > 0) { + fail( + `the ${target} archive would not be self-contained: it is told ` + + `to load ${missing.join(", ")}, which ${ + missing.length === 1 ? "is" : "are" + } not in it`, + ); + } + return { manifest, referenced }; +} + +function main() { + const version = resolveVersion(ROOT); + + if (!fs.existsSync(DIST)) { + fail( + "there is no dist/ to package. script/package runs make build " + + "first; run it rather than this program.", + ); + } + + fs.rmSync(RELEASE, { recursive: true, force: true }); + fs.mkdirSync(RELEASE, { recursive: true }); + + // Files the build emits at the dist/ root, outside both browser + // directories. Printed rather than ignored: dist/styles.css is the + // Tailwind output that build.js then copies into each browser directory, + // so leaving it out is correct — but "correct and stated" and "dropped by + // a glob" are different things, and only one of them survives the next + // change to the build. + const rootOnly = fs + .readdirSync(DIST, { withFileTypes: true }) + .filter((e) => !e.isDirectory()) + .map((e) => e.name) + .sort(); + if (rootOnly.length > 0) { + console.log( + `Not shipped (dist/ root, outside every browser directory, and ` + + `referenced by nothing inside one): ${rootOnly.join(", ")}`, + ); + } + + const sums = []; + for (const { dir, ext } of TARGETS) { + const targetDir = path.join(DIST, dir); + if (!fs.existsSync(targetDir)) { + fail(`dist/${dir} does not exist; run make build`); + } + + const members = listFiles(targetDir); + const readFromDir = (member) => + fs.readFileSync(path.join(targetDir, member)); + + const { manifest } = checkSelfContained(dir, members, readFromDir); + if (manifest.version !== version) { + fail( + `dist/${dir}/manifest.json says version ${manifest.version} ` + + `but this tree is ${version}. dist/ is stale: run make ` + + `build.`, + ); + } + + const archive = writeZip( + members.map((name) => ({ name, data: readFromDir(name) })), + ); + const name = `autistmask-${dir}-${version}.${ext}`; + const outPath = path.join(RELEASE, name); + fs.writeFileSync(outPath, archive); + + // Read the artifact back off disk, not the buffer that was just + // written: what ships is the file. + const written = fs.readFileSync(outPath); + const entries = readZip(written); + const inArchive = entries.map((e) => e.name).sort(); + if (inArchive.join("\n") !== members.join("\n")) { + fail( + `${name} does not hold the same members as dist/${dir}: ` + + `archive has ${inArchive.length}, directory has ` + + `${members.length}`, + ); + } + for (const entry of entries) { + const onDisk = readFromDir(entry.name); + if (sha256(entry.data) !== sha256(onDisk)) { + fail(`${name} member ${entry.name} differs from dist/${dir}`); + } + } + // Re-run the self-containment check against the ARCHIVE's own + // contents. The directory passing it is not the claim being made. + const byName = new Map(entries.map((e) => [e.name, e.data])); + checkSelfContained(dir, inArchive, (m) => byName.get(m)); + + const digest = sha256(written); + sums.push(`${digest} ${name}`); + console.log( + `${name}: ${entries.length} member(s), ${written.length} bytes, ` + + `sha256 ${digest}`, + ); + } + + fs.writeFileSync( + path.join(RELEASE, "SHA256SUMS"), + sums.map((l) => `${l}\n`).join(""), + ); + console.log(`Wrote release/ for version ${version}`); +} + +// Only when run as a program. The reference-resolving helpers are what decide +// whether an archive is self-contained, so tests/packaging.test.js exercises +// them directly and must be able to require this file without packaging +// anything. +if (require.main === module) { + try { + main(); + } catch (err) { + console.error(`package: ${err && err.message ? err.message : err}`); + process.exit(1); + } +} + +module.exports = { checkSelfContained, htmlReferences, manifestReferences }; diff --git a/script/lib/version.js b/script/lib/version.js new file mode 100644 index 0000000..f187704 --- /dev/null +++ b/script/lib/version.js @@ -0,0 +1,74 @@ +// The version, and the rule that there is only one of it. +// +// Three files declare a version and none of them can be derived from another: +// Chrome and Firefox each need their own manifest, both are copied to dist/ +// verbatim (tests/manifest.test.js asserts that what is in manifest/ is what +// ships), and package.json's copy is what build.js compiles into the About +// screen. So the single source of truth is enforced rather than generated — +// they must all agree or there is no version and no build. +// +// Reading one of the three and ignoring the rest is what this replaces. That +// shape cannot fail: it silently ships an extension whose About screen and +// whose browser-reported version disagree, and whose release artifact is named +// after whichever file the packager happened to read. +// +// Required by build.js, script/lib/package.js and tests/version.test.js, so +// the build, the release artifacts and make check all apply the same rule to +// the same files. + +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const VERSION_SOURCES = [ + "package.json", + "manifest/chrome.json", + "manifest/firefox.json", +]; + +// Every declared version, in VERSION_SOURCES order, as { source, version }. +// A file that declares nothing usable fails here rather than being skipped: +// a missing version is not agreement. +function declaredVersions(root) { + return VERSION_SOURCES.map((source) => { + const file = path.join(root, source); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(file, "utf8")); + } catch (e) { + throw new Error( + `${source} could not be read as JSON: ${e.message}`, + ); + } + const version = parsed.version; + if (typeof version !== "string" || version.trim() === "") { + throw new Error( + `${source} declares no usable "version" (found ` + + `${JSON.stringify(version)}). Every artifact is named and ` + + `stamped with it, so there is nothing to build without it.`, + ); + } + return { source, version }; + }); +} + +// The one version all three declare, or a failure naming every disagreeing +// file and what it said. +function resolveVersion(root) { + const declared = declaredVersions(root); + const distinct = [...new Set(declared.map((d) => d.version))]; + if (distinct.length !== 1) { + throw new Error( + "the declared versions disagree, so this tree has no version: " + + declared.map((d) => `${d.source}=${d.version}`).join(", ") + + ". Set all of them to the same value: the manifests are what " + + "the browser reports and package.json is what the About " + + "screen shows, and a build that picked one of them would " + + "ship the disagreement.", + ); + } + return distinct[0]; +} + +module.exports = { VERSION_SOURCES, declaredVersions, resolveVersion }; diff --git a/script/lib/zip.js b/script/lib/zip.js new file mode 100644 index 0000000..974b36f --- /dev/null +++ b/script/lib/zip.js @@ -0,0 +1,274 @@ +// A minimal, deterministic ZIP writer and reader. +// +// Used by script/lib/package.js to build the distributable archives: a Chrome +// zip and a Firefox XPI are both ordinary zip files with manifest.json at the +// root, so one implementation covers both. +// +// Why this rather than a package or the zip(1) binary. A dependency would have +// to be hash-pinned like everything else in REPO_POLICIES.md, and this is +// about a hundred lines of stdlib zlib for a format the archives use two +// features of. The binary is worse: the release artifact would then depend on +// whichever Info-ZIP the machine happens to have, which is the same objection +// that keeps linting inside a container. +// +// Deterministic on purpose. Entries are sorted by name, every timestamp is the +// same fixed 1980-01-01 the format's epoch starts at, and the compression +// level is fixed, so building the same dist/ twice produces byte-identical +// archives and the sha256 in SHA256SUMS is a property of the input rather than +// of the clock. Two builds of the same commit that disagree are then visible +// instead of expected. +// +// Deliberately NOT implemented: zip64, encryption, data descriptors, +// directory entries (browsers infer directories from member paths), and +// anything to do with symlinks. writeZip refuses input it cannot represent +// rather than emitting an archive that is quietly wrong. + +"use strict"; + +const zlib = require("zlib"); + +const LOCAL_SIG = 0x04034b50; +const CENTRAL_SIG = 0x02014b50; +const EOCD_SIG = 0x06054b50; + +const METHOD_STORE = 0; +const METHOD_DEFLATE = 8; + +// 1980-01-01 00:00:00, the earliest the MS-DOS timestamp fields can express. +const DOS_DATE = (0 << 9) | (1 << 5) | 1; +const DOS_TIME = 0; + +// Unix regular file, mode 0644, in the high 16 bits, which is where the "made +// by unix" convention puts it. +// >>> 0 because JS shifts are signed 32-bit and this one sets the top bit. +const EXTERNAL_ATTRS = (0o100644 << 16) >>> 0; +const VERSION_MADE_BY = (3 << 8) | 20; // unix, needs zip 2.0 +const VERSION_NEEDED = 20; + +// Without zip64 every size and offset is a u32. +const MAX_U32 = 0xffffffff; + +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let i = 0; i < 256; i++) { + let c = i; + for (let k = 0; k < 8; k++) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c; + } + return table; +})(); + +// Written out rather than taken from zlib.crc32, which only exists from node +// 22.2: this runs from script/ on whatever node the host has as well as inside +// the pinned image, and a checksum that silently is not there is worse than +// twelve lines. +function crc32(buf) { + let c = -1; + for (let i = 0; i < buf.length; i++) { + c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + } + return (c ^ -1) >>> 0; +} + +// Member names are stored as raw bytes. Anything outside ASCII would need the +// UTF-8 flag and interoperability care that nothing this repo emits requires, +// so it is refused instead of guessed at. +function encodeName(name) { + if (typeof name !== "string" || name === "") { + throw new Error(`zip: unusable member name ${JSON.stringify(name)}`); + } + const segments = name.split("/"); + if ( + name.startsWith("/") || + name.includes("\\") || + segments.some((s) => s === "" || s === "." || s === "..") + ) { + throw new Error( + `zip: refusing member name ${JSON.stringify(name)}: archive ` + + `members must be relative paths under the archive root`, + ); + } + if (/[^\x20-\x7e]/.test(name)) { + throw new Error( + `zip: refusing non-ASCII member name ${JSON.stringify(name)}`, + ); + } + return Buffer.from(name, "ascii"); +} + +function compress(data) { + if (data.length === 0) { + return { method: METHOD_STORE, body: data }; + } + const deflated = zlib.deflateRawSync(data, { level: 9 }); + if (deflated.length >= data.length) { + return { method: METHOD_STORE, body: data }; + } + return { method: METHOD_DEFLATE, body: deflated }; +} + +// entries: [{ name, data }]. Returns the archive as a Buffer. +function writeZip(entries) { + if (!Array.isArray(entries) || entries.length === 0) { + throw new Error("zip: refusing to write an archive with no members"); + } + + const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : 1)); + const seen = new Set(); + const locals = []; + const centrals = []; + let offset = 0; + + for (const entry of sorted) { + const name = encodeName(entry.name); + if (seen.has(entry.name)) { + throw new Error(`zip: duplicate member ${entry.name}`); + } + seen.add(entry.name); + + const data = Buffer.from(entry.data); + const { method, body } = compress(data); + if (data.length > MAX_U32 || body.length > MAX_U32) { + throw new Error( + `zip: ${entry.name} is too large for a non-zip64 archive`, + ); + } + const crc = crc32(data); + + const local = Buffer.alloc(30 + name.length); + local.writeUInt32LE(LOCAL_SIG, 0); + local.writeUInt16LE(VERSION_NEEDED, 4); + local.writeUInt16LE(0, 6); + local.writeUInt16LE(method, 8); + local.writeUInt16LE(DOS_TIME, 10); + local.writeUInt16LE(DOS_DATE, 12); + local.writeUInt32LE(crc, 14); + local.writeUInt32LE(body.length, 18); + local.writeUInt32LE(data.length, 22); + local.writeUInt16LE(name.length, 26); + local.writeUInt16LE(0, 28); + name.copy(local, 30); + + const central = Buffer.alloc(46 + name.length); + central.writeUInt32LE(CENTRAL_SIG, 0); + central.writeUInt16LE(VERSION_MADE_BY, 4); + central.writeUInt16LE(VERSION_NEEDED, 6); + central.writeUInt16LE(0, 8); + central.writeUInt16LE(method, 10); + central.writeUInt16LE(DOS_TIME, 12); + central.writeUInt16LE(DOS_DATE, 14); + central.writeUInt32LE(crc, 16); + central.writeUInt32LE(body.length, 20); + central.writeUInt32LE(data.length, 24); + central.writeUInt16LE(name.length, 28); + central.writeUInt16LE(0, 30); + central.writeUInt16LE(0, 32); + central.writeUInt16LE(0, 34); + central.writeUInt16LE(0, 36); + central.writeUInt32LE(EXTERNAL_ATTRS, 38); + if (offset > MAX_U32) { + throw new Error("zip: archive too large for a non-zip64 archive"); + } + central.writeUInt32LE(offset, 42); + name.copy(central, 46); + + locals.push(local, body); + centrals.push(central); + offset += local.length + body.length; + } + + const centralBuf = Buffer.concat(centrals); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(EOCD_SIG, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(sorted.length, 8); + eocd.writeUInt16LE(sorted.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + + return Buffer.concat([...locals, centralBuf, eocd]); +} + +// Read an archive back into [{ name, data }], from the central directory +// rather than by scanning for local headers: the central directory is the +// authoritative index, and a member reachable only by scanning is one a real +// unzipper would not extract. +// +// Every member's CRC is checked. The point of reading an archive back is to +// establish that it holds what it was meant to hold, so a member that does not +// decompress to its recorded checksum is a failure and never a warning. +function readZip(buf) { + if (buf.length < 22) { + throw new Error("zip: too short to be an archive"); + } + // No archive this writes has a trailing comment, so the EOCD is the last + // 22 bytes. Anything else is not an archive this produced. + const eocdAt = buf.length - 22; + if (buf.readUInt32LE(eocdAt) !== EOCD_SIG) { + throw new Error( + "zip: no end-of-central-directory record at the end of the " + + "archive (a trailing comment, or not a zip at all)", + ); + } + const count = buf.readUInt16LE(eocdAt + 10); + const centralSize = buf.readUInt32LE(eocdAt + 12); + let at = buf.readUInt32LE(eocdAt + 16); + if (at + centralSize > eocdAt) { + throw new Error("zip: central directory runs past the archive"); + } + + const out = []; + for (let i = 0; i < count; i++) { + if (buf.readUInt32LE(at) !== CENTRAL_SIG) { + throw new Error(`zip: bad central directory entry ${i}`); + } + const method = buf.readUInt16LE(at + 10); + const crc = buf.readUInt32LE(at + 16); + const compSize = buf.readUInt32LE(at + 20); + const rawSize = buf.readUInt32LE(at + 24); + const nameLen = buf.readUInt16LE(at + 28); + const extraLen = buf.readUInt16LE(at + 30); + const commentLen = buf.readUInt16LE(at + 32); + const localAt = buf.readUInt32LE(at + 42); + const name = buf.toString("ascii", at + 46, at + 46 + nameLen); + at += 46 + nameLen + extraLen + commentLen; + + if (buf.readUInt32LE(localAt) !== LOCAL_SIG) { + throw new Error(`zip: ${name} has no local header`); + } + // The local header's own name and extra lengths, not the central + // directory's: the two are allowed to differ and the data starts after + // the local ones. + const localNameLen = buf.readUInt16LE(localAt + 26); + const localExtraLen = buf.readUInt16LE(localAt + 28); + const dataAt = localAt + 30 + localNameLen + localExtraLen; + const body = buf.subarray(dataAt, dataAt + compSize); + + let data; + if (method === METHOD_STORE) { + data = Buffer.from(body); + } else if (method === METHOD_DEFLATE) { + data = zlib.inflateRawSync(body); + } else { + throw new Error(`zip: ${name} uses compression method ${method}`); + } + + if (data.length !== rawSize) { + throw new Error( + `zip: ${name} decompressed to ${data.length} bytes, not the ` + + `recorded ${rawSize}`, + ); + } + if (crc32(data) !== crc) { + throw new Error(`zip: ${name} fails its recorded CRC32`); + } + out.push({ name, data }); + } + return out; +} + +module.exports = { crc32, readZip, writeZip }; diff --git a/script/package b/script/package new file mode 100755 index 0000000..71ae367 --- /dev/null +++ b/script/package @@ -0,0 +1,38 @@ +#!/bin/sh +# script/package: produce the release artifacts — one self-contained, +# versioned archive per browser — into release/. Our own extension to +# scripts-to-rule-them-all. +# +# It builds first, through `make build` rather than by calling build.js +# itself. That target is the only audited path to a release build: it creates +# the build receipt outside the repo, scrubs AUTISTMASK_DEBUG from the +# verifier's environment, tells script/verify-build in so many words to expect +# a RELEASE build, and re-runs script/check-censored against dist/. +# script/test-verify-build asserts that wiring by reading the recipe back out +# of `make -n`. Re-implementing that sequence here would give the release +# artifacts a second, unaudited path to dist/ — and it is the release +# artifacts, above everything else, that must never be built from a debug +# compile. +# +# This packages, it does not publish. Tagging, CRX packing and any upload are +# outward-facing acts and are nobody's job but the owner's. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + + if ! command -v make >/dev/null 2>&1; then + echo "package: make is required (the release build runs through" \ + "make build)" >&2 + exit 1 + fi + + make build + + echo "Packaging release artifacts..." + node script/lib/package.js +} + +main "$@" diff --git a/script/test-e2e b/script/test-e2e index 74a84b2..d5d22c6 100755 --- a/script/test-e2e +++ b/script/test-e2e @@ -78,6 +78,22 @@ main() { -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ "$(cat "$IIDFILE")" \ node tests/e2e/run.js + + # Where chrome.storage.local lives, and what moves it: two unpacked loads + # from two different paths in one profile, with the shipped manifest and + # again with `key` stripped out. Its own browser sessions — four of them — + # because the whole subject is what happens ACROSS loads, which the suite + # above cannot express with one. + # + # No PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS here: this drives no + # RPC and installs no route handlers, and the browser is started with + # --host-resolver-rules=MAP * ~NOTFOUND so nothing it does can leave. + echo "Running the extension-id and storage-partition observations..." + docker run --rm \ + --ipc=host \ + -e HOME=/tmp \ + "$(cat "$IIDFILE")" \ + node tests/e2e/storagePartition.js } main "$@" diff --git a/script/test-e2e-firefox b/script/test-e2e-firefox index df0d4d2..3eba215 100755 --- a/script/test-e2e-firefox +++ b/script/test-e2e-firefox @@ -72,6 +72,19 @@ main() { -e HOME=/tmp \ "$(cat "$IIDFILE")" \ node tests/e2e/firefox/run.js dist/firefox + + # The install/uninstall/re-install property, against the packaged XPI + # rather than the unpacked directory: it is the artifact a user would be + # handed, and this is the only place a real Firefox is asked to load it. + # Its own browser session, because it takes the add-on away in the middle + # and the suite above shares one session throughout. + echo "Running the Firefox re-install suite against the packaged XPI..." + docker run --rm \ + --shm-size=1g \ + --network none \ + -e HOME=/tmp \ + "$(cat "$IIDFILE")" \ + node tests/e2e/firefox/reinstall.js } main "$@" diff --git a/tests/e2e/firefox/Dockerfile b/tests/e2e/firefox/Dockerfile index 31ea99c..9e944c1 100644 --- a/tests/e2e/firefox/Dockerfile +++ b/tests/e2e/firefox/Dockerfile @@ -72,6 +72,11 @@ RUN script/bootstrap COPY . . -RUN make build +# make package builds (and verifies) dist/ and then writes the release +# artifacts, so the image carries both: run.js installs the unpacked +# dist/firefox and reinstall.js installs the packaged .xpi, which is how the +# artifact that would actually be handed to someone gets exercised in a real +# Firefox rather than only being produced. +RUN make package CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"] diff --git a/tests/e2e/firefox/driver.js b/tests/e2e/firefox/driver.js index 0a187b0..0592baa 100644 --- a/tests/e2e/firefox/driver.js +++ b/tests/e2e/firefox/driver.js @@ -103,7 +103,13 @@ class Driver { // ------------------------------------------------------------ setup - async newSession() { + // `profileDir` reuses an existing profile directory in place instead of + // letting geckodriver make a throwaway one. That is the only way to ask + // what survives a browser RESTART, which for a Firefox add-on that can + // only be installed temporarily is the question that decides whether the + // extension is usable at all: a temporary add-on is unloaded when Firefox + // exits, so every session begins by adding it again. + async newSession(profileDir) { const prefs = { // See EXTENSION_UUID above. The pref is a string pref whose // value is itself JSON. @@ -132,26 +138,27 @@ class Driver { "extensions.openPopupWithoutUserGesture.enabled": false, }; + const args = [ + "-headless", + // Mandatory on Firefox 153: without it, navigating to + // moz-extension:// and running chrome-context script both + // fail with "unsupported operation". + // + // It grants the driver FULL CHROME PRIVILEGES over this + // browser. Acceptable only because the browser is a + // throwaway in a CI container; never point a session with + // this flag at anything you care about. + "-remote-allow-system-access", + ]; + if (profileDir) args.push("-profile", profileDir); + const value = await this.send("POST", "/session", { capabilities: { alwaysMatch: { browserName: "firefox", "moz:firefoxOptions": { binary: FIREFOX_BIN, - args: [ - "-headless", - // Mandatory on Firefox 153: without it, - // navigating to moz-extension:// and running - // chrome-context script both fail with - // "unsupported operation". - // - // It grants the driver FULL CHROME PRIVILEGES - // over this browser. Acceptable only because - // the browser is a throwaway in a CI - // container; never point a session with this - // flag at anything you care about. - "-remote-allow-system-access", - ], + args, prefs, }, }, @@ -162,16 +169,30 @@ class Driver { return value; } - // Installs the unpacked MV2 build straight from a directory. - // temporary:true bypasses signature checks, so no XPI and no signing - // are involved, and the add-on dies with the profile. - async installAddon(dir) { + // Installs the MV2 build, either from an unpacked directory or from an + // XPI file. temporary:true bypasses signature checks — which is the only + // way an UNSIGNED xpi installs at all, and the reason README.md says + // release Firefox will refuse the artifact this repo produces — and the + // add-on dies with the profile. + // + // Returns the add-on id Firefox assigned, which is + // browser_specific_settings.gecko.id from the manifest and is what + // uninstallAddon() takes. + async installAddon(pathToAddon) { return this.session("POST", "/moz/addon/install", { - path: dir, + path: pathToAddon, temporary: true, }); } + // Removes an installed add-on, the way clicking Remove in about:addons + // does. tests/e2e/firefox/reinstall.js uses it to ask the one question + // that decides whether this extension can be used at all on Firefox: does + // the vault survive being removed and added again. + async uninstallAddon(id) { + return this.session("POST", "/moz/addon/uninstall", { id }); + } + // Classic navigation on purpose. BiDi's browsingContext.navigate // refuses moz-extension:// URLs outright. async navigate(url) { diff --git a/tests/e2e/firefox/reinstall.js b/tests/e2e/firefox/reinstall.js new file mode 100644 index 0000000..e7c8733 --- /dev/null +++ b/tests/e2e/firefox/reinstall.js @@ -0,0 +1,438 @@ +// Does the wallet survive being installed again on Firefox? +// +// This is the question behind +// https://git.eeqj.de/sneak/AutistMask/issues/310, and it is the one property +// that has to hold before real money goes into this extension. The only route +// that works on release Firefox is a TEMPORARY add-on, which is unloaded when +// the browser exits: daily use means adding it again from about:debugging on +// every browser start. If extension storage did not survive that, every start +// would present an empty wallet and the recovery phrase would be the only copy +// of the money. +// +// manifest/firefox.json declares a fixed browser_specific_settings.gecko.id, +// which is the right SHAPE for storage to survive — Firefox keys the storage +// area by add-on id — but shape is not observation, and nothing asserted it. +// +// Two different things are asked here, because they have different answers and +// conflating them would be the whole mistake: +// +// RESTART one profile, two browser runs, the add-on added temporarily +// in each. This is what a user does every day, and the vault +// has to survive it. +// REMOVAL an explicit uninstall, the way about:addons "Remove" works, +// inside one browser run. Firefox destroys an add-on's storage +// when it is uninstalled, and the observed result is recorded +// here rather than wished away — for a wallet it means Remove +// is irreversible except from the recovery phrase. +// +// The vault is not merely compared as bytes in the restart case. It is +// DECRYPTED with the original password through the real Show Recovery Phrase +// screen and the phrase is compared against the one wallet creation produced, +// because "the ciphertext is still in storage" and "the wallet still works" +// are different claims and only the second one is worth anything. +// +// Run through script/test-e2e-firefox, which builds the artifact and the +// pinned container. The one argument is what to install — an unpacked +// directory or an .xpi. With none, the packaged XPI in release/ is used, so +// this doubles as the check that the release artifact installs in a real +// Firefox. +// +// node tests/e2e/firefox/reinstall.js release/autistmask-firefox-0.1.0.xpi +// +// A separate program from run.js rather than another step in it: every step +// there shares one browser session with one installed add-on, and this needs +// two browsers and three installs. + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { EXTENSION_ID, EXTENSION_UUID, start } = require("./driver"); + +const REPO_ROOT = path.resolve(__dirname, "..", "..", ".."); +const PASSWORD = "e2e-harness-password"; + +const STEP_TIMEOUT_MS = 120000; + +const checks = []; +let failed = 0; + +function check(name, cond, detail) { + checks.push(name); + if (cond) { + console.log("ok " + checks.length + " - " + name); + } else { + failed += 1; + console.log("not ok " + checks.length + " - " + name); + if (detail) console.log(" " + detail); + } +} + +// The moz-extension:// uuid Firefox currently serves this add-on from, read +// out of the pref that holds the mapping. Privileged scope, because that pref +// is not reachable from content. +// +// It has to be read after every install and never assumed. driver.js pins a +// uuid through extensions.webextensions.uuids at each session start, but an +// uninstall inside a running session drops that mapping and the next install +// mints a fresh one — and navigating to the stale origin does not fail, it +// HANGS until the session times out, which is how this was found. +async function extensionUuid(d) { + const raw = await d.executeChrome( + `return Services.prefs.getStringPref( + "extensions.webextensions.uuids", "{}");`, + ); + let map; + try { + map = JSON.parse(raw); + } catch (e) { + throw new Error( + "extensions.webextensions.uuids is not JSON (" + + e.message + + "): " + + raw, + ); + } + return map[EXTENSION_ID] || null; +} + +async function openPopup(d) { + const uuid = await extensionUuid(d); + if (!uuid) { + throw new Error( + "no uuid for " + + EXTENSION_ID + + " in extensions.webextensions.uuids, so the popup has no " + + "origin to be served from", + ); + } + await d.navigate("moz-extension://" + uuid + "/src/popup/index.html"); + // Whichever screen it lands on, wait for one of the two it can land on. + // Waiting for #view-main directly would time out rather than say what + // happened, and an empty storage partition is exactly the case where it + // lands on the other one. + await d.waitFor( + "the popup to finish restoring", + `const w = document.getElementById("view-welcome"); + const m = document.getElementById("view-main"); + if (!w || !m) return false; + const shown = (el) => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }; + return shown(w) || shown(m);`, + [], + STEP_TIMEOUT_MS, + ); + return uuid; +} + +// The persisted record, straight out of extension storage, read from the popup +// page — the one moz-extension:// document this program opens and therefore +// the only place the storage API is reachable from. +async function readVault(d) { + const outcome = await d.executeAsync( + `const done = arguments[arguments.length - 1]; + const api = typeof browser !== "undefined" ? browser : chrome; + Promise.resolve(api.storage.local.get("autistmask")) + .then((r) => { + const s = r.autistmask; + if (!s) return done({ present: false }); + const w = (s.wallets || [])[0]; + if (!w) return done({ present: false }); + done({ + present: true, + wallets: s.wallets.length, + name: w.name, + xpub: w.xpub, + address: (w.addresses || []).map((a) => a.address)[0], + vault: JSON.stringify(w.encryptedSecret), + }); + }) + .catch((e) => done({ error: String((e && e.message) || e) }));`, + ); + if (outcome && outcome.error) { + throw new Error("could not read extension storage: " + outcome.error); + } + return outcome; +} + +async function createWallet(d) { + await d.click("#btn-welcome-add"); + await d.waitVisible("#view-add-wallet"); + await d.click("#btn-generate-phrase"); + await d.waitFor( + "a generated recovery phrase of at least 12 words", + `const el = document.getElementById("wallet-mnemonic"); + return !!el && el.value.trim().split(/\\s+/).length >= 12;`, + ); + const phrase = (await d.value("#wallet-mnemonic")).trim(); + await d.fill("#add-wallet-password", PASSWORD); + await d.fill("#add-wallet-password-confirm", PASSWORD); + await d.click("#btn-add-wallet-confirm"); + // Argon2id under libsodium, for real. + await d.waitVisible("#view-main", STEP_TIMEOUT_MS); + return phrase; +} + +// The phrase the vault decrypts to, obtained the way a user would: Settings, +// Show Recovery Phrase, the original password. +async function revealPhrase(d) { + if (!(await d.isVisible("#view-settings"))) { + await d.click("#btn-settings"); + } + await d.waitVisible("#view-settings"); + await d.click("#settings-wallet-list .btn-show-phrase"); + await d.waitVisible("#view-show-phrase"); + await d.fill("#show-phrase-password", PASSWORD); + await d.click("#btn-show-phrase-reveal"); + await d.waitVisible("#show-phrase-result", STEP_TIMEOUT_MS); + return (await d.text("#show-phrase-value")).trim(); +} + +// What to install. An explicit argument wins; with none, the packaged XPI in +// release/ is used, and there is deliberately no fallback to dist/firefox: the +// point of running this against the artifact is that the artifact is what gets +// installed, and quietly testing something else instead would leave the XPI +// unexercised while the run stayed green. +function resolveArtifact() { + if (process.argv[2]) return path.resolve(REPO_ROOT, process.argv[2]); + + const releaseDir = path.join(REPO_ROOT, "release"); + const xpis = fs.existsSync(releaseDir) + ? fs.readdirSync(releaseDir).filter((f) => f.endsWith(".xpi")) + : []; + if (xpis.length !== 1) { + throw new Error( + "expected exactly one .xpi in release/, found " + + xpis.length + + " (" + + xpis.join(", ") + + "). Run make package, or name the artifact as the argument.", + ); + } + return path.join(releaseDir, xpis[0]); +} + +async function main() { + let artifact; + try { + artifact = resolveArtifact(); + } catch (e) { + console.error("e2e-firefox-reinstall: " + e.message); + process.exitCode = 1; + return; + } + if (!fs.existsSync(artifact)) { + console.error( + "e2e-firefox-reinstall: nothing to install at " + artifact, + ); + process.exitCode = 1; + return; + } + console.log("# installing: " + artifact); + + // One profile, reused by both browser runs below. geckodriver would + // otherwise make a throwaway one per session, and "survives a restart" + // cannot be asked of a profile that does not. + const profile = fs.mkdtempSync( + path.join(os.tmpdir(), "autistmask-reinstall-profile-"), + ); + + let phrase = null; + let before = null; + let first; + + // --- run one: install, create a wallet, close the browser -------------- + try { + first = await start(); + await first.newSession(profile); + } catch (e) { + // A browser we cannot start is a failure of this program, never an + // absent one. + console.error("e2e-firefox-reinstall: cannot run: " + e.message); + if (first) await first.quit().catch(() => {}); + process.exitCode = 1; + return; + } + + try { + const firstId = await first.installAddon(artifact); + check( + "the artifact installs and reports the manifest's gecko id", + firstId === EXTENSION_ID, + "installed add-on id is " + + JSON.stringify(firstId) + + ", expected " + + JSON.stringify(EXTENSION_ID) + + ". Without a stable id Firefox has no key to hang the " + + "storage area on, and nothing below can hold.", + ); + + const firstUuid = await openPopup(first); + await first.waitVisible("#view-welcome", STEP_TIMEOUT_MS); + phrase = await createWallet(first); + before = await readVault(first); + check( + "a wallet created through the UI is in extension storage", + before.present && before.wallets === 1 && !!before.vault, + JSON.stringify(before), + ); + console.log( + "# run 1 moz-extension uuid: " + + firstUuid + + (firstUuid === EXTENSION_UUID ? " (the pinned one)" : ""), + ); + } catch (e) { + failed += 1; + console.log("# ERROR in run 1: " + (e && e.stack ? e.stack : e)); + } finally { + await first.quit().catch(() => {}); + } + + // --- run two: same profile, add-on added again ------------------------- + // + // This is the restart. The temporary add-on died with the previous + // browser; the profile, and whatever Firefox kept in it, did not. + let second; + try { + second = await start(); + await second.newSession(profile); + } catch (e) { + console.error( + "e2e-firefox-reinstall: cannot restart the browser: " + e.message, + ); + if (second) await second.quit().catch(() => {}); + process.exitCode = 1; + return; + } + + try { + const secondId = await second.installAddon(artifact); + check( + "the add-on installs again after a browser restart with the " + + "same id", + secondId === EXTENSION_ID, + "re-installed id is " + JSON.stringify(secondId), + ); + + const secondUuid = await openPopup(second); + // The origin the popup is served from is not the thing that carries + // the wallet, and the uuid is read live for exactly that reason. In + // this run it comes back as the pinned one, because driver.js writes + // extensions.webextensions.uuids into the profile at every session + // start; an uninstall inside a running session drops the mapping and + // the next install mints a fresh uuid instead. Either way the storage + // area is keyed on the add-on id, never on this. + console.log( + "# run 2 moz-extension uuid: " + + secondUuid + + (secondUuid === EXTENSION_UUID + ? " (the pinned one, re-applied at session start)" + : " (freshly minted)"), + ); + + const onWelcome = await second.isVisible("#view-welcome"); + check( + "after a restart and re-add, the extension does not come up as " + + "a fresh install", + !onWelcome, + "the popup shows the welcome screen, which is what an empty " + + "storage partition looks like: the wallet is gone and only " + + "the recovery phrase would get it back.", + ); + + const after = await readVault(second); + check( + "the vault, xpub and first address survive the restart unchanged", + after.present && + after.wallets === before.wallets && + after.vault === before.vault && + after.xpub === before.xpub && + after.address === before.address, + "before: " + + JSON.stringify(before) + + " after: " + + JSON.stringify(after), + ); + + if (after.present) { + const revealed = await revealPhrase(second); + check( + "the vault still decrypts with the original password to the " + + "original recovery phrase", + revealed === phrase, + "the recovery phrase read back after the restart is not the " + + "one the wallet was created with", + ); + } else { + check( + "the vault still decrypts with the original password to the " + + "original recovery phrase", + false, + "there was no vault left to decrypt", + ); + } + + // --- an explicit removal, in the same browser run ------------------ + // + // Leave the popup FIRST. Removing the add-on destroys every document + // it serves, and the popup is this session's only window: uninstalling + // while it is on screen discards the browsing context, and every + // subsequent WebDriver command fails with "no such window" rather than + // with anything about the add-on. Observed, not anticipated. + await second.navigate("about:blank"); + await second.uninstallAddon(EXTENSION_ID); + await second.installAddon(artifact); + await openPopup(second); + + const afterRemoval = await readVault(second); + console.log( + "# after an explicit uninstall: " + JSON.stringify(afterRemoval), + ); + // Recorded as observed behaviour, not as something this repo wants. + // Firefox destroys an add-on's storage when it is uninstalled, and + // that is correct of a browser — it is stated here, and in README.md, + // because for a WALLET it means about:addons "Remove" is irreversible + // except from the recovery phrase. If a Firefox ever stops doing it + // this fails and the claim gets rewritten from a new observation. + check( + "an explicit uninstall DESTROYS the vault (observed Firefox " + + "behaviour: Remove is irreversible, unlike a restart)", + afterRemoval.present === false, + "the vault survived an explicit uninstall: " + + JSON.stringify(afterRemoval), + ); + } catch (e) { + failed += 1; + console.log("# ERROR in run 2: " + (e && e.stack ? e.stack : e)); + } finally { + await second.quit().catch(() => {}); + fs.rmSync(profile, { recursive: true, force: true }); + } + + console.log("1.." + checks.length); + if (checks.length === 0) { + console.log("# FAILED: this program asserted nothing"); + process.exitCode = 1; + return; + } + console.log( + "# " + + (checks.length - failed) + + "/" + + checks.length + + " checks passed", + ); + if (failed > 0) { + console.log("# FAILED"); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error("e2e-firefox-reinstall: " + (e && e.stack ? e.stack : e)); + process.exitCode = 1; +}); diff --git a/tests/e2e/storagePartition.js b/tests/e2e/storagePartition.js new file mode 100644 index 0000000..000590d --- /dev/null +++ b/tests/e2e/storagePartition.js @@ -0,0 +1,303 @@ +// Where does chrome.storage.local live, and what moves it? +// +// The finding behind https://git.eeqj.de/sneak/AutistMask/issues/310: an +// unpacked Chrome extension with no `key` in its manifest gets an extension id +// derived from the ABSOLUTE PATH it was loaded from, and chrome.storage.local +// is partitioned by that id. README.md documents Load unpacked from +// dist/chrome/ as the install route, so moving the checkout, re-cloning it, or +// loading a second copy from anywhere else means a different id, a different +// storage partition, and a wallet that reads as empty — with no error, no +// prompt and nothing in the UI to say what happened. +// +// So this OBSERVES the behaviour rather than restating it. Two unpacked loads +// from two different directories, in one profile, with the shipped manifest +// and again with `key` stripped out, and it reports what each pair actually +// does. The assertions are on what was seen when this was written and are +// annotated as such; if Chrome's derivation ever changes, this says so instead +// of passing. +// +// Run through script/test-e2e, which builds the extension and the pinned +// container. +// +// node tests/e2e/storagePartition.js +// +// A separate program from run.js because every test there shares one browser +// and one extension load, and the whole subject here is what happens across +// two of each. +// +// The extension's own UI is deliberately not driven. What is under test is the +// storage partition, so a sentinel key the extension never reads or writes is +// written and read back directly: a wallet would prove the same thing more +// slowly, and would confuse an empty partition with a UI that failed to +// render. + +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { chromium } = require("playwright-core"); + +const REPO_ROOT = path.resolve(__dirname, "..", ".."); +const DIST_CHROME = path.join(REPO_ROOT, "dist", "chrome"); + +// Never touched by the extension: src/shared/state.js reads and writes the +// single key "autistmask" and nothing else. +const SENTINEL_KEY = "e2e-storage-partition-sentinel"; +const SENTINEL_VALUE = "written-by-the-first-load"; + +const checks = []; +let failed = 0; +const scratch = []; + +function check(name, cond, detail) { + checks.push(name); + if (cond) { + console.log("ok " + checks.length + " - " + name); + } else { + failed += 1; + console.log("not ok " + checks.length + " - " + name); + if (detail) console.log(" " + detail); + } +} + +function tmpdir(tag) { + const dir = fs.mkdtempSync( + path.join(os.tmpdir(), "autistmask-" + tag + "-"), + ); + scratch.push(dir); + return dir; +} + +// A copy of the built extension at a fresh absolute path. `withKey: false` +// strips the manifest's `key`, which is the pre-fix state of this repo and the +// control the whole program is built around. +function extensionCopy(tag, withKey) { + const dir = path.join(tmpdir(tag), "chrome"); + fs.cpSync(DIST_CHROME, dir, { recursive: true }); + const manifestPath = path.join(dir, "manifest.json"); + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (withKey) { + if (!manifest.key) { + throw new Error( + "dist/chrome/manifest.json has no `key`, so this program has " + + "nothing to observe. That field is what pins the " + + "extension id; see tests/extensionId.test.js.", + ); + } + } else { + delete manifest.key; + } + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 4)); + return dir; +} + +async function launch(profileDir, extensionDirs) { + const ctx = await chromium.launchPersistentContext(profileDir, { + // See the note in harness.js: the default headless shell silently + // refuses to load extensions. + channel: "chromium", + headless: true, + args: [ + "--disable-extensions-except=" + extensionDirs.join(","), + "--load-extension=" + extensionDirs.join(","), + "--no-sandbox", + // Nothing here should reach the network; this makes sure it + // cannot. + "--host-resolver-rules=MAP * ~NOTFOUND", + ], + }); + return ctx; +} + +// Every extension id Chrome ended up with in this context, from the service +// worker urls. MV3 registers one worker per loaded extension. +async function extensionIds(ctx, expected) { + const deadline = Date.now() + 30000; + for (;;) { + const ids = [ + ...new Set(ctx.serviceWorkers().map((w) => new URL(w.url()).host)), + ].sort(); + if (ids.length >= expected) return ids; + if (Date.now() > deadline) return ids; + await new Promise((r) => setTimeout(r, 200)); + } +} + +// Open one extension's popup and run `fn` in it. The popup page is used rather +// than the service worker because Chrome stops an idle MV3 worker, and a +// handle to a stopped worker cannot be evaluated in. +async function inExtension(ctx, id, fn, arg) { + const page = await ctx.newPage(); + try { + await page.goto("chrome-extension://" + id + "/src/popup/index.html"); + return await page.evaluate(fn, arg); + } finally { + await page.close(); + } +} + +const writeSentinel = ([key, value]) => + new Promise((resolve) => { + chrome.storage.local.set({ [key]: value }, () => resolve(true)); + }); + +const readSentinel = (key) => + new Promise((resolve) => { + chrome.storage.local.get(key, (r) => resolve(r[key] ?? null)); + }); + +// Load `firstDir` in a fresh profile, write the sentinel, close; load +// `secondDir` in the SAME profile, read it back. Returns both ids and what the +// second load saw. +async function acrossTwoPaths(tag, firstDir, secondDir) { + const profile = tmpdir(tag + "-profile"); + + const first = await launch(profile, [firstDir]); + let firstId; + try { + [firstId] = await extensionIds(first, 1); + if (!firstId) throw new Error("no extension loaded from " + firstDir); + await inExtension(first, firstId, writeSentinel, [ + SENTINEL_KEY, + SENTINEL_VALUE, + ]); + } finally { + await first.close(); + } + + const second = await launch(profile, [secondDir]); + let secondId; + let seen; + try { + [secondId] = await extensionIds(second, 1); + if (!secondId) throw new Error("no extension loaded from " + secondDir); + seen = await inExtension(second, secondId, readSentinel, SENTINEL_KEY); + } finally { + await second.close(); + } + + return { firstId, secondId, seen }; +} + +async function main() { + if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) { + console.error( + "storagePartition: no unpacked build at " + + DIST_CHROME + + " — run make build first", + ); + process.exitCode = 1; + return; + } + + try { + // --- the shipped manifest, which carries `key` ---------------------- + const keyedA = extensionCopy("keyed-a", true); + const keyedB = extensionCopy("keyed-b", true); + const keyed = await acrossTwoPaths("keyed", keyedA, keyedB); + console.log("# with `key`: " + keyedA + " -> " + keyed.firstId); + console.log("# with `key`: " + keyedB + " -> " + keyed.secondId); + console.log("# with `key`: sentinel read back: " + keyed.seen); + + check( + "with `key`, two different paths produce the SAME extension id", + keyed.firstId === keyed.secondId, + keyed.firstId + " != " + keyed.secondId, + ); + check( + "with `key`, the second path reads the first path's storage", + keyed.seen === SENTINEL_VALUE, + "the second load read " + + JSON.stringify(keyed.seen) + + " instead of the value the first load wrote. The storage " + + "partition did not follow the extension across the move, " + + "which is the wallet silently reading as empty.", + ); + + // --- the same build with `key` removed: the control ----------------- + const barePath = extensionCopy("bare-a", false); + const bareB = extensionCopy("bare-b", false); + const bare = await acrossTwoPaths("bare", barePath, bareB); + console.log("# without `key`: " + barePath + " -> " + bare.firstId); + console.log("# without `key`: " + bareB + " -> " + bare.secondId); + console.log("# without `key`: sentinel read back: " + bare.seen); + + // Observed, not assumed. If Chrome ever stops deriving the id from + // the load path these two fail, and the right response is to record + // what it does now — not to delete them. + check( + "without `key`, two different paths produce DIFFERENT ids " + + "(observed Chrome behaviour, the defect this fixes)", + bare.firstId !== bare.secondId, + "both loads got " + + bare.firstId + + ", so the id no longer follows the load path on this Chrome", + ); + check( + "without `key`, the second path sees an EMPTY partition " + + "(observed Chrome behaviour, the defect this fixes)", + bare.seen === null, + "the second load read " + + JSON.stringify(bare.seen) + + " from a different id's partition", + ); + + // --- both copies loaded at once, in one profile --------------------- + // The literal shape of the question, kept because "two unpacked loads + // in one profile" is what a user does when they forget to remove the + // old one. With `key`, both copies claim the same id. + const profile = tmpdir("simultaneous-profile"); + const ctx = await launch(profile, [keyedA, keyedB]); + let ids = []; + try { + ids = await extensionIds(ctx, 2); + } finally { + await ctx.close(); + } + console.log( + "# both keyed copies loaded at once: " + + ids.length + + " extension id(s): " + + ids.join(", "), + ); + check( + "loading both keyed copies at once yields one id, not two " + + "(observed: Chrome does not load a second copy of an id it " + + "already has)", + ids.length === 1 && ids[0] === keyed.firstId, + "saw " + JSON.stringify(ids) + ", expected exactly one id", + ); + } catch (e) { + failed += 1; + console.log("# ERROR: " + (e && e.stack ? e.stack : e)); + } finally { + for (const dir of scratch) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } + + console.log("1.." + checks.length); + if (checks.length === 0) { + console.log("# FAILED: this program asserted nothing"); + process.exitCode = 1; + return; + } + console.log( + "# " + + (checks.length - failed) + + "/" + + checks.length + + " checks passed", + ); + if (failed > 0) { + console.log("# FAILED"); + process.exitCode = 1; + } +} + +main().catch((e) => { + console.error("storagePartition: " + (e && e.stack ? e.stack : e)); + process.exitCode = 1; +}); diff --git a/tests/extensionId.test.js b/tests/extensionId.test.js new file mode 100644 index 0000000..58b5c32 --- /dev/null +++ b/tests/extensionId.test.js @@ -0,0 +1,119 @@ +// The extension identity on both browsers, pinned. +// +// This is the anti-regression check for +// https://git.eeqj.de/sneak/AutistMask/issues/310. An unpacked Chrome +// extension with no `key` in its manifest gets an id derived from the +// ABSOLUTE PATH it was loaded from, and chrome.storage.local is partitioned by +// that id. Move the checkout, re-clone it, or load it from a second directory, +// and the wallet is silently gone: the extension comes up on a fresh, empty +// storage partition with no error anywhere. `key` pins the id to the public +// key instead of to the path, which is what makes the storage survive. +// +// So the id is asserted as a literal. A test that merely recomputed the id +// from whatever `key` happened to be in the manifest would pass after someone +// replaced the key — and replacing the key is exactly the change that orphans +// every existing wallet. The value below is the promise; changing it is a +// migration, not an edit. +// +// Firefox needs no key: browser_specific_settings.gecko.id declares the id +// directly, and it is pinned here for the same reason. The Firefox e2e suite +// depends on it too (tests/e2e/firefox/driver.js maps it to a fixed uuid), and +// tests/e2e/firefox/reinstall.js is the empirical half — it removes the add-on +// and installs it again and reads the vault back out. + +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const MANIFEST_DIR = path.join(__dirname, "..", "manifest"); + +// The public half of an RSA keypair, DER-encoded SubjectPublicKeyInfo, base64. +// The PRIVATE half is not in this repo and is not needed to build, load or +// test anything here: it is only ever used to sign a CRX, which this repo does +// not do. +const CHROME_KEY = + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+" + + "5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUU" + + "YzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35" + + "hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kL" + + "WoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW" + + "+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB"; + +// chrome.storage.local for this extension lives under this id, and nowhere +// else. +const CHROME_EXTENSION_ID = "gipbhkogfopeahplcjhipkgpcimdpkip"; + +const FIREFOX_EXTENSION_ID = "autistmask@sneak.berlin"; + +// Chrome's id derivation: sha256 of the DER public key, first 16 bytes, each +// hex digit mapped 0-f onto a-p. Written out here rather than taken on trust, +// because the whole claim of this file is that the committed key produces that +// id. +function chromeExtensionId(keyBase64) { + const der = Buffer.from(keyBase64, "base64"); + const digest = crypto.createHash("sha256").update(der).digest("hex"); + return [...digest.slice(0, 32)] + .map((c) => String.fromCharCode(97 + parseInt(c, 16))) + .join(""); +} + +function readManifest(name) { + return JSON.parse( + fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"), + ); +} + +describe("chrome extension identity", () => { + test("the manifest carries the pinned key", () => { + expect(readManifest("chrome").key).toBe(CHROME_KEY); + }); + + test("the key is a well-formed RSA public key", () => { + const der = Buffer.from(CHROME_KEY, "base64"); + // Round-trips: a truncated or re-wrapped base64 blob would still + // decode to bytes, and Chrome would then derive an id from garbage. + expect(der.toString("base64")).toBe(CHROME_KEY); + const key = crypto.createPublicKey({ + key: der, + format: "der", + type: "spki", + }); + expect(key.asymmetricKeyType).toBe("rsa"); + expect(key.asymmetricKeyDetails.modulusLength).toBe(2048); + }); + + test("the key derives the pinned extension id", () => { + expect(chromeExtensionId(CHROME_KEY)).toBe(CHROME_EXTENSION_ID); + expect(CHROME_EXTENSION_ID).toMatch(/^[a-p]{32}$/); + }); + + // The private half is a credential. It has never been in this repo and no + // target generates one into the working tree; this fails loudly if that + // ever changes, because a committed .pem is a key anyone can sign a CRX + // with under this extension's id. + test("no private key is committed anywhere in the tree", () => { + const tracked = require("child_process") + .execSync("git ls-files", { + cwd: path.join(__dirname, ".."), + encoding: "utf8", + }) + .split("\n") + .filter(Boolean); + expect(tracked.filter((f) => /\.(pem|key|p12|pfx)$/i.test(f))).toEqual( + [], + ); + }); +}); + +describe("firefox extension identity", () => { + test("the manifest declares the pinned gecko id", () => { + const gecko = readManifest("firefox").browser_specific_settings.gecko; + expect(gecko.id).toBe(FIREFOX_EXTENSION_ID); + }); + + // Firefox derives nothing from the path, so no key field belongs here; one + // would be ignored and would only suggest the id came from somewhere else. + test("the firefox manifest carries no chrome key field", () => { + expect(readManifest("firefox").key).toBeUndefined(); + }); +}); diff --git a/tests/packaging.test.js b/tests/packaging.test.js new file mode 100644 index 0000000..cb87836 --- /dev/null +++ b/tests/packaging.test.js @@ -0,0 +1,190 @@ +// The release archives: the zip container, and the self-containment rule. +// +// script/package produces one archive per browser and script/lib/package.js +// decides what goes in it. The trap that rule exists for is real and specific: +// build.js writes the compiled stylesheet to dist/styles.css at the dist/ +// ROOT, outside both browser directories, and copies it into each of them as +// src/popup/styles.css. A `zip -r dist/chrome` is correct only because of that +// copy, and would silently start shipping a popup with no stylesheet the +// moment a reference pointed up and out of the directory. +// +// So the packager resolves every reference in the manifest and in every HTML +// document, and fails on any that leaves the extension root. These are the +// cases for that, plus the archive format itself — new code, and the thing the +// artifact is made of. + +const { + checkSelfContained, + htmlReferences, + manifestReferences, +} = require("../script/lib/package"); +const { readZip, writeZip } = require("../script/lib/zip"); + +function archiveOf(files) { + return { + members: Object.keys(files).sort(), + read: (name) => Buffer.from(files[name] ?? "", "utf8"), + }; +} + +const MINIMAL_MANIFEST = { + manifest_version: 3, + name: "AutistMask", + version: "0.1.0", + action: { default_popup: "src/popup/index.html" }, + background: { service_worker: "src/background/index.js" }, +}; + +describe("archive self-containment", () => { + test("a complete tree passes", () => { + const { members, read } = archiveOf({ + "manifest.json": JSON.stringify(MINIMAL_MANIFEST), + "src/popup/index.html": + '' + + '', + "src/popup/styles.css": "body{}", + "src/popup/index.js": "//", + "src/background/index.js": "//", + }); + expect(() => checkSelfContained("chrome", members, read)).not.toThrow(); + }); + + test("a manifest naming a file that is not in the archive fails", () => { + const { members, read } = archiveOf({ + "manifest.json": JSON.stringify(MINIMAL_MANIFEST), + "src/popup/index.html": "", + "src/popup/index.js": "//", + }); + expect(() => checkSelfContained("chrome", members, read)).toThrow( + /would not be self-contained.*src\/background\/index\.js/s, + ); + }); + + // The dist/styles.css case, exactly: a popup that reached up out of its + // own browser directory for the stylesheet the build leaves at the dist/ + // root. Nothing would be missing from disk, and the zip would still be + // built — the archive would just have no stylesheet in it. + test("an HTML reference that escapes the extension root fails", () => { + const { members, read } = archiveOf({ + "manifest.json": JSON.stringify(MINIMAL_MANIFEST), + "src/popup/index.html": + '', + "src/popup/index.js": "//", + "src/background/index.js": "//", + }); + expect(() => checkSelfContained("chrome", members, read)).toThrow( + /resolves outside the extension root/, + ); + }); + + test("a manifest reference that escapes the extension root fails", () => { + const { members, read } = archiveOf({ + "manifest.json": JSON.stringify({ + ...MINIMAL_MANIFEST, + background: { service_worker: "../shared/index.js" }, + }), + "src/popup/index.html": "", + }); + expect(() => checkSelfContained("chrome", members, read)).toThrow( + /points outside the extension root/, + ); + }); + + test("an archive with no manifest.json at its root fails", () => { + const { members, read } = archiveOf({ "src/popup/index.js": "//" }); + expect(() => checkSelfContained("chrome", members, read)).toThrow( + /no manifest\.json at its root/, + ); + }); + + test("manifest strings that are not paths are not treated as files", () => { + const found = manifestReferences({ + name: "AutistMask", + version: "0.1.0", + permissions: ["storage", ""], + content_security_policy: { + extension_pages: "default-src 'self'; script-src 'self'", + }, + key: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy", + }); + expect([...found]).toEqual([]); + }); + + test("absolute, data: and anchor HTML references are not files", () => { + const refs = htmlReferences( + "src/popup/index.html", + 'a' + + '' + + 'b' + + '', + ); + expect([...refs]).toEqual(["src/popup/index.js"]); + }); +}); + +describe("the zip container", () => { + const files = [ + { name: "manifest.json", data: Buffer.from('{"a":1}') }, + // Long enough that deflate wins, so both paths through compress() are + // exercised by one archive. + { name: "src/popup/index.js", data: Buffer.from("x".repeat(5000)) }, + { name: "empty.txt", data: Buffer.alloc(0) }, + ]; + + test("round-trips every member byte for byte", () => { + const entries = readZip(writeZip(files)); + expect(entries.map((e) => e.name)).toEqual([ + "empty.txt", + "manifest.json", + "src/popup/index.js", + ]); + for (const original of files) { + const found = entries.find((e) => e.name === original.name); + expect(found.data.equals(original.data)).toBe(true); + } + }); + + // The sha256 in release/SHA256SUMS has to be a property of the input. Two + // builds of one commit that produce different archives cannot be compared + // to each other, which is most of what publishing a digest is for. + test("is byte-identical across runs and independent of input order", () => { + const a = writeZip(files); + const b = writeZip([...files].reverse()); + expect(a.equals(b)).toBe(true); + }); + + // Reading an archive back is how script/lib/package.js establishes that + // the artifact holds what dist/ holds, so a member whose bytes changed + // after it was written has to fail rather than be handed back. + test("a corrupted member fails its CRC on read", () => { + // One member, incompressible at this size, so it is stored verbatim + // and its bytes begin at a known offset: local header (30) + name. + const name = "a.js"; + const archive = writeZip([{ name, data: Buffer.from("hello") }]); + expect(readZip(archive)[0].data.toString()).toBe("hello"); + archive[30 + name.length] ^= 0xff; + expect(() => readZip(archive)).toThrow(/fails its recorded CRC32/); + }); + + test.each([["/abs.js"], ["../up.js"], ["a/../b.js"], ["a\\b.js"], [""]])( + "refuses the member name %p", + (name) => { + expect(() => writeZip([{ name, data: Buffer.from("x") }])).toThrow( + /member name/, + ); + }, + ); + + test("refuses an archive with no members", () => { + expect(() => writeZip([])).toThrow(/no members/); + }); + + test("refuses duplicate members", () => { + expect(() => + writeZip([ + { name: "a.js", data: Buffer.from("1") }, + { name: "a.js", data: Buffer.from("2") }, + ]), + ).toThrow(/duplicate member/); + }); +}); diff --git a/tests/version.test.js b/tests/version.test.js new file mode 100644 index 0000000..d1a629a --- /dev/null +++ b/tests/version.test.js @@ -0,0 +1,97 @@ +// One version, three files, and the rule that they agree. +// +// package.json, manifest/chrome.json and manifest/firefox.json each declare a +// version and none is derived from another. Before this, nothing compared +// them: the manifests were hardcoded at 0.1.0 and copied to dist/ verbatim +// while package.json fed the About screen separately, so the number the +// browser reported and the number the extension displayed could drift apart +// with no check anywhere failing. +// +// The build enforces the agreement (build.js calls resolveVersion before it +// emits anything) and script/lib/package.js names the artifacts from it. This +// asserts both halves: that the tree as committed agrees, and that a tree that +// does not is refused rather than resolved to one of the answers. + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const { + VERSION_SOURCES, + declaredVersions, + resolveVersion, +} = require("../script/lib/version"); + +const ROOT = path.join(__dirname, ".."); + +// A tree containing only the three version files, with the given versions. +function fixture(versions) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-version-")); + fs.mkdirSync(path.join(dir, "manifest")); + VERSION_SOURCES.forEach((source, i) => { + fs.writeFileSync( + path.join(dir, source), + JSON.stringify({ version: versions[i] }), + ); + }); + return dir; +} + +describe("the declared version", () => { + test("all three sources agree in this tree", () => { + const declared = declaredVersions(ROOT); + expect(declared.map((d) => d.source)).toEqual(VERSION_SOURCES); + expect([...new Set(declared.map((d) => d.version))]).toHaveLength(1); + expect(resolveVersion(ROOT)).toBe(declared[0].version); + }); + + // Semver-shaped, because it names every release artifact and is what the + // browser compares when deciding whether an install is an upgrade. + test("is semver-shaped", () => { + expect(resolveVersion(ROOT)).toMatch(/^\d+\.\d+\.\d+$/); + }); + + // The load-bearing case: each of the three, disagreeing on its own, has to + // fail. A check that only looked at two of them would pass one of these. + test.each([ + ["package.json", ["9.9.9", "0.1.0", "0.1.0"]], + ["manifest/chrome.json", ["0.1.0", "9.9.9", "0.1.0"]], + ["manifest/firefox.json", ["0.1.0", "0.1.0", "9.9.9"]], + ])("a disagreeing %s fails rather than resolving", (source, versions) => { + const dir = fixture(versions); + try { + expect(() => resolveVersion(dir)).toThrow( + /the declared versions disagree/, + ); + expect(() => resolveVersion(dir)).toThrow(new RegExp(source)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test("agreeing sources resolve", () => { + const dir = fixture(["1.2.3", "1.2.3", "1.2.3"]); + try { + expect(resolveVersion(dir)).toBe("1.2.3"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + // A missing or empty version is not agreement. Left unchecked, three files + // that all declared nothing would "agree" on undefined and the artifacts + // would be named after it. + test.each([[undefined], [""], [" "], [3]])( + "a version of %p is refused", + (bad) => { + const dir = fixture([bad, bad, bad]); + try { + expect(() => resolveVersion(dir)).toThrow( + /declares no usable "version"/, + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }, + ); +});