fix: run libsodium on WebAssembly under the extension CSP (closes #182) #206
Reference in New Issue
Block a user
Delete Branch "fix/issue-182-libsodium-wasm-csp"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #182.
The decision: allow WASM, do not ship asm.js
libsodium ships a WebAssembly build and a
wasm2jstranslation of it in onefile, tries WASM first, and silently falls back if instantiation throws. Under a
plain
script-src 'self'the fallback was taken on every popup load.Measured on the vault's own Argon2id parameters (
OPSLIMIT_INTERACTIVE,MEMLIMIT_INTERACTIVE= 2 passes over 64MiB), node 22, three runs each:wasm2jsThat is the number the issue asked for before deciding. Note what it is not: the
work factor is set by the ops and memory parameters, not by wall time, so the
fallback was not a weaker KDF and no parameter re-derivation is needed either
way. It was the same work factor taking ~20x longer — about 3.5s on every
operation that asks for the password, which is every signature — while also being
the code path no test exercised, since node compiles WASM happily.
So
'wasm-unsafe-eval'is granted rather than the asm.js build being shippeddeliberately. The security reasoning, weighed as a wallet holding private keys:
It does not permit
eval()of strings, inline script, or remote script. It isa distinct token from
'unsafe-eval'precisely so that this can be grantedwithout granting that, and
'unsafe-eval'is not granted.At that point the popup is fully compromised and WASM adds nothing an attacker
did not already have. It does not turn a data-injection into code execution,
which is what makes
'unsafe-eval'genuinely dangerous.attacker-influenced reaches
WebAssembly.compile, and no.wasmis fetched.machine-translated code path that is far less exercised in the field than
libsodium's WASM build, and would keep tests and production on different
backends unless the tests were also forced onto the slow one.
The relaxation is scoped to
extension_pagesand is pinned in bothdirections by
tests/manifest.test.js: an exact token-set match, so dropping'wasm-unsafe-eval'failsmake check, and so does adding anything beside it.What changed in each manifest
Same policy, different manifest shapes:
manifest/chrome.json(MV3):content_security_policyis an object;extension_pagesis set toscript-src 'self' 'wasm-unsafe-eval'; object-src 'self'. Covers the popup andthe background service worker. This is byte-for-byte Chrome's documented
minimum MV3
extension_pagespolicy; nothing tighter can run WASM.manifest/firefox.json(MV2):content_security_policyis a bare string, setto the same policy.
On Firefox MV2 the
'wasm-unsafe-eval'token is future-proofing, not arequirement: MV2 extensions may still use WebAssembly without it for backward
compatibility, enforcement is report-only, and
Bugzilla 1770909
("Enforce 'wasm-unsafe-eval' CSP for MV2 extensions") is still open. It is
carried so the Firefox build does not silently regress the day that bug lands,
and it is inside Firefox's MV2 base-CSP ceiling, so the string will not be
rejected or reset to the default.
object-src 'self'on that target is the opposite case — it is load-bearingtoday. Firefox before 106 rejects an MV2 policy string that omits
object-srcoutright and falls back to its own default, discarding everything declared here.
It must not be trimmed as redundant.
Nothing is dropped under either baseline:
(
extensions.webextensions.default-content-security-policy=script-src 'self' 'wasm-unsafe-eval';) the new string leavesscript-srcunchanged and adds
object-src 'self', constraining<object>and<embed>sources that were previously unrestricted. That direction is atightening.
script-src 'self'; object-src 'self';)it is a one-token loosening, identical to Chrome.
A test asserts the two targets' policies stay equal, since one codebase and one
crypto path across two builds should not diverge here.
The anti-regression checks
The silence was the dangerous part, so the fallback is now loud at three levels:
tests/manifest.test.js(inmake check) — exact CSP token set for bothmanifests, plus an explicit deny-list (
'unsafe-eval','unsafe-inline',http:,https:,data:,blob:,*).build.jscopies these files todist/<target>/manifest.jsonverbatim, so what is asserted is what ships.tests/vaultBackend.test.js(inmake check) — asserts the unit testsexercise the WASM backend, which was the issue's "tests measure the wrong
build" concern. Its check that libsodium never swapped in the fallback reads
an internal marker, so a second test forces the fallback and watches the
marker disappear: without that, a future libsodium could make the marker
vacuously true and the test would pass while measuring nothing.
tests/e2e/run.js— compiles a WebAssembly module inside the real popup underthe real shipped manifest, and the harness allowlist entry that used to excuse
the
CompileErroris deleted (ALLOWED_ERRORSis now empty), so a recurrencefails the run rather than being tolerated.
At runtime the fallback is kept — a wallet that refuses to decrypt is worse than
a slow one — but
vault.jsnow reports viacryptoBackend()whether this realmmay compile WebAssembly, and logs an error if it may not. That probe measures the
realm and infers libsodium's backend from it, which is exactly the CSP case this
change guards; a fallback taken for some other reason (allocation failure,
corrupt module) would not be caught by it, and its doc comment says so rather
than claiming to report the backend in use.
tests/vaultBackend.test.jschecks libsodium's own marker directly. In the e2esuite that log is itself a failure, since any
console.errorfails the run.The decision and the numbers are recorded next to the import in
src/shared/vault.js, and in a new Content Security Policy section inREADME.md.Evidence
Not a manifest diff argument: the CSP change was reverted and the suite run
against the real extension in the containerized Chrome, then re-run with it
restored. Same commit, same harness, only the manifests differ.
Before (
script/test-e2e, manifests without the CSP):All three mechanisms fired independently: the browser's own refusal, the new
probe, and
vault.jsreporting its own backend. (That capture is from thefive-test suite as it stood then; the suite has since grown to 14 tests on
next.)After (same command, on the current head
792e95c, CSP in place — full suiteas it now stands):
No
CompileError, no page errors at all, with the allowlist empty — and test 3performs a real vault encryption through the WASM path. Since libsodium's module
is embedded in the bundle and needs no fetch, a popup realm that compiles WASM is
one where libsodium takes the WASM path.
tests/manifest.test.jswas also confirmed to fail closed, re-captured on thishead rather than carried over: with
manifest/chrome.jsonreverted to no CSP,make checkgoes red with2 failed, 365 passedof 367 (1 suite failed, 15passed). The manifest was restored afterwards, confirmed byte-identical to its
committed form, and
make checkre-run green.dist/chrome/manifest.jsonanddist/firefox/manifest.jsonwere diffed againsttheir
manifest/sources on this head: byte-identical for both targets.Verification
make check: green on head792e95c— 16 suites, 367 tests, prettier clean,lint clean.
script/cibuild(the CI entrypoint, containerized): green, with theRUN make checklayer observed executing rather than reportedCACHED(24.7s, 367/367 inside the container).
make fmt: clean.make test-e2e: 14/14 on the pushed head792e95c, before/after capturedabove.
nextatba35282, resolving aTODO.mdconflict in# Completed Stepsby keeping both entries;make checkandmake test-e2ere-run after the resolution, both green.
Not verified
The Firefox MV2 policy is asserted statically only. The e2e harness drives
Chrome, and the Firefox build is not functional yet
(#153), so "Firefox accepts
this manifest and compiles WASM" has not been observed in a real Firefox. If
Firefox ever did reject the string the failure mode is a fallback to a more
restrictive default — slower, not less safe, and now loud.
45c5f31971tocc89cb891bFAIL —
needs-rebase.1. Blocking: conflicts with
next.nexthas moved from19cb1catob9bc226(#164 landed).git merge-tree origin/next HEADconflicts inTODO.md— both changes prepend an entry to# Completed Steps. Gitea now reportsmergeable: false. Rebase ontob9bc226, keeping both entries.2. Blocking: CI has not run.
check / check (push)oncc89cb8has beenpending/ "Waiting to run" since 14:27. Not red, but not green either, so head is unverified by CI. The rebase in (1) will re-trigger it. For what it is worth I ranscript/cibuildlocally on head: green, test layer executed in 16.3s (notCACHED), 155 tests, prettier clean — so I expect CI to pass once it runs.3.
tests/manifest.test.js:87-89— factually wrong comment. "Firefox 102 and later require'wasm-unsafe-eval'for extension pages exactly as Chrome does" is not true for MV2, which is whatmanifest/firefox.jsonis. Per MDN, MV2 extensions in Firefox may still use WebAssembly without the keyword for backward compatibility; enforcement is report-only and Bugzilla 1770909 ("Enforce 'wasm-unsafe-eval' CSP for MV2 extensions") is still open. The same claim is in the PR body. The manifest change itself is correct and worth keeping (it is future-proofing for when that bug lands), but the stated reason should say so rather than asserting a requirement that does not exist. Same forobject-src 'self', which is genuinely load-bearing on Firefox <=105 — omit it there and the whole declared policy is discarded — and that is the better justification to record.4.
tests/e2e/run.js/ PR body — stale evidence figure. PR body says revertingmanifest/chrome.jsongives2 failed, 147 passed. Actual on this head is2 failed, 153 passed(147+2 does not reach the 155 total). Cosmetic, but re-capture it on the rebase so the recorded evidence matches the tree.5. Non-blocking:
src/shared/vault.js:51cryptoBackend()measures the realm, not libsodium. It reports whether this realm can compile WASM, and infers libsodium's backend from that. Sound for the CSP case, which is the one that matters, but if libsodium ever fell back for a non-CSP reason (allocation failure, corrupt module) theensureReady()error would not fire.tests/vaultBackend.test.jscovers the direct marker in node; production has only the indirect probe. The doc comment "the libsodium backend in use in this realm" slightly overstates what the function knows.The security decision itself is sound and I am not blocking on it.
'wasm-unsafe-eval'is genuinely narrower as claimed. W3C CSP3 §6.1.10: it gates onlyWebAssembly.compile/instantiate/Module/*Streaming, and "only permits WebAssembly and does not affect JavaScript". It grants nothing toeval(),new Function(), inline script, or any remote origin.'unsafe-eval'is granted nowhere in the tree — verified across both manifests and all source; there is nometa http-equivCSP anywhere, so these two files are the entire CSP surface.extension_pagespolicy is byte-for-bytescript-src 'self' 'wasm-unsafe-eval'; object-src 'self'— the policy chosen here. Nothing tighter can run WASM..wasmis fetched, and the e2e run is fully offline with an empty allowlist.MV2 is not a regression — it is a tightening. Checked independently against Gecko's own prefs rather than the author's framing. Firefox's real MV2 default (
extensions.webextensions.default-content-security-policy) isscript-src 'self' 'wasm-unsafe-eval';— noobject-srcat all, and wasm already permitted. So the new string leavesscript-srcunchanged and addsobject-src 'self', restricting<object>/<embed>sources that were previously unconstrained. Measured against MDN's documented MV2 default instead (script-src 'self'; object-src 'self';) it is a one-token loosening, identical to Chrome. Under either baseline nothing is dropped.'wasm-unsafe-eval'is inside Firefox's MV2 base-CSP ceiling, so it will not be rejected or silently reset to default. The MV3upgrade-insecure-requeststrap does not apply: it is in Firefox's MV3 default, and the Firefox build here is MV2 while the MV3 file targets Chrome, whose default has no such directive.On the KDF point: the correction in the PR body is right. Argon2id strength is fixed by opslimit/memlimit, not wall time, so the wasm2js fallback was the same work factor, not a weaker one. Stating that plainly — the justification for this token rests on latency and on not shipping an under-exercised machine-translated crypto path, not on a security fix. That is a weaker case than #182 assumed and should be understood as such. It still carries the grant: there is provably no narrower policy that runs WASM, the token cannot turn data injection into code execution, and ~3.5s on every signature is a real pressure toward weaker user passwords in a wallet whose only at-rest protection is this KDF.
Anti-regression checks survived my own mutations — I did not take these on the author's word:
manifest/chrome.jsonwith the token dropped:2 failed, 153 passed. With'unsafe-eval'added alongside:2 failed, 153 passed.manifest/firefox.jsonwith the token dropped:2 failed, 153 passed. Pinned in both directions, both files.src/shared/vault.jsEMPTY_WASM_MODULEcorrupted so the probe cannot succeed: the WASM-backend test fails (Expected "wasm", Received "asmjs") — not vacuous.await core.useBackupModule()with a no-op, simulating a future libsodium where taking the fallback leaves the marker in place.the fallback marker distinguishes the two backendsfails (Expected "undefined", Received "function"). The guard genuinely holds the marker inference honest, as claimed.CompileErrorpageerror on tests 1 and 5, the new probe failing test 2, and thevault.jsconsole.errorfailing test 3.ALLOWED_ERRORSis genuinely empty andisAllowed()therefore filters nothing, so pageerrors,console.errorand unstubbed requests all fail the run.dist/{chrome,firefox}/manifest.jsonare byte-identical to themanifest/sources, so whattests/manifest.test.jsasserts is what ships and what the e2e browser loaded.Also checked and passing: single commit titled
... (closes #182), basenext, no Claude/Anthropic references or attribution trailers anywhere, oneTODO.mdentry, no scope creep, inclusive terminology,make checkgreen locally (155 tests, prettier clean, ~19s),make fmtclean.Disclosure: I accept the author's disclosed caveat that the Firefox MV2 policy is unverified in a real Firefox (#153). I checked it statically against Gecko's prefs and MDN instead, and it does not need gating — the failure mode if Firefox ever rejected the string is a fallback to a more restrictive default, which costs speed, not safety, and is now loud.
cc89cb891btoe533ceff5fe533ceff5fto0a5779a1200a5779a120to8f908ce5b28f908ce5b2to982d881de9982d881de9to741a16ad9b741a16ad9bto69bcbdb03aReworked. Head is now
792e95c, single commit, parentba35282(currentnext), fast-forwardable.1. Rebase. Done.
nextmoved twice while this ran; the final rebase is ontoba35282, and that is where the predictedTODO.mdconflict on# Completed Stepsactually appeared. Resolved keeping both entries: the#239bullet fromnextstays on top, this branch's#182bullet directly under it, list order still reverse-chronological. Nothing else conflicted.make checkandmake test-e2ewere re-run after the resolution, not just before it.2.
tests/manifest.test.jsFirefox comment. Rewritten. It no longer claims MV2 requires'wasm-unsafe-eval'; it says the token is future-proofing for when Bugzilla 1770909 lands, and records the trap that actually bites:object-src 'self'is load-bearing because Firefox before 106 rejects an MV2 policy string that omits it and falls back to its own default, discarding the whole declared policy. Same correction in the PR body, where the old bare "Nothing is dropped under either baseline" framing now sits behind that distinction.3. Stale evidence figure. Re-captured on this head, not carried over. Reverting
manifest/chrome.jsonto no CSP gives2 failed, 365 passedof 367 (1 suite failed, 15 passed). The manifest was restored afterwards and diffed byte-identical to its committed form. PR body corrected.4.
cryptoBackend()doc comment. Already reworded on the reviewed head and kept: it states that it probes whether this realm may compile WebAssembly and infers libsodium's backend from that, and that a fallback taken for another reason (allocation failure, corrupt module) would not be caught there. No behaviour change.Nothing reviewer-verified was touched:
'wasm-unsafe-eval'present in bothmanifest/chrome.jsonandmanifest/firefox.json,object-src 'self'in both,EMPTY_WASM_MODULEprobe intact, fallback-marker guard intact,ALLOWED_ERRORSstill empty.dist/{chrome,firefox}/manifest.jsondiffed byte-identical to themanifest/sources.Verification on
792e95c, after the conflict resolution:make check: green, 16 suites / 367 tests, prettier clean.make test-e2e: 14/14, zeronot ok, no page errors.script/cibuild: green, withRUN make checkobserved executing rather than reportedCACHED(24.7s, 367/367 in-container). Captured on the identical tree one rebase earlier; the resolution touchedTODO.mdonly.make fmt: clean.Only the test comment changed as code in this pass (9 lines to 8); the rest is
TODO.mdconflict resolution and PR-body text.69bcbdb03ato792e95c4a7PASS — independent re-review on a fresh clone at head
792e95c: rebase clean (parent is currentorigin/nextba35282, fast-forwardable, single commit,TODO.mda pure +5 addition with #239 above #182 and nothing lost), the newtests/manifest.test.js:82-89Firefox claim verified true against MDN's own source (object-srcoptional from Firefox 106; earlier versions ignorecontent_security_policyentirely and use the default), author and committer bothclawbot, working tree and diff free of any stray manifest mutation,dist/{chrome,firefox}/manifest.jsonbyte-identical tomanifest/,make check16 suites / 367 tests executing,script/cibuildwithRUN make checkobserved running notCACHED(21.4s, 367/367 in-container),make test-e2e14/14,make fmtclean, no Claude/Anthropic references or attribution trailers.Probes re-run rather than taken on trust: corrupting
EMPTY_WASM_MODULEfails the WASM-backend test (1 failed / 366); droppingobject-srcfrommanifest/chrome.jsonand'wasm-unsafe-eval'frommanifest/firefox.jsonfails 3 manifest tests; removingcontent_security_policyfrommanifest/chrome.jsonreproduces the PR body's figure exactly (2 failed, 365 passedof 367, 1 suite failed / 15 passed). All mutations were local to my own clone and reverted; tree confirmed clean after each.Disclosures: (1) I did not re-run the fallback-marker mutation (no-op
core.useBackupModule()) — a surgical edit of the minified vendor bundle was impractical; the guard is unchanged since the prior pass, asserts marker-present then absent across a realuseBackupModule()call, and passes non-vacuously in the green run, so I accepted it on that plus the prior mutation test. (2) I used a read-onlypython3heredoc to print a slice of that minifiednode_modulesfile; no file was modified by it.