All checks were successful
check / check (push) Successful in 24s
libsodium ships a WASM build and a wasm2js translation in one file, tries WASM first, and silently falls back if instantiation throws. Under a plain script-src 'self' the fallback was taken on every popup load, announced by nothing but an uncaught CompileError. Measured on the vault's own Argon2id parameters (OPSLIMIT_INTERACTIVE, MEMLIMIT_INTERACTIVE), node 22: WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is identical either way — it is set by the ops and memory parameters, not by wall time — so the fallback bought no security and cost about 3.5s on every operation that asks for the password, which is every signature. Both manifests now declare script-src 'self' 'wasm-unsafe-eval'; object-src 'self' for extension pages: an object under content_security_policy.extension_pages for Chrome MV3, a bare string for Firefox MV2. The keyword permits compiling WebAssembly and nothing else — not eval() of strings, not inline script, not remote script — and reaching it requires already executing script in an extension page. 'unsafe-eval' is not granted. The silence is what made this dangerous, so the fallback is now loud at three levels: tests/manifest.test.js pins both policies to exactly that token set, failing make check if the grant is dropped or if anything is added beside it; tests/vaultBackend.test.js asserts the unit tests exercise the WASM backend, with a self-validating check that libsodium never swapped its fallback in; and the e2e suite compiles a WebAssembly module inside the real popup under the real manifest, with the harness allowlist entry that used to excuse the CompileError now deleted. The runtime fallback itself is kept — a wallet that refuses to decrypt is worse than a slow one — but vault.js now reports the backend and logs an error when it is not WASM.
305 lines
13 KiB
JavaScript
305 lines
13 KiB
JavaScript
// End-to-end harness: launches a real Chromium with the unpacked MV3
|
|
// build loaded, collects every uncaught page error and console.error, and
|
|
// exposes the popup flows the tests drive.
|
|
//
|
|
// This runs inside the pinned Playwright container; see script/test-e2e.
|
|
// It is deliberately NOT part of make check — REPO_POLICIES.md caps
|
|
// make test at 20 seconds and a browser suite does not fit.
|
|
|
|
"use strict";
|
|
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
|
|
const { chromium } = require("playwright-core");
|
|
const { installNetworkStubs } = require("./network");
|
|
|
|
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome");
|
|
|
|
// Page errors that are known, tracked, and deliberately tolerated. Every
|
|
// entry must name the issue that will remove it. This list is the one
|
|
// concession in an otherwise zero-tolerance policy: an uncaught error is
|
|
// how this harness caught issue #150 in the first place.
|
|
//
|
|
// Empty, and worth keeping that way. Its only entry was the WASM
|
|
// CompileError libsodium provoked on every popup load, deleted with #182
|
|
// when both manifests started allowing WASM; the run that used to need it
|
|
// is now the run that proves the fix.
|
|
const ALLOWED_ERRORS = [];
|
|
|
|
function isAllowed(text) {
|
|
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
|
|
}
|
|
|
|
// Collects every uncaught page error, console.error and unstubbed
|
|
// request, and hands each one to exactly one reporter.
|
|
//
|
|
// This deliberately has NO window API. It used to expose mark()/since()
|
|
// so a test could ask for "the errors since I started", and that shape
|
|
// produced a green run that proved nothing twice over: first the mark
|
|
// started after test 1, so everything recorded during launch was
|
|
// discarded, then the tail after the final test was never read at all. In
|
|
// both cases a record fell outside somebody's window and vanished, which
|
|
// is the precise failure this harness exists to prevent.
|
|
//
|
|
// So there is no window left to fall outside of. take() is the only
|
|
// reader and it always takes everything outstanding, so successive takes
|
|
// partition the entire record stream with no gaps, and the runner turns
|
|
// every record it reads into a failure.
|
|
//
|
|
// Observation ends when the browser context is closed. Nothing records
|
|
// after that — the route handler and the console listeners are gone with
|
|
// the context — so there is no post-teardown phase to collect, and this
|
|
// class deliberately offers no mechanism pretending to cover one.
|
|
class ErrorCollector {
|
|
constructor() {
|
|
this.entries = [];
|
|
this.taken = 0;
|
|
}
|
|
|
|
record(kind, text) {
|
|
const line = kind + ": " + String(text).split("\n")[0];
|
|
if (isAllowed(line)) return;
|
|
this.entries.push(line);
|
|
}
|
|
|
|
// Everything recorded since the previous take(). Never yields a
|
|
// record twice and never skips one.
|
|
take() {
|
|
const out = this.entries.slice(this.taken);
|
|
this.taken = this.entries.length;
|
|
return out;
|
|
}
|
|
}
|
|
|
|
function attachErrorListeners(ctx, errors) {
|
|
const attachPage = (page) => {
|
|
page.on("pageerror", (err) => {
|
|
errors.record("pageerror", err.message || String(err));
|
|
});
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") {
|
|
errors.record("console.error", msg.text());
|
|
}
|
|
});
|
|
};
|
|
ctx.pages().forEach(attachPage);
|
|
ctx.on("page", attachPage);
|
|
// Per-page listeners only: the context-level "weberror" event covers
|
|
// the same page exceptions and would double-report them. Playwright
|
|
// exposes no error EVENT for service workers, so an uncaught
|
|
// exception in the background worker is not visible here — everything
|
|
// this suite drives lives in the popup page. That is an error-channel
|
|
// gap only: worker NETWORK traffic is intercepted and reported like
|
|
// any other, and assertWorkerTrafficIntercepted() below fails the run
|
|
// if it ever stops being.
|
|
}
|
|
|
|
async function serviceWorker(ctx) {
|
|
const [existing] = ctx.serviceWorkers();
|
|
if (existing) return existing;
|
|
return ctx.waitForEvent("serviceworker", { timeout: 30000 });
|
|
}
|
|
|
|
// How long to wait for the background worker's first outbound request.
|
|
//
|
|
// The margin that actually decides whether this check is sound is not
|
|
// this timeout — it is whether the route handler is installed before the
|
|
// worker fetches. Measured over several runs: route installation
|
|
// completes 11-23ms after the context comes up, and the worker's
|
|
// blocklist fetch arrives 525-883ms after that, so the route wins by
|
|
// roughly 25-50x. This 30s figure is only slack for a loaded machine on
|
|
// top of that; losing the race fails the run rather than passing it
|
|
// quietly, which was verified by forcing a 3s delay before route
|
|
// installation.
|
|
const WORKER_TRAFFIC_TIMEOUT_MS = 30000;
|
|
|
|
// ctx.route() only sees service-worker requests when Playwright runs with
|
|
// PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, which script/test-e2e
|
|
// sets. Without it the worker's traffic — notably the phishing blocklist
|
|
// fetch src/background/index.js issues at startup — goes to the real
|
|
// internet, and nothing says so, because src/shared/phishingDomains.js
|
|
// swallows fetch failures. A harness whose isolation can lapse in silence
|
|
// is worthless, so this does not take the flag on trust: the background
|
|
// worker's own startup fetch has to show up in the route handler, or the
|
|
// suite refuses to run.
|
|
//
|
|
// Deliberately NOT a synthetic probe fetched through worker.evaluate():
|
|
// evaluating in an extension worker this early kills it (the call fails
|
|
// with "Target page, context or browser has been closed" and the worker
|
|
// disappears), which would break the very thing being measured. Observing
|
|
// traffic the extension already generates costs nothing and cannot
|
|
// perturb it.
|
|
async function assertWorkerTrafficIntercepted(stubs) {
|
|
const seen = await stubs.waitForServiceWorkerTraffic(
|
|
WORKER_TRAFFIC_TIMEOUT_MS,
|
|
);
|
|
if (seen) return seen;
|
|
|
|
// State the observation, not a conclusion. This fires for at least
|
|
// two quite different causes and the harness cannot tell them apart
|
|
// from here, so guessing one of them in the message sends the reader
|
|
// the wrong way.
|
|
throw new Error(
|
|
"observed no service-worker request in the route handler within " +
|
|
WORKER_TRAFFIC_TIMEOUT_MS +
|
|
"ms. Under working interception the background worker's " +
|
|
"startup blocklist fetch (src/background/index.js) reaches the " +
|
|
"handler about half a second after the route is installed. " +
|
|
"Two causes are plausible and this check cannot distinguish " +
|
|
"them: (1) service-worker interception is not in effect, so " +
|
|
"that traffic went to the real internet unobserved — the suite " +
|
|
"must be run through script/test-e2e, which sets " +
|
|
"PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1, and a " +
|
|
"Playwright upgrade may have dropped or renamed that flag; " +
|
|
"(2) no worker request was made in the first place — the route " +
|
|
"lost the startup race, or the worker no longer fetches at " +
|
|
"startup, in which case this check needs a new anchor because " +
|
|
"there is no longer any worker traffic to observe. Either way " +
|
|
"the fix is a replacement mechanism or an honest downgrade of " +
|
|
"the isolation claims in tests/e2e/network.js and README.md — " +
|
|
"not deleting this check",
|
|
);
|
|
}
|
|
|
|
async function launch(routeOpts) {
|
|
if (!fs.existsSync(path.join(EXT_PATH, "manifest.json"))) {
|
|
throw new Error(
|
|
"no unpacked build at " +
|
|
EXT_PATH +
|
|
" — run make build before the e2e suite",
|
|
);
|
|
}
|
|
|
|
const userDir = fs.mkdtempSync(path.join(os.tmpdir(), "autistmask-e2e-"));
|
|
const ctx = await chromium.launchPersistentContext(userDir, {
|
|
// channel: "chromium" is load-bearing. The default headless mode
|
|
// uses the headless shell, which silently refuses to load
|
|
// extensions: there is no error at all, the service worker simply
|
|
// never appears. This cost real debugging time once already.
|
|
channel: "chromium",
|
|
headless: true,
|
|
args: [
|
|
"--disable-extensions-except=" + EXT_PATH,
|
|
"--load-extension=" + EXT_PATH,
|
|
// The container runs unprivileged; Chrome's sandbox needs
|
|
// capabilities the harness deliberately does not grant it.
|
|
"--no-sandbox",
|
|
// Belt to the interception braces: nothing that slips past
|
|
// the route handler can resolve a name, so a request that
|
|
// escapes cannot actually reach the internet. Detection is
|
|
// still assertWorkerTrafficIntercepted()'s job — this only
|
|
// bounds the damage while a gap goes unnoticed. Playwright
|
|
// fulfils routed requests without touching the resolver, and
|
|
// it drives the browser over a pipe, so neither is affected.
|
|
"--host-resolver-rules=MAP * ~NOTFOUND",
|
|
],
|
|
});
|
|
|
|
const cleanup = async () => {
|
|
await ctx.close().catch(() => {});
|
|
fs.rmSync(userDir, { recursive: true, force: true });
|
|
};
|
|
|
|
try {
|
|
const errors = new ErrorCollector();
|
|
attachErrorListeners(ctx, errors);
|
|
routeOpts.report = (text) => errors.record("network", text);
|
|
const stubs = await installNetworkStubs(ctx, routeOpts);
|
|
|
|
await assertWorkerTrafficIntercepted(stubs);
|
|
|
|
// The extension id is derived from the unpacked path, so it
|
|
// changes and must never be hardcoded. It is the host part of the
|
|
// service worker URL.
|
|
const sw = await serviceWorker(ctx);
|
|
const id = new URL(sw.url()).host;
|
|
|
|
return {
|
|
ctx,
|
|
errors,
|
|
extensionId: id,
|
|
popupUrl: "chrome-extension://" + id + "/src/popup/index.html",
|
|
close: cleanup,
|
|
};
|
|
} catch (e) {
|
|
// Anything that fails after the browser is up has to tear it down
|
|
// on the way out: an orphaned context keeps node alive forever,
|
|
// turning a clean failure into a hung run.
|
|
await cleanup();
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- flows
|
|
|
|
const PASSWORD = "e2e-harness-password";
|
|
|
|
async function visible(page, selector, timeout = 15000) {
|
|
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
}
|
|
|
|
// An empty WebAssembly module: magic number and version header, no
|
|
// sections. Compiling it in the popup asks the one question that decides
|
|
// libsodium's backend — may this realm compile WebAssembly — of the real
|
|
// page under the real shipped manifest, which is the only place the
|
|
// answer can be observed. Kept independent of src/shared/vault.js on
|
|
// purpose: a bundle asked to grade itself proves less than an outside
|
|
// observation of the same realm.
|
|
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
|
|
|
|
async function pageCompilesWasm(page) {
|
|
return page.evaluate(async (bytes) => {
|
|
try {
|
|
await WebAssembly.compile(new Uint8Array(bytes));
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}, EMPTY_WASM_MODULE);
|
|
}
|
|
|
|
async function openPopup(ctx, popupUrl) {
|
|
const page = await ctx.newPage();
|
|
await page.goto(popupUrl);
|
|
return page;
|
|
}
|
|
|
|
// Full wallet creation through the real UI: BIP-39 generation, libsodium
|
|
// vault encryption and extension storage persistence, for real.
|
|
async function createWallet(page) {
|
|
await page.click("#btn-welcome-add");
|
|
await visible(page, "#view-add-wallet");
|
|
await page.click("#btn-generate-phrase");
|
|
await page.waitForFunction(() => {
|
|
const el = document.getElementById("wallet-mnemonic");
|
|
return el && el.value.trim().split(/\s+/).length >= 12;
|
|
});
|
|
await page.fill("#add-wallet-password", PASSWORD);
|
|
await page.fill("#add-wallet-password-confirm", PASSWORD);
|
|
await page.click("#btn-add-wallet-confirm");
|
|
await visible(page, "#view-main", 60000);
|
|
}
|
|
|
|
// Reach the address detail screen from wherever the popup restored to.
|
|
// Clicking .address-row does not open it; the [info] button does.
|
|
async function openAddressDetail(page) {
|
|
const onAddress = await page.isVisible("#view-address");
|
|
if (!onAddress) {
|
|
await visible(page, "#view-main");
|
|
await page.click("#wallet-list .btn-addr-info");
|
|
}
|
|
await visible(page, "#view-address");
|
|
}
|
|
|
|
module.exports = {
|
|
createWallet,
|
|
launch,
|
|
openAddressDetail,
|
|
openPopup,
|
|
pageCompilesWasm,
|
|
visible,
|
|
};
|