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 }); + } + }, + ); +});