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.
106 lines
4.0 KiB
JavaScript
106 lines
4.0 KiB
JavaScript
// 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);
|
|
});
|
|
});
|