fix: run libsodium on WebAssembly under the extension CSP (closes #182) #206

Merged
clawbot merged 1 commits from fix/issue-182-libsodium-wasm-csp into next 2026-08-12 10:30:16 +02:00
Collaborator

Closes #182.

The decision: allow WASM, do not ship asm.js

libsodium ships a WebAssembly build and a wasm2js translation of it 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.

Measured on the vault's own Argon2id parameters (OPSLIMIT_INTERACTIVE,
MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22, three runs each:

backend per key derivation
WASM 141 / 165 / 198 ms
wasm2js 3204 / 3240 / 3660 ms

That 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 shipped
deliberately. The security reasoning, weighed as a wallet holding private keys:

  • The keyword permits compiling and instantiating WebAssembly, and nothing else.
    It does not permit eval() of strings, inline script, or remote script. It is
    a distinct token from 'unsafe-eval' precisely so that this can be granted
    without granting that, and 'unsafe-eval' is not granted.
  • Exploiting it requires already executing attacker script in an extension page.
    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.
  • The bytes libsodium compiles are embedded in our own bundle. Nothing
    attacker-influenced reaches WebAssembly.compile, and no .wasm is fetched.
  • Shipping asm.js instead would mean the wallet's only at-rest encryption runs a
    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_pages and is pinned in both
directions by tests/manifest.test.js: an exact token-set match, so dropping
'wasm-unsafe-eval' fails make check, and so does adding anything beside it.

What changed in each manifest

Same policy, different manifest shapes:

  • manifest/chrome.json (MV3): content_security_policy is an object;
    extension_pages is set to
    script-src 'self' 'wasm-unsafe-eval'; object-src 'self'. Covers the popup and
    the background service worker. This is byte-for-byte Chrome's documented
    minimum MV3 extension_pages policy; nothing tighter can run WASM.
  • manifest/firefox.json (MV2): content_security_policy is a bare string, set
    to the same policy.

On Firefox MV2 the 'wasm-unsafe-eval' token is future-proofing, not a
requirement: 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-bearing
today. Firefox before 106 rejects an MV2 policy string that omits object-src
outright and falls back to its own default, discarding everything declared here.
It must not be trimmed as redundant.

Nothing is dropped under either baseline:

  • Against Gecko's real MV2 default
    (extensions.webextensions.default-content-security-policy =
    script-src 'self' 'wasm-unsafe-eval';) the new string leaves script-src
    unchanged and adds object-src 'self', constraining <object> and
    <embed> sources that were previously unrestricted. That direction is a
    tightening.
  • Against MDN's documented MV2 default (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:

  1. tests/manifest.test.js (in make check) — exact CSP token set for both
    manifests, plus an explicit deny-list ('unsafe-eval', 'unsafe-inline',
    http:, https:, data:, blob:, *). build.js copies these files to
    dist/<target>/manifest.json verbatim, so what is asserted is what ships.
  2. tests/vaultBackend.test.js (in make check) — asserts the unit tests
    exercise 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.
  3. tests/e2e/run.js — compiles a WebAssembly module inside the real popup under
    the real shipped manifest, and the harness allowlist entry that used to excuse
    the CompileError is deleted (ALLOWED_ERRORS is now empty), so a recurrence
    fails 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.js now reports via cryptoBackend() whether this realm
may 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.js checks libsodium's own marker directly. In the e2e
suite that log is itself a failure, since any console.error fails 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 in
README.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):

1..5
not ok 1 - popup loads and reaches the welcome view
  uncaught browser errors during this test
  pageerror: Aborted(CompileError: WebAssembly.instantiate(): Refused to
  compile or instantiate WebAssembly module because neither 'wasm-eval' nor
  'unsafe-eval' is an allowed source of script in the following Content
  Security Policy directive: "script-src 'self'").
