release: package the extension, pin the Chrome extension id, and prove the wallet survives a reinstall (closes #310) #347

Merged
clawbot merged 1 commits from release/310-packaging-artifacts into next 2026-08-23 16:13:23 +02:00
21 changed files with 2142 additions and 42 deletions

View File

@@ -4,3 +4,4 @@
node_modules node_modules
.DS_Store .DS_Store
dist dist
release

3
.gitignore vendored
View File

@@ -23,6 +23,9 @@ node_modules/
# Build output # Build output
dist/ dist/
# Release artifacts (make package). Derived from dist/, never committed.
release/
# Yarn # Yarn
.yarn-integrity .yarn-integrity
package-lock.json package-lock.json

View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
yarn.lock yarn.lock
dist/ dist/
release/
.claude/ .claude/

View File

@@ -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/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -41,6 +41,9 @@ check:
check-censored: check-censored:
@script/check-censored @script/check-censored
package:
@script/package
docker: docker:
@script/docker @script/docker
@@ -102,7 +105,7 @@ vendor-blocklist:
@script/vendor-blocklist @script/vendor-blocklist
clean: clean:
@rm -rf dist/ @rm -rf dist/ release/
dev: dev:
@echo "Building in watch mode..." @echo "Building in watch mode..."

153
README.md
View File

@@ -44,7 +44,107 @@ Load the extension:
- **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click - **Chrome**: Navigate to `chrome://extensions/`, enable "Developer mode", click
"Load unpacked", and select the `dist/chrome/` directory. "Load unpacked", and select the `dist/chrome/` directory.
- **Firefox**: Navigate to `about:debugging#/runtime/this-firefox`, click "Load - **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-<version>.zip
release/autistmask-firefox-<version>.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=<path to the .pem>`.
### 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 ### Debug Builds
@@ -146,6 +246,11 @@ provide:
fails anywhere else. Part of `make check`, which inspects `dist/` when there 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 is one and says loudly when there is not; `make build` re-runs it with
`--require-dist`, so a build artifact is always covered `--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 - `script/vendor-blocklist` — refresh `src/shared/phishingBlocklist.json` from
its upstream, pinned to a commit and to the sha256 of the bytes that commit 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 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 - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a
debug build, and keeping its `dist/` on failure (see debug build, and keeping its `dist/` on failure (see
[Debug Builds](#debug-builds)) [Debug Builds](#debug-builds))
- `make clean` — remove `dist/` - `make clean` — remove `dist/` and `release/`
- `make dev` — build in watch mode - `make dev` — build in watch mode
## End-to-End Tests ## 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 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. 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`) ### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `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 `eth_sendRawTransaction` with the hash `ethers` computes for the artifact it
sent, or `provider.broadcastTransaction()` refuses the answer. 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 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 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` of a published Playwright image, whereas this one is assembled from a `node`

34
TODO.md
View File

@@ -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 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 scope by the issue's definition of done. `README.md`'s Display Consistency
section records the exception. 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-<version>.zip` and
`release/autistmask-firefox-<version>.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 - 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 ([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
the entire state blob, and every extension page — the toolbar popup, a dApp the entire state blob, and every extension page — the toolbar popup, a dApp

View File

@@ -3,6 +3,7 @@ const path = require("path");
const crypto = require("crypto"); const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
const { resolveVersion } = require("./script/lib/version");
const DIST = path.join(__dirname, "dist"); const DIST = path.join(__dirname, "dist");
const DIST_CHROME = path.join(DIST, "chrome"); const DIST_CHROME = path.join(DIST, "chrome");
@@ -160,28 +161,53 @@ function isDebugBuild() {
return process.env.AUTISTMASK_DEBUG === "1"; 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() { function getBuildInfo() {
const pkg = JSON.parse( const pkg = JSON.parse(
fs.readFileSync(path.join(__dirname, "package.json"), "utf8"), fs.readFileSync(path.join(__dirname, "package.json"), "utf8"),
); );
let commitHash = "unknown"; const commitHashFull = git("rev-parse HEAD") || "unknown";
try { const shortHash = git("rev-parse --short HEAD") || "unknown";
commitHash = execSync("git rev-parse --short HEAD", { // The full hash is left clean because it is the href of the commit link in
encoding: "utf8", // the About screen, and "abc123-dirty" is not a commit anyone can fetch.
}).trim(); // The displayed short hash carries the marker, so the screen says the tree
} catch { // was modified while still linking somewhere real.
// not a git repo or git not available const commitHash =
} shortHash === "unknown" ? shortHash : shortHash + worktreeSuffix();
let commitHashFull = "unknown";
try {
commitHashFull = execSync("git rev-parse HEAD", {
encoding: "utf8",
}).trim();
} catch {
// not a git repo or git not available
}
return { 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, license: pkg.license,
author: pkg.author, author: pkg.author,
commitHash, commitHash,

View File

@@ -3,6 +3,7 @@
"name": "AutistMask", "name": "AutistMask",
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzy/G9gT4Z3Ci0HCmthUPEiCjENg+5meZpjdogyT7SiMfxENtHdrpDL6wGhAg1Dk0f1C67Ft8OYpMrMH3kiP2Wnt0UpHo45PY0YUUYzdJgbsp8u0kaykd5FFiY6FycIIFaTniMuh7wRKuNNdJWly+H3aG7qZ6nGu5PIMdb1GXUk35hY+yl7dz5dqFFYUCyxvWCT9XGBSYiI+XRBB/rVZjMWfWpaTmRPdOZ4+GO/Lx0OdMxKlPA/kLWoPot5vMlLn2FDPu6sASphiu7dKZnrINW+h/27jlHMJQS0jncB1EgqOHW0vbXrZnTveFX6UW+Qp86FfSkikhKtQgTW2A4mtWawIDAQAB",
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "content_security_policy": {

294
script/lib/package.js Normal file
View File

@@ -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-<version>.zip loaded via chrome://extensions
// release/autistmask-firefox-<version>.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, "<all_urls>", 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 };

74
script/lib/version.js Normal file
View File

@@ -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 };

274
script/lib/zip.js Normal file
View File

@@ -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 };

38
script/package Executable file
View File

@@ -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 "$@"

View File

@@ -78,6 +78,22 @@ main() {
-e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \ -e "E2E_TRACE_NETWORK=${E2E_TRACE_NETWORK:-0}" \
"$(cat "$IIDFILE")" \ "$(cat "$IIDFILE")" \
node tests/e2e/run.js 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 "$@" main "$@"

View File

@@ -72,6 +72,19 @@ main() {
-e HOME=/tmp \ -e HOME=/tmp \
"$(cat "$IIDFILE")" \ "$(cat "$IIDFILE")" \
node tests/e2e/firefox/run.js dist/firefox 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 "$@" main "$@"

View File

@@ -72,6 +72,11 @@ RUN script/bootstrap
COPY . . 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"] CMD ["node", "tests/e2e/firefox/run.js", "dist/firefox"]

View File

@@ -103,7 +103,13 @@ class Driver {
// ------------------------------------------------------------ setup // ------------------------------------------------------------ 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 = { const prefs = {
// See EXTENSION_UUID above. The pref is a string pref whose // See EXTENSION_UUID above. The pref is a string pref whose
// value is itself JSON. // value is itself JSON.
@@ -132,26 +138,27 @@ class Driver {
"extensions.openPopupWithoutUserGesture.enabled": false, "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", { const value = await this.send("POST", "/session", {
capabilities: { capabilities: {
alwaysMatch: { alwaysMatch: {
browserName: "firefox", browserName: "firefox",
"moz:firefoxOptions": { "moz:firefoxOptions": {
binary: FIREFOX_BIN, binary: FIREFOX_BIN,
args: [ 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",
],
prefs, prefs,
}, },
}, },
@@ -162,16 +169,30 @@ class Driver {
return value; return value;
} }
// Installs the unpacked MV2 build straight from a directory. // Installs the MV2 build, either from an unpacked directory or from an
// temporary:true bypasses signature checks, so no XPI and no signing // XPI file. temporary:true bypasses signature checks — which is the only
// are involved, and the add-on dies with the profile. // way an UNSIGNED xpi installs at all, and the reason README.md says
async installAddon(dir) { // 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", { return this.session("POST", "/moz/addon/install", {
path: dir, path: pathToAddon,
temporary: true, 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 // Classic navigation on purpose. BiDi's browsingContext.navigate
// refuses moz-extension:// URLs outright. // refuses moz-extension:// URLs outright.
async navigate(url) { async navigate(url) {

View File

@@ -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;
});

View File

@@ -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;
});

119
tests/extensionId.test.js Normal file
View File

@@ -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();
});
});

190
tests/packaging.test.js Normal file
View File

@@ -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":
'<link rel="stylesheet" href="styles.css" />' +
'<script src="index.js"></script>',
"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": "<html></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":
'<link rel="stylesheet" href="../../../styles.css" />',
"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": "<html></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", "<all_urls>"],
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 href="https://example.com/x.js">a</a>' +
'<img src="data:image/png;base64,AAAA" />' +
'<a href="#view-main">b</a>' +
'<script src="index.js"></script>',
);
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/);
});
});

97
tests/version.test.js Normal file
View File

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