fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 32s

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.
This commit is contained in:
2026-08-11 12:24:01 +00:00
parent 158278d251
commit 69bcbdb03a
9 changed files with 323 additions and 23 deletions

105
tests/manifest.test.js Normal file
View File

@@ -0,0 +1,105 @@
// 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/<target>/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. Firefox does not require
// 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
// Bugzilla 1770909 is still open — so that token is future-proofing
// for when it lands, not a mandate, and it stays inside Firefox's MV2
// base-CSP ceiling. object-src 'self' is the load-bearing half: a
// Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, 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);
});
});