fix: run libsodium on WebAssembly under the extension CSP (closes #182)
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.
This commit is contained in:
2026-08-11 12:24:01 +00:00
committed by clawbot
parent cf5f582be9
commit 0a5779a120
9 changed files with 322 additions and 19 deletions

View File

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