diff --git a/README.md b/README.md index 7c10480..46370a3 100644 --- a/README.md +++ b/README.md @@ -107,12 +107,13 @@ unavailable). The suite lives in `tests/e2e/` and is driven by `playwright-core`, whose version must stay matched to the container's Playwright version — the browsers ship inside the image. -It covers popup load, wallet creation through the UI, the Add Token screen and -the transaction detail screen for an ERC-20 transfer. All outbound network is -intercepted at the browser level and served from fixtures in -`tests/e2e/network.js`, so the run is deterministic and fully offline; -unrecognised outbound requests are reported as failures rather than silently -allowed. +It covers popup load, WebAssembly compilation under the shipped CSP (see +[Content Security Policy](#content-security-policy)), wallet creation through +the UI, the Add Token screen and the transaction detail screen for an ERC-20 +transfer. All outbound network is intercepted at the browser level and served +from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully +offline; unrecognised outbound requests are reported as failures rather than +silently allowed. That reporting has one bound worth knowing. Observation ends when the browser context is torn down, and nothing can watch traffic after that, so the run keeps @@ -751,6 +752,36 @@ battle-tested. Exceptions require explicit authorization in a code comment referencing this policy, but as of now there are none. +### Content Security Policy + +Both manifests declare the same policy for extension pages — +`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under +`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as +a bare string in `manifest/firefox.json` (MV2). + +`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly +build and a `wasm2js` translation of it in one file, tries WASM first, and +silently falls back to the translation 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 same Argon2id parameters +the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a +key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it +is set by the ops and memory parameters, not by wall time — so the fallback +bought nothing and cost about three and a half seconds on every operation that +asks for the password, which is every signature. + +The keyword permits compiling WebAssembly and nothing else: not `eval()` of +strings, not inline script, not remote script. Using it requires already +executing script in an extension page, which is complete compromise on its own. +`'unsafe-eval'` is a different proposition and is not granted. + +The grant is pinned in both directions. `tests/manifest.test.js` asserts the +exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent +20x regression on the key derivation) and adding anything beyond it both fail +`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM +backend, and `make test-e2e` compiles a WebAssembly module inside the real popup +under the real manifest. + ### DEBUG Mode Policy The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and diff --git a/TODO.md b/TODO.md index c210509..1ac496f 100644 --- a/TODO.md +++ b/TODO.md @@ -44,6 +44,11 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-11: libsodium runs on WebAssembly in the shipped builds — + `'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js + fallback at 20x the Argon2id cost, pinned in both directions by + `tests/manifest.test.js` and observed in the real popup by the e2e suite + ([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)). - 2026-08-11: `docs/README.md` rewritten against the code: no competitor names, all five network destinations documented, password/Settings/Add Wallet sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)). diff --git a/manifest/chrome.json b/manifest/chrome.json index c589225..3ea33dd 100644 --- a/manifest/chrome.json +++ b/manifest/chrome.json @@ -5,6 +5,9 @@ "description": "Minimal Ethereum wallet for Chrome", "permissions": ["storage", "activeTab"], "host_permissions": [""], + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + }, "action": { "default_popup": "src/popup/index.html" }, diff --git a/manifest/firefox.json b/manifest/firefox.json index 830f5c3..fff301d 100644 --- a/manifest/firefox.json +++ b/manifest/firefox.json @@ -4,6 +4,7 @@ "version": "0.1.0", "description": "Minimal Ethereum wallet for Firefox", "permissions": ["storage", "activeTab", ""], + "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", "browser_action": { "default_popup": "src/popup/index.html" }, diff --git a/src/shared/vault.js b/src/shared/vault.js index 5e4dd29..08a1ffa 100644 --- a/src/shared/vault.js +++ b/src/shared/vault.js @@ -1,14 +1,75 @@ // Vault: password-based encryption of secrets using libsodium. // Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption. // All crypto operations are delegated to libsodium — no raw primitives. +// +// Backend: WebAssembly, deliberately (#182). +// +// libsodium ships one file containing both a WebAssembly build and a +// wasm2js ("asm.js") translation of it. It tries WASM first and, if +// instantiation throws, silently swaps in the translation. An extension +// CSP of plain script-src 'self' refuses WASM, so every popup load used +// to take that fallback — announced by nothing but an uncaught +// CompileError in the console. +// +// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE, +// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine: +// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is +// identical either way — it is set by the ops/mem parameters, not by wall +// time — so the fallback bought no security, it only made every password +// operation take three and a half seconds, and the wallet asks for the +// password on every signature. +// +// So both manifests declare 'wasm-unsafe-eval' for extension pages. That +// keyword permits compiling WebAssembly and nothing else: not eval() of +// strings, not inline script, not remote script. Reaching it requires +// already executing script in the extension page, which is total +// compromise on its own. 'unsafe-eval' would be a different matter and is +// not granted. tests/manifest.test.js pins both policies to exactly +// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding +// strictness can drift unnoticed. +// +// The fallback still exists, and a wallet that refuses to decrypt is +// worse than a slow one, so it is not disabled — it is made loud: +// cryptoBackend() reports which backend this realm can run, ensureReady() +// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the +// unit tests exercise the WASM backend, and the end-to-end suite asserts +// it in the real popup under the real manifest. const sodium = require("libsodium-wrappers-sumo"); +const { log } = require("./log"); + +// An empty WebAssembly module: the 8-byte magic number and version header, +// no sections. Compiling it asks the cheapest possible form of the only +// question that matters here — may this realm compile WebAssembly at all — +// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and +// exactly what decides which backend libsodium ends up on. +const EMPTY_WASM_MODULE = new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, +]); + +// "wasm" or "asmjs": the libsodium backend in use in this realm. +async function cryptoBackend() { + try { + await WebAssembly.compile(EMPTY_WASM_MODULE); + return "wasm"; + } catch (_) { + return "asmjs"; + } +} let ready = false; async function ensureReady() { if (!ready) { await sodium.ready; + if ((await cryptoBackend()) !== "wasm") { + log.errorf( + "libsodium is running on the wasm2js fallback: this realm " + + "refuses to compile WebAssembly, so every password " + + "derivation costs roughly 20x what it should. See the " + + "backend note in src/shared/vault.js.", + ); + } ready = true; } } @@ -59,4 +120,4 @@ async function decryptWithPassword(encrypted, password) { return sodium.to_string(plaintext); } -module.exports = { encryptWithPassword, decryptWithPassword }; +module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword }; diff --git a/tests/e2e/harness.js b/tests/e2e/harness.js index f51fe78..99a822d 100644 --- a/tests/e2e/harness.js +++ b/tests/e2e/harness.js @@ -22,18 +22,12 @@ const EXT_PATH = path.join(REPO_ROOT, "dist", "chrome"); // 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. -const ALLOWED_ERRORS = [ - { - // libsodium ships a WASM build and an asm.js fallback. The - // extension CSP (script-src 'self', with no wasm-unsafe-eval) - // refuses the WASM module on every popup load; libsodium catches - // it and falls back to asm.js, so the wallet works. Deciding - // which backend actually ships is issue #182, and this entry gets - // deleted when that lands. - issue: "#182", - pattern: /Refused to compile or instantiate WebAssembly module/, - }, -]; +// +// 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)); @@ -247,6 +241,26 @@ 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); @@ -285,5 +299,6 @@ module.exports = { launch, openAddressDetail, openPopup, + pageCompilesWasm, visible, }; diff --git a/tests/e2e/run.js b/tests/e2e/run.js index a771e3c..89698f7 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -14,6 +14,7 @@ const { launch, openAddressDetail, openPopup, + pageCompilesWasm, visible, } = require("./harness"); const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); @@ -55,6 +56,27 @@ test("popup loads and reaches the welcome view", async (env) => { assert(title === "AutistMask", "unexpected popup title: " + title); }); +// The empirical half of #182. The manifest change is only a claim about +// what the CSP permits; this is the observation. Two things have to hold +// together, and the run covers both: the popup realm compiles WASM (here), +// and no WASM refusal or abort is recorded anywhere in the run — the +// harness allowlist that used to excuse exactly that error is now empty, +// so a recurrence fails whichever test it lands in rather than being +// tolerated. Since libsodium's WASM module is embedded in the bundle and +// needs no fetch, a realm that compiles WASM is a realm where libsodium +// takes the WASM path, and the next test drives a real vault encryption +// through it. +test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => { + const ok = await pageCompilesWasm(env.page); + assert( + ok, + "the popup refused to compile WebAssembly. The shipped manifest CSP " + + "has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " + + "fallback and every password derivation costs roughly 20x what it " + + "should — see the backend note in src/shared/vault.js", + ); +}); + test("wallet creation through the UI reaches the main view", async (env) => { await createWallet(env.page); const addrCount = await env.page diff --git a/tests/manifest.test.js b/tests/manifest.test.js new file mode 100644 index 0000000..9408979 --- /dev/null +++ b/tests/manifest.test.js @@ -0,0 +1,100 @@ +// The shipped Content Security Policy, pinned in both directions. +// +// This is the anti-regression check for #182. libsodium decides its +// backend by trying to compile WebAssembly and catching the failure, so a +// CSP that refuses WASM demotes the vault to the wasm2js translation — +// roughly 20x slower per Argon2id derivation — and says so only in a +// console message nobody reads. Dropping 'wasm-unsafe-eval' from either +// manifest therefore has to fail a check, not a log line. +// +// It is equally a check against loosening. 'wasm-unsafe-eval' is granted +// deliberately and narrowly (see the backend note in src/shared/vault.js); +// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and +// an exact match on the token set is what keeps the next edit from +// smuggling one in alongside. +// +// build.js copies these files to dist//manifest.json verbatim, so +// what is asserted here is what ships. + +const fs = require("fs"); +const path = require("path"); + +const MANIFEST_DIR = path.join(__dirname, "..", "manifest"); + +const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"]; +const EXPECTED_OBJECT_SRC = ["'self'"]; + +const FORBIDDEN_SOURCES = [ + "'unsafe-eval'", + "'unsafe-inline'", + "http:", + "https:", + "data:", + "blob:", + "*", +]; + +function readManifest(name) { + return JSON.parse( + fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"), + ); +} + +// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... } +function parseCsp(policy) { + const directives = {}; + for (const part of policy.split(";")) { + const tokens = part.trim().split(/\s+/).filter(Boolean); + if (tokens.length === 0) continue; + directives[tokens[0]] = tokens.slice(1); + } + return directives; +} + +function assertPolicy(policy) { + const directives = parseCsp(policy); + expect(Object.keys(directives).sort()).toEqual([ + "object-src", + "script-src", + ]); + expect(directives["script-src"].slice().sort()).toEqual( + EXPECTED_SCRIPT_SRC, + ); + expect(directives["object-src"].slice().sort()).toEqual( + EXPECTED_OBJECT_SRC, + ); + for (const source of FORBIDDEN_SOURCES) { + expect(directives["script-src"]).not.toContain(source); + expect(directives["object-src"]).not.toContain(source); + } +} + +describe("shipped Content Security Policy", () => { + // MV3 takes an object and applies extension_pages to the popup and the + // background service worker, which is where libsodium runs. + test("chrome MV3 allows WASM and nothing else beyond 'self'", () => { + const csp = readManifest("chrome").content_security_policy; + expect(typeof csp).toBe("object"); + expect(Object.keys(csp)).toEqual(["extension_pages"]); + assertPolicy(csp.extension_pages); + }); + + // MV2 takes the policy as a bare string, and Firefox 102 and later + // require 'wasm-unsafe-eval' for extension pages exactly as Chrome + // does. Same policy, different manifest shape. + test("firefox MV2 allows WASM and nothing else beyond 'self'", () => { + const csp = readManifest("firefox").content_security_policy; + expect(typeof csp).toBe("string"); + assertPolicy(csp); + }); + + // The two targets share one codebase and one crypto path; a policy + // that drifts apart between them means one of the two builds is + // running a backend nothing tests. + test("both targets ship the same policy", () => { + const chrome = + readManifest("chrome").content_security_policy.extension_pages; + const firefox = readManifest("firefox").content_security_policy; + expect(firefox).toBe(chrome); + }); +}); diff --git a/tests/vaultBackend.test.js b/tests/vaultBackend.test.js new file mode 100644 index 0000000..0dd2b8f --- /dev/null +++ b/tests/vaultBackend.test.js @@ -0,0 +1,52 @@ +// The unit tests must exercise the libsodium backend that actually ships +// (#182). Before this, they could not: node compiles WebAssembly happily, +// the extension CSP refused it, and so the browser silently ran the +// wasm2js translation while every test ran the WASM build. +// +// With 'wasm-unsafe-eval' in both manifests the two agree, and these tests +// hold that agreement in place from the node side. tests/manifest.test.js +// holds up the CSP end of it, and the end-to-end suite observes the real +// popup. + +const { cryptoBackend } = require("../src/shared/vault"); + +// The module libsodium-wrappers-sumo itself requires and drives. Not a new +// dependency: it is inspected here, never used to perform crypto, because +// it is the only thing that can say which backend is loaded. +const SODIUM_CORE = "libsodium-sumo"; + +describe("libsodium backend", () => { + test("this realm compiles WebAssembly, so the tests run the WASM build", async () => { + await expect(cryptoBackend()).resolves.toBe("wasm"); + }); + + test("libsodium did not swap in the wasm2js fallback", async () => { + const core = require(SODIUM_CORE); + await require("libsodium-wrappers-sumo").ready; + // useBackupModule is the entry point to the fallback; taking it + // replaces the module's exports with the translation's, and the + // entry point goes with them. Still present after ready means the + // WASM module is the one in place. The test below is what keeps + // that inference honest. + expect(typeof core.useBackupModule).toBe("function"); + }); + + // Deliberately last, and deliberately destructive: it takes the + // fallback, which replaces the loaded module for the rest of this + // file. Jest gives each test file its own module registry, so nothing + // outside sees it. + // + // Without this, the check above would be a claim about libsodium's + // internals with nothing holding it to account: if a future version + // kept useBackupModule on the fallback module too, the marker would + // quietly become true in both backends and the test would pass while + // measuring nothing. Forcing the fallback and watching the marker + // disappear is what makes its presence mean something. + test("the fallback marker distinguishes the two backends", async () => { + const core = require(SODIUM_CORE); + await require("libsodium-wrappers-sumo").ready; + expect(typeof core.useBackupModule).toBe("function"); + await core.useBackupModule(); + expect(typeof core.useBackupModule).toBe("undefined"); + }); +});