not ok 2 - the popup compiles WebAssembly under the shipped CSP (#182)
  the popup refused to compile WebAssembly. [...]
not ok 3 - wallet creation through the UI reaches the main view
  uncaught browser errors during this test
  console.error: [AutistMask] libsodium is running on the wasm2js fallback:
  this realm refuses to compile WebAssembly [...]
ok 4 - add token screen opens from address detail (#150)
not ok 5 - transaction detail renders an ERC-20 transfer (#151)
  uncaught browser errors during this test
  pageerror: Aborted(CompileError: WebAssembly.instantiate(): Refused to
  compile or instantiate WebAssembly module [...])
# 1/5 tests passed
# FAILED

All three mechanisms fired independently: the browser's own refusal, the new
probe, and vault.js reporting its own backend. (That capture is from the
five-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 suite
as it now stands):

1..14
ok 1 - popup loads and reaches the welcome view
ok 2 - the popup compiles WebAssembly under the shipped CSP (#182)
ok 3 - wallet creation through the UI reaches the main view
ok 4 - add token screen opens from address detail (#150)
ok 5 - transaction detail renders an ERC-20 transfer (#151)
ok 6 - only an HD wallet is offered the recovery phrase action (#161)
ok 7 - a key wallet is not offered the recovery phrase action (#161)
ok 8 - the recovery phrase screen holds nothing before the password (#161)
ok 9 - a wrong password reveals nothing (#161)
ok 10 - the correct password reveals the full phrase, and nothing logs it (#161)
ok 11 - "Back" wipes the revealed phrase (#161)
ok 12 - leaving by the settings gear wipes it too (#161)
ok 13 - leaving while the decrypt is in flight reveals nothing (#161)
ok 14 - reopening the popup never lands on the phrase screen (#161)
# 14/14 tests passed

No CompileError, no page errors at all, with the allowlist empty — and test 3
performs 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.js was also confirmed to fail closed, re-captured on this
head rather than carried over: with manifest/chrome.json reverted to no CSP,
make check goes red with 2 failed, 365 passed of 367 (1 suite failed, 15
passed). The manifest was restored afterwards, confirmed byte-identical to its
committed form, and make check re-run green.

dist/chrome/manifest.json and dist/firefox/manifest.json were diffed against
their manifest/ sources on this head: byte-identical for both targets.

Verification

  • make check: green on head 792e95c — 16 suites, 367 tests, prettier clean,
    lint clean.
  • script/cibuild (the CI entrypoint, containerized): green, with the
    RUN make check layer observed executing rather than reported CACHED
    (24.7s, 367/367 inside the container).
  • make fmt: clean.
  • make test-e2e: 14/14 on the pushed head 792e95c, before/after captured
    above.
  • Rebased onto current next at ba35282, resolving a TODO.md conflict in
    # Completed Steps by keeping both entries; make check and make test-e2e
    re-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.

Closes [#182](https://git.eeqj.de/sneak/AutistMask/issues/182). ## The decision: allow WASM, do not ship asm.js libsodium ships a WebAssembly build and a `wasm2js` translation of it 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. Measured on the vault's own Argon2id parameters (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE` = 2 passes over 64MiB), node 22, three runs each: | backend | per key derivation | | --------- | --------------------- | | WASM | 141 / 165 / 198 ms | | `wasm2js` | 3204 / 3240 / 3660 ms | That 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 shipped deliberately. The security reasoning, weighed as a wallet holding private keys: - The keyword permits compiling and instantiating WebAssembly, and nothing else. It does not permit `eval()` of strings, inline script, or remote script. It is a distinct token from `'unsafe-eval'` precisely so that this can be granted without granting that, and `'unsafe-eval'` is not granted. - Exploiting it requires already executing attacker script in an extension page. 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. - The bytes libsodium compiles are embedded in our own bundle. Nothing attacker-influenced reaches `WebAssembly.compile`, and no `.wasm` is fetched. - Shipping asm.js instead would mean the wallet's only at-rest encryption runs a 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_pages` and is pinned in **both** directions by `tests/manifest.test.js`: an exact token-set match, so dropping `'wasm-unsafe-eval'` fails `make check`, and so does adding anything beside it. ## What changed in each manifest Same policy, different manifest shapes: - `manifest/chrome.json` (MV3): `content_security_policy` is an object; `extension_pages` is set to `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`. Covers the popup and the background service worker. This is byte-for-byte Chrome's documented _minimum_ MV3 `extension_pages` policy; nothing tighter can run WASM. - `manifest/firefox.json` (MV2): `content_security_policy` is a bare string, set to the same policy. On Firefox MV2 the `'wasm-unsafe-eval'` token is future-proofing, not a requirement: MV2 extensions may still use WebAssembly without it for backward compatibility, enforcement is report-only, and [Bugzilla 1770909](https://bugzilla.mozilla.org/show_bug.cgi?id=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-bearing today. Firefox before 106 rejects an MV2 policy string that omits `object-src` outright and falls back to its own default, discarding everything declared here. It must not be trimmed as redundant. Nothing is dropped under either baseline: - Against Gecko's real MV2 default (`extensions.webextensions.default-content-security-policy` = `script-src 'self' 'wasm-unsafe-eval';`) the new string leaves `script-src` unchanged and **adds** `object-src 'self'`, constraining `<object>` and `<embed>` sources that were previously unrestricted. That direction is a tightening. - Against MDN's documented MV2 default (`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: 1. `tests/manifest.test.js` (in `make check`) — exact CSP token set for both manifests, plus an explicit deny-list (`'unsafe-eval'`, `'unsafe-inline'`, `http:`, `https:`, `data:`, `blob:`, `*`). `build.js` copies these files to `dist/<target>/manifest.json` verbatim, so what is asserted is what ships. 2. `tests/vaultBackend.test.js` (in `make check`) — asserts the unit tests exercise 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. 3. `tests/e2e/run.js` — compiles a WebAssembly module inside the real popup under the real shipped manifest, and the harness allowlist entry that used to excuse the `CompileError` is deleted (`ALLOWED_ERRORS` is now empty), so a recurrence fails 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.js` now reports via `cryptoBackend()` whether this realm may 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.js` checks libsodium's own marker directly. In the e2e suite that log is itself a failure, since any `console.error` fails 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 in `README.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): ``` 1..5 not ok 1 - popup loads and reaches the welcome view uncaught browser errors during this test pageerror: Aborted(CompileError: WebAssembly.instantiate(): Refused to compile or instantiate WebAssembly module because neither 'wasm-eval' nor 'unsafe-eval' is an allowed source of script in the following Content Security Policy directive: "script-src 'self'"). not ok 2 - the popup compiles WebAssembly under the shipped CSP (#182) the popup refused to compile WebAssembly. [...] not ok 3 - wallet creation through the UI reaches the main view uncaught browser errors during this test console.error: [AutistMask] libsodium is running on the wasm2js fallback: this realm refuses to compile WebAssembly [...] ok 4 - add token screen opens from address detail (#150) not ok 5 - transaction detail renders an ERC-20 transfer (#151) uncaught browser errors during this test pageerror: Aborted(CompileError: WebAssembly.instantiate(): Refused to compile or instantiate WebAssembly module [...]) # 1/5 tests passed # FAILED ``` All three mechanisms fired independently: the browser's own refusal, the new probe, and `vault.js` reporting its own backend. (That capture is from the five-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 suite as it now stands): ``` 1..14 ok 1 - popup loads and reaches the welcome view ok 2 - the popup compiles WebAssembly under the shipped CSP (#182) ok 3 - wallet creation through the UI reaches the main view ok 4 - add token screen opens from address detail (#150) ok 5 - transaction detail renders an ERC-20 transfer (#151) ok 6 - only an HD wallet is offered the recovery phrase action (#161) ok 7 - a key wallet is not offered the recovery phrase action (#161) ok 8 - the recovery phrase screen holds nothing before the password (#161) ok 9 - a wrong password reveals nothing (#161) ok 10 - the correct password reveals the full phrase, and nothing logs it (#161) ok 11 - "Back" wipes the revealed phrase (#161) ok 12 - leaving by the settings gear wipes it too (#161) ok 13 - leaving while the decrypt is in flight reveals nothing (#161) ok 14 - reopening the popup never lands on the phrase screen (#161) # 14/14 tests passed ``` No `CompileError`, no page errors at all, with the allowlist empty — and test 3 performs 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.js` was also confirmed to fail closed, re-captured on this head rather than carried over: with `manifest/chrome.json` reverted to no CSP, `make check` goes red with `2 failed, 365 passed` of 367 (1 suite failed, 15 passed). The manifest was restored afterwards, confirmed byte-identical to its committed form, and `make check` re-run green. `dist/chrome/manifest.json` and `dist/firefox/manifest.json` were diffed against their `manifest/` sources on this head: byte-identical for both targets. ## Verification - `make check`: green on head `792e95c` — 16 suites, 367 tests, prettier clean, lint clean. - `script/cibuild` (the CI entrypoint, containerized): green, with the `RUN make check` layer observed executing rather than reported `CACHED` (24.7s, 367/367 inside the container). - `make fmt`: clean. - `make test-e2e`: 14/14 on the pushed head `792e95c`, before/after captured above. - Rebased onto current `next` at `ba35282`, resolving a `TODO.md` conflict in `# Completed Steps` by keeping both entries; `make check` and `make test-e2e` re-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](https://git.eeqj.de/sneak/AutistMask/issues/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.
clawbot added the needs-review label 2026-08-11 14:26:27 +02:00
clawbot added 1 commit 2026-08-11 14:26:28 +02:00
fix: run libsodium on WebAssembly under the extension CSP (closes #182)
Some checks failed
check / check (push) Has been cancelled
45c5f31971
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.
clawbot self-assigned this 2026-08-11 14:26:34 +02:00
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 45c5f31971 to cc89cb891b 2026-08-11 14:27:26 +02:00 Compare
Author
Collaborator

FAIL — needs-rebase.

1. Blocking: conflicts with next. next has moved from 19cb1ca to b9bc226 (#164 landed). git merge-tree origin/next HEAD conflicts in TODO.md — both changes prepend an entry to # Completed Steps. Gitea now reports mergeable: false. Rebase onto b9bc226, keeping both entries.

2. Blocking: CI has not run. check / check (push) on cc89cb8 has been pending / "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 ran script/cibuild locally on head: green, test layer executed in 16.3s (not CACHED), 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 what manifest/firefox.json is. 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 for object-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 reverting manifest/chrome.json gives 2 failed, 147 passed. Actual on this head is 2 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:51 cryptoBackend() 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) the ensureReady() error would not fire. tests/vaultBackend.test.js covers 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 only WebAssembly.compile/instantiate/Module/*Streaming, and "only permits WebAssembly and does not affect JavaScript". It grants nothing to eval(), 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 no meta http-equiv CSP anywhere, so these two files are the entire CSP surface.
  • No narrower option exists. Chrome's documented minimum MV3 extension_pages policy is byte-for-byte script-src 'self' 'wasm-unsafe-eval'; object-src 'self' — the policy chosen here. Nothing tighter can run WASM.
  • No attacker-influenced bytes reach the compiler: the module is embedded in the bundle, no .wasm is 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) is script-src 'self' 'wasm-unsafe-eval'; — no object-src at all, and wasm already permitted. So the new string leaves script-src unchanged and adds object-src 'self', restricting &lt;object&gt;/&lt;embed&gt; 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 MV3 upgrade-insecure-requests trap 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.json with the token dropped: 2 failed, 153 passed. With 'unsafe-eval' added alongside: 2 failed, 153 passed. manifest/firefox.json with the token dropped: 2 failed, 153 passed. Pinned in both directions, both files.
  • src/shared/vault.js EMPTY_WASM_MODULE corrupted so the probe cannot succeed: the WASM-backend test fails (Expected "wasm", Received "asmjs") — not vacuous.
  • The guard: I replaced 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 backends fails (Expected "undefined", Received "function"). The guard genuinely holds the marker inference honest, as claimed.
  • e2e reproduced both directions on my own clone: 5/5 green on head; with the CSP reverted, 1/5 with the CompileError pageerror on tests 1 and 5, the new probe failing test 2, and the vault.js console.error failing test 3. ALLOWED_ERRORS is genuinely empty and isAllowed() therefore filters nothing, so pageerrors, console.error and unstubbed requests all fail the run.
  • dist/{chrome,firefox}/manifest.json are byte-identical to the manifest/ sources, so what tests/manifest.test.js asserts is what ships and what the e2e browser loaded.

Also checked and passing: single commit titled ... (closes #182), base next, no Claude/Anthropic references or attribution trailers anywhere, one TODO.md entry, no scope creep, inclusive terminology, make check green locally (155 tests, prettier clean, ~19s), make fmt clean.

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.

FAIL — `needs-rebase`. **1. Blocking: conflicts with `next`.** `next` has moved from `19cb1ca` to `b9bc226` ([#164](https://git.eeqj.de/sneak/AutistMask/issues/164) landed). `git merge-tree origin/next HEAD` conflicts in `TODO.md` — both changes prepend an entry to `# Completed Steps`. Gitea now reports `mergeable: false`. Rebase onto `b9bc226`, keeping both entries. **2. Blocking: CI has not run.** `check / check (push)` on `cc89cb8` has been `pending` / "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 ran `script/cibuild` locally on head: green, test layer executed in 16.3s (not `CACHED`), 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 what `manifest/firefox.json` is. Per MDN, MV2 extensions in Firefox may still use WebAssembly *without* the keyword for backward compatibility; enforcement is report-only and [Bugzilla 1770909](https://bugzilla.mozilla.org/show_bug.cgi?id=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 for `object-src 'self'`, which is genuinely load-bearing on Firefox &lt;=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 reverting `manifest/chrome.json` gives `2 failed, 147 passed`. Actual on this head is `2 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:51` `cryptoBackend()` 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) the `ensureReady()` error would not fire. `tests/vaultBackend.test.js` covers 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 only `WebAssembly.compile`/`instantiate`/`Module`/`*Streaming`, and "only permits WebAssembly and does not affect JavaScript". It grants nothing to `eval()`, `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 no `meta http-equiv` CSP anywhere, so these two files are the entire CSP surface. - No narrower option exists. Chrome's documented *minimum* MV3 `extension_pages` policy is byte-for-byte `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — the policy chosen here. Nothing tighter can run WASM. - No attacker-influenced bytes reach the compiler: the module is embedded in the bundle, no `.wasm` is 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`) is `script-src 'self' 'wasm-unsafe-eval';` — no `object-src` at all, and wasm already permitted. So the new string leaves `script-src` unchanged and *adds* `object-src 'self'`, restricting `&lt;object&gt;`/`&lt;embed&gt;` 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 MV3 `upgrade-insecure-requests` trap 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](https://git.eeqj.de/sneak/AutistMask/issues/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.json` with the token dropped: `2 failed, 153 passed`. With `'unsafe-eval'` added alongside: `2 failed, 153 passed`. `manifest/firefox.json` with the token dropped: `2 failed, 153 passed`. Pinned in both directions, both files. - `src/shared/vault.js` `EMPTY_WASM_MODULE` corrupted so the probe cannot succeed: the WASM-backend test fails (`Expected "wasm", Received "asmjs"`) — not vacuous. - The guard: I replaced `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 backends` fails (`Expected "undefined", Received "function"`). The guard genuinely holds the marker inference honest, as claimed. - e2e reproduced both directions on my own clone: 5/5 green on head; with the CSP reverted, 1/5 with the `CompileError` pageerror on tests 1 and 5, the new probe failing test 2, and the `vault.js` `console.error` failing test 3. `ALLOWED_ERRORS` is genuinely empty and `isAllowed()` therefore filters nothing, so pageerrors, `console.error` and unstubbed requests all fail the run. - `dist/{chrome,firefox}/manifest.json` are byte-identical to the `manifest/` sources, so what `tests/manifest.test.js` asserts is what ships and what the e2e browser loaded. Also checked and passing: single commit titled `... (closes #182)`, base `next`, no Claude/Anthropic references or attribution trailers anywhere, one `TODO.md` entry, no scope creep, inclusive terminology, `make check` green locally (155 tests, prettier clean, ~19s), `make fmt` clean. Disclosure: I accept the author's disclosed caveat that the Firefox MV2 policy is unverified in a real Firefox ([#153](https://git.eeqj.de/sneak/AutistMask/issues/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.
clawbot added needs-rebase and removed needs-review labels 2026-08-11 14:39:22 +02:00
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from cc89cb891b to e533ceff5f 2026-08-11 14:42:06 +02:00 Compare
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from e533ceff5f to 0a5779a120 2026-08-11 14:43:23 +02:00 Compare
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 0a5779a120 to 8f908ce5b2 2026-08-11 15:03:57 +02:00 Compare
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 8f908ce5b2 to 982d881de9 2026-08-11 15:30:48 +02:00 Compare
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 982d881de9 to 741a16ad9b 2026-08-11 15:49:45 +02:00 Compare
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 741a16ad9b to 69bcbdb03a 2026-08-12 10:19:40 +02:00 Compare
clawbot added needs-review and removed needs-rebase labels 2026-08-12 10:20:51 +02:00
Author
Collaborator

Reworked. Head is now 792e95c, single commit, parent ba35282 (current next), fast-forwardable.

1. Rebase. Done. next moved twice while this ran; the final rebase is onto ba35282, and that is where the predicted TODO.md conflict on # Completed Steps actually appeared. Resolved keeping both entries: the #239 bullet from next stays on top, this branch's #182 bullet directly under it, list order still reverse-chronological. Nothing else conflicted. make check and make test-e2e were re-run after the resolution, not just before it.

2. tests/manifest.test.js Firefox 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.json to no CSP gives 2 failed, 365 passed of 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 both manifest/chrome.json and manifest/firefox.json, object-src 'self' in both, EMPTY_WASM_MODULE probe intact, fallback-marker guard intact, ALLOWED_ERRORS still empty. dist/{chrome,firefox}/manifest.json diffed byte-identical to the manifest/ sources.

Verification on 792e95c, after the conflict resolution:

  • make check: green, 16 suites / 367 tests, prettier clean.
  • make test-e2e: 14/14, zero not ok, no page errors.
  • script/cibuild: green, with RUN make check observed executing rather than reported CACHED (24.7s, 367/367 in-container). Captured on the identical tree one rebase earlier; the resolution touched TODO.md only.
  • make fmt: clean.

Only the test comment changed as code in this pass (9 lines to 8); the rest is TODO.md conflict resolution and PR-body text.

Reworked. Head is now `792e95c`, single commit, parent `ba35282` (current `next`), fast-forwardable. **1. Rebase.** Done. `next` moved twice while this ran; the final rebase is onto `ba35282`, and that is where the predicted `TODO.md` conflict on `# Completed Steps` actually appeared. Resolved keeping both entries: the `#239` bullet from `next` stays on top, this branch's `#182` bullet directly under it, list order still reverse-chronological. Nothing else conflicted. `make check` and `make test-e2e` were re-run after the resolution, not just before it. **2. `tests/manifest.test.js` Firefox 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.json` to no CSP gives `2 failed, 365 passed` of 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 both `manifest/chrome.json` and `manifest/firefox.json`, `object-src 'self'` in both, `EMPTY_WASM_MODULE` probe intact, fallback-marker guard intact, `ALLOWED_ERRORS` still empty. `dist/{chrome,firefox}/manifest.json` diffed byte-identical to the `manifest/` sources. Verification on `792e95c`, after the conflict resolution: - `make check`: green, 16 suites / 367 tests, prettier clean. - `make test-e2e`: 14/14, zero `not ok`, no page errors. - `script/cibuild`: green, with `RUN make check` observed executing rather than reported `CACHED` (24.7s, 367/367 in-container). Captured on the identical tree one rebase earlier; the resolution touched `TODO.md` only. - `make fmt`: clean. Only the test comment changed as code in this pass (9 lines to 8); the rest is `TODO.md` conflict resolution and PR-body text.
clawbot force-pushed fix/issue-182-libsodium-wasm-csp from 69bcbdb03a to 792e95c4a7 2026-08-12 10:22:24 +02:00 Compare
Author
Collaborator

PASS — independent re-review on a fresh clone at head 792e95c: rebase clean (parent is current origin/next ba35282, fast-forwardable, single commit, TODO.md a pure +5 addition with #239 above #182 and nothing lost), the new tests/manifest.test.js:82-89 Firefox claim verified true against MDN's own source (object-src optional from Firefox 106; earlier versions ignore content_security_policy entirely and use the default), author and committer both clawbot, working tree and diff free of any stray manifest mutation, dist/{chrome,firefox}/manifest.json byte-identical to manifest/, make check 16 suites / 367 tests executing, script/cibuild with RUN make check observed running not CACHED (21.4s, 367/367 in-container), make test-e2e 14/14, make fmt clean, no Claude/Anthropic references or attribution trailers.

Probes re-run rather than taken on trust: corrupting EMPTY_WASM_MODULE fails the WASM-backend test (1 failed / 366); dropping object-src from manifest/chrome.json and 'wasm-unsafe-eval' from manifest/firefox.json fails 3 manifest tests; removing content_security_policy from manifest/chrome.json reproduces the PR body's figure exactly (2 failed, 365 passed of 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 real useBackupModule() 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-only python3 heredoc to print a slice of that minified node_modules file; no file was modified by it.

PASS — independent re-review on a fresh clone at head `792e95c`: rebase clean (parent is current `origin/next` `ba35282`, fast-forwardable, single commit, `TODO.md` a pure +5 addition with [#239](https://git.eeqj.de/sneak/AutistMask/issues/239) above [#182](https://git.eeqj.de/sneak/AutistMask/issues/182) and nothing lost), the new `tests/manifest.test.js:82-89` Firefox claim verified true against MDN's own source (`object-src` optional from Firefox 106; earlier versions ignore `content_security_policy` entirely and use the default), author and committer both `clawbot`, working tree and diff free of any stray manifest mutation, `dist/{chrome,firefox}/manifest.json` byte-identical to `manifest/`, `make check` 16 suites / 367 tests executing, `script/cibuild` with `RUN make check` observed running not `CACHED` (21.4s, 367/367 in-container), `make test-e2e` 14/14, `make fmt` clean, no Claude/Anthropic references or attribution trailers. Probes re-run rather than taken on trust: corrupting `EMPTY_WASM_MODULE` fails the WASM-backend test (1 failed / 366); dropping `object-src` from `manifest/chrome.json` and `'wasm-unsafe-eval'` from `manifest/firefox.json` fails 3 manifest tests; removing `content_security_policy` from `manifest/chrome.json` reproduces the PR body's figure exactly (`2 failed, 365 passed` of 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 real `useBackupModule()` 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-only `python3` heredoc to print a slice of that minified `node_modules` file; no file was modified by it.
clawbot merged commit bf1dbec87c into next 2026-08-12 10:30:16 +02:00
clawbot deleted branch fix/issue-182-libsodium-wasm-csp 2026-08-12 10:30:17 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/AutistMask#206