Compare commits

...

2 Commits

Author SHA1 Message Date
792e95c4a7 fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 31s
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.
2026-08-12 08:21:34 +00:00
ba35282092 docs: describe the bundled token list by its criterion, not a drifting count (closes #239)
All checks were successful
check / check (push) Successful in 29s
2026-08-12 10:20:40 +02:00
10 changed files with 373 additions and 60 deletions

107
README.md
View File

@@ -123,16 +123,17 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright `playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image. version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen, the It covers popup load, WebAssembly compilation under the shipped CSP (see
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen [Content Security Policy](#content-security-policy)), wallet creation through
— which wallet types are offered it, that it holds nothing before the password the UI, the Add Token screen, the transaction detail screen for an ERC-20
is accepted, that a wrong password reveals nothing, that leaving it by either transfer, and the recovery phrase screen — which wallet types are offered it,
route wipes it — including a leave taken while the decrypt is still running — that it holds nothing before the password is accepted, that a wrong password
and that reopening the popup does not land on it. All outbound network is reveals nothing, that leaving it by either route wipes it — including a leave
intercepted at the browser level and served from fixtures in taken while the decrypt is still running — and that reopening the popup does not
`tests/e2e/network.js`, so the run is deterministic and fully offline; land on it. All outbound network is intercepted at the browser level and served
unrecognised outbound requests are reported as failures rather than silently from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
allowed. offline; unrecognised outbound requests are reported as failures rather than
silently allowed.
That reporting has one bound worth knowing. Observation ends when the browser That reporting has one bound worth knowing. Observation ends when the browser
context is torn down, and nothing can watch traffic after that, so the run keeps context is torn down, and nothing can watch traffic after that, so the run keeps
@@ -443,9 +444,9 @@ The core hierarchy is **Wallets → Addresses**:
Which tokens an address shows is decided by `fetchTokenBalances()` in Which tokens an address shows is decided by `fetchTokenBalances()` in
`src/shared/balances.js`, from the Blockscout `token-balances` response, so `src/shared/balances.js`, from the Blockscout `token-balances` response, so
tokens do appear without the user adding them. An ERC-20 is shown when its tokens do appear without the user adding them. An ERC-20 is shown when its
balance is nonzero and it is in the bundled top-250 token list, is tracked by balance is nonzero and it is in the bundled known-token list, is tracked by the
the user, or has 1,000 or more holders; a token claiming a symbol from the user, or has 1,000 or more holders; a token claiming a symbol from the bundled
bundled list from any other contract address is always dropped. That filter is list from any other contract address is always dropped. That filter is
unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs unconditional — the "Hide tokens with fewer than 1,000 holders" setting governs
the transaction history and the send-screen token selector, not this list. the transaction history and the send-screen token selector, not this list.
Tracked tokens with a zero balance are listed as well while "Show tracked tokens Tracked tokens with a zero balance are listed as well while "Show tracked tokens
@@ -1008,7 +1009,7 @@ communicates with three external services to function as a wallet:
What the extension does NOT do: What the extension does NOT do:
- No analytics or telemetry services - No analytics or telemetry services
- No token list APIs (the top-250 token list is bundled at build time) - No token list APIs (the known-token list is bundled at build time)
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
@@ -1068,6 +1069,36 @@ battle-tested.
Exceptions require explicit authorization in a code comment referencing this Exceptions require explicit authorization in a code comment referencing this
policy, but as of now there are none. policy, but as of now there are none.
### Content Security Policy
Both manifests declare the same policy for extension pages —
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2).
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
build and a `wasm2js` translation of it in one file, tries WASM first, and
silently falls back to the translation 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 same Argon2id parameters
the vault uses (`OPSLIMIT_INTERACTIVE`, `MEMLIMIT_INTERACTIVE`), WASM derives a
key in 141-198ms and `wasm2js` in 3204-3660ms. The work factor is identical — it
is set by the ops and memory parameters, not by wall time — so the fallback
bought nothing and cost about three and a half seconds on every operation that
asks for the password, which is every signature.
The keyword permits compiling WebAssembly and nothing else: not `eval()` of
strings, not inline script, not remote script. Using it requires already
executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted.
The grant is pinned in both directions. `tests/manifest.test.js` asserts the
exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
20x regression on the key derivation) and adding anything beyond it both fail
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest.
### DEBUG Mode Policy ### DEBUG Mode Policy
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and
@@ -1136,8 +1167,8 @@ hardcoded test phrase.
- Add multiple addresses within an HD wallet - Add multiple addresses within an HD wallet
- Manage multiple wallets simultaneously - Manage multiple wallets simultaneously
- View ETH balance per address - View ETH balance per address
- View ERC-20 token balances (bundled top-250 tokens, tokens with 1,000 or more - View ERC-20 token balances (tokens on the bundled known-token list, tokens
holders, and tokens the user adds by contract address) with 1,000 or more holders, and tokens the user adds by contract address)
- Send ETH to an address - Send ETH to an address
- Send ERC-20 tokens to an address - Send ERC-20 tokens to an address
- Receive ETH/tokens (display address, copy to clipboard, QR code) - Receive ETH/tokens (display address, copy to clipboard, QR code)
@@ -1195,26 +1226,30 @@ indexes it as a real token transfer.
address. Users should always verify the full address on the confirmation address. Users should always verify the full address on the confirmation
screen before signing or sending. screen before signing or sending.
- **Known token symbol verification**: AutistMask ships a hardcoded list of the - **Known token symbol verification**: AutistMask ships a hardcoded list of
top 250 ERC-20 tokens with their legitimate contract addresses and symbols. high-market-cap ERC-20 tokens with their legitimate contract addresses and
Any token transfer claiming a symbol from this list (e.g. "ETH", "USDT", symbols. The list is a point-in-time snapshot of the highest-market-cap
"USDC") but originating from an unrecognized contract address is identified as Ethereum mainnet ERC-20s taken from the CoinGecko API, with decimals verified
a spoof and filtered from display. The fake "Ethereum" token in the attack on-chain and addresses EIP-55 checksummed; `TOKENS` in
above used symbol "ETH" from contract `src/shared/tokenList.js` is the authoritative set. It is bundled at build
`0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not match the known time and only changes when that file is regenerated. Any token transfer
WETH contract — so it would be caught by this check. Detecting a spoof is also claiming a symbol from this list (e.g. "ETH", "USDT", "USDC") but originating
what adds a contract to the fraud contract blocklist below; that is the only from an unrecognized contract address is identified as a spoof and filtered
thing that populates it. In the transaction history the check is the "Hide from display. The fake "Ethereum" token in the attack above used symbol "ETH"
fake tokens impersonating a known symbol" setting, on by default; with it off, from contract `0xD05339f9Ea5ab9d9F03B9d57F671d2abD1F55c82`, which does not
spoofed transfers are shown and no new blocklist entries are learned from match the known WETH contract — so it would be caught by this check. Detecting
them. The send-screen token selector applies the same check unconditionally, a spoof is also what adds a contract to the fraud contract blocklist below;
because it decides which tokens the user can act on rather than what the that is the only thing that populates it. In the transaction history the check
history displays. The balance list applies it unconditionally too, but not is the "Hide fake tokens impersonating a known symbol" setting, on by default;
identically: it exempts symbols that `KNOWN_SYMBOLS` maps to `null`, and with it off, spoofed transfers are shown and no new blocklist entries are
`"ETH"` is the only one. So the fake "Ethereum" token above is filtered from learned from them. The send-screen token selector applies the same check
the transaction history and from the send selector, but a fake-`ETH` ERC-20 unconditionally, because it decides which tokens the user can act on rather
that clears the balance list's own 1,000-holder floor — or that the user than what the history displays. The balance list applies it unconditionally
tracked manually — is still shown in the balance list. too, but not identically: it exempts symbols that `KNOWN_SYMBOLS` maps to
`null`, and `"ETH"` is the only one. So the fake "Ethereum" token above is
filtered from the transaction history and from the send selector, but a
fake-`ETH` ERC-20 that clears the balance list's own 1,000-holder floor — or
that the user tracked manually — is still shown in the balance list.
- **Low-holder token filtering**: Token transfers from ERC-20 contracts with - **Low-holder token filtering**: Token transfers from ERC-20 contracts with
fewer than 1,000 holders are hidden from transaction history by default. fewer than 1,000 holders are hidden from transaction history by default.

12
TODO.md
View File

@@ -44,6 +44,18 @@ undefined identifiers, which is how
# Completed Steps # Completed Steps
- 2026-08-12: Bundled token list documentation no longer states a count. The
four "top 250" claims in `README.md` and the "roughly 500" claim in
`docs/README.md` are replaced with a description of how the list is actually
selected — a point-in-time CoinGecko snapshot of the highest-market-cap
Ethereum mainnet ERC-20s — with `TOKENS` in `src/shared/tokenList.js` named as
the authoritative set
([#239](https://git.eeqj.de/sneak/AutistMask/issues/239)).
- 2026-08-11: libsodium runs on WebAssembly in the shipped builds —
`'wasm-unsafe-eval'` added to both manifest CSPs after measuring the wasm2js
fallback at 20x the Argon2id cost, pinned in both directions by
`tests/manifest.test.js` and observed in the real popup by the e2e suite
([#182](https://git.eeqj.de/sneak/AutistMask/issues/182)).
- 2026-08-11: Known-symbol spoof verification became a Settings toggle - 2026-08-11: Known-symbol spoof verification became a Settings toggle
(`hideSpoofedSymbols`), on by default, governing the transaction-history (`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds filter and the fraud-contract learning it feeds

View File

@@ -327,17 +327,19 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled AutistMask includes several defenses against common Ethereum scams, all enabled
by default: by default:
**Known token symbol verification.** AutistMask ships a list of roughly 500 **Known token symbol verification.** AutistMask ships a bundled list of
legitimate ERC-20 tokens with their contract addresses. If a transaction or high-market-cap ERC-20 tokens with their legitimate contract addresses — a
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from point-in-time snapshot of the highest-market-cap Ethereum mainnet ERC-20s, fixed
an unrecognized contract, it is identified as a spoof and hidden. In your at build time and updated only when a new release ships a newer snapshot. If a
transaction history this is the "Hide fake tokens impersonating a known symbol" transaction or balance claims to involve a known symbol (like "ETH" or "USDT")
setting, which you can switch off; doing so also stops new entries being added but comes from an unrecognized contract, it is identified as a spoof and hidden.
to the fraud contract blocklist below, since detecting a spoof is what fills it. In your transaction history this is the "Hide fake tokens impersonating a known
The send token list always applies the check. Your balances apply it too, with symbol" setting, which you can switch off; doing so also stops new entries being
one exception: a token claiming the symbol "ETH" is not filtered there, so a added to the fraud contract blocklist below, since detecting a spoof is what
fake "ETH" token can still show up in your balance list even though it is hidden fills it. The send token list always applies the check. Your balances apply it
from your transaction history and from the send token list. too, with one exception: a token claiming the symbol "ETH" is not filtered
there, so a fake "ETH" token can still show up in your balance list even though
it is hidden from your transaction history and from the send token list.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden **Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list, and are left out of your from transaction history and the send token list, and are left out of your

View File

@@ -5,6 +5,9 @@
"description": "Minimal Ethereum wallet for Chrome", "description": "Minimal Ethereum wallet for Chrome",
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
},
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -4,6 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -1,14 +1,80 @@
// Vault: password-based encryption of secrets using libsodium. // Vault: password-based encryption of secrets using libsodium.
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption. // Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
// All crypto operations are delegated to libsodium — no raw primitives. // All crypto operations are delegated to libsodium — no raw primitives.
//
// Backend: WebAssembly, deliberately (#182).
//
// libsodium ships one file containing both a WebAssembly build and a
// wasm2js ("asm.js") translation of it. It tries WASM first and, if
// instantiation throws, silently swaps in the translation. An extension
// CSP of plain script-src 'self' refuses WASM, so every popup load used
// to take that fallback — announced by nothing but an uncaught
// CompileError in the console.
//
// Measured here, same Argon2id parameters (OPSLIMIT_INTERACTIVE,
// MEMLIMIT_INTERACTIVE = 2 passes over 64MiB), node 22 on this machine:
// WASM 141-198ms per derivation, wasm2js 3204-3660ms. The work factor is
// identical either way — it is set by the ops/mem parameters, not by wall
// time — so the fallback bought no security, it only made every password
// operation take three and a half seconds, and the wallet asks for the
// password on every signature.
//
// So both manifests declare 'wasm-unsafe-eval' for extension pages. That
// keyword permits compiling WebAssembly and nothing else: not eval() of
// strings, not inline script, not remote script. Reaching it requires
// already executing script in the extension page, which is total
// compromise on its own. 'unsafe-eval' would be a different matter and is
// not granted. tests/manifest.test.js pins both policies to exactly
// "'self' 'wasm-unsafe-eval'" so neither the grant nor the surrounding
// strictness can drift unnoticed.
//
// The fallback still exists, and a wallet that refuses to decrypt is
// worse than a slow one, so it is not disabled — it is made loud:
// cryptoBackend() reports which backend this realm can run, ensureReady()
// logs an error if it is not WASM, tests/vaultBackend.test.js asserts the
// unit tests exercise the WASM backend, and the end-to-end suite asserts
// it in the real popup under the real manifest.
const sodium = require("libsodium-wrappers-sumo"); const sodium = require("libsodium-wrappers-sumo");
const { log } = require("./log");
// An empty WebAssembly module: the 8-byte magic number and version header,
// no sections. Compiling it asks the cheapest possible form of the only
// question that matters here — may this realm compile WebAssembly at all —
// which is exactly what a CSP without 'wasm-unsafe-eval' refuses, and
// exactly what decides which backend libsodium ends up on.
const EMPTY_WASM_MODULE = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
]);
// "wasm" or "asmjs": whether this realm may compile WebAssembly, which is
// what decides libsodium's backend when the CSP is the reason it cannot —
// the case this codebase guards. It probes the realm, not libsodium, so a
// fallback taken for some other reason (allocation failure, corrupt module)
// would not be caught here; tests/vaultBackend.test.js checks libsodium's
// own marker directly.
async function cryptoBackend() {
try {
await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm";
} catch (_) {
return "asmjs";
}
}
let ready = false; let ready = false;
async function ensureReady() { async function ensureReady() {
if (!ready) { if (!ready) {
await sodium.ready; await sodium.ready;
if ((await cryptoBackend()) !== "wasm") {
log.errorf(
"libsodium is running on the wasm2js fallback: this realm " +
"refuses to compile WebAssembly, so every password " +
"derivation costs roughly 20x what it should. See the " +
"backend note in src/shared/vault.js.",
);
}
ready = true; ready = true;
} }
} }
@@ -59,4 +125,4 @@ async function decryptWithPassword(encrypted, password) {
return sodium.to_string(plaintext); return sodium.to_string(plaintext);
} }
module.exports = { encryptWithPassword, decryptWithPassword }; module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };

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 // 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 // concession in an otherwise zero-tolerance policy: an uncaught error is
// how this harness caught issue #150 in the first place. // how this harness caught issue #150 in the first place.
const ALLOWED_ERRORS = [ //
{ // Empty, and worth keeping that way. Its only entry was the WASM
// libsodium ships a WASM build and an asm.js fallback. The // CompileError libsodium provoked on every popup load, deleted with #182
// extension CSP (script-src 'self', with no wasm-unsafe-eval) // when both manifests started allowing WASM; the run that used to need it
// refuses the WASM module on every popup load; libsodium catches // is now the run that proves the fix.
// it and falls back to asm.js, so the wallet works. Deciding const ALLOWED_ERRORS = [];
// 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/,
},
];
function isAllowed(text) { function isAllowed(text) {
return ALLOWED_ERRORS.some((a) => a.pattern.test(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 }); 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) { async function openPopup(ctx, popupUrl) {
const page = await ctx.newPage(); const page = await ctx.newPage();
await page.goto(popupUrl); await page.goto(popupUrl);
@@ -293,5 +307,6 @@ module.exports = {
launch, launch,
openAddressDetail, openAddressDetail,
openPopup, openPopup,
pageCompilesWasm,
visible, visible,
}; };

View File

@@ -15,6 +15,7 @@ const {
launch, launch,
openAddressDetail, openAddressDetail,
openPopup, openPopup,
pageCompilesWasm,
visible, visible,
} = require("./harness"); } = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network"); const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
@@ -60,6 +61,27 @@ test("popup loads and reaches the welcome view", async (env) => {
assert(title === "AutistMask", "unexpected popup title: " + title); assert(title === "AutistMask", "unexpected popup title: " + title);
}); });
// The empirical half of #182. The manifest change is only a claim about
// what the CSP permits; this is the observation. Two things have to hold
// together, and the run covers both: the popup realm compiles WASM (here),
// and no WASM refusal or abort is recorded anywhere in the run — the
// harness allowlist that used to excuse exactly that error is now empty,
// so a recurrence fails whichever test it lands in rather than being
// tolerated. Since libsodium's WASM module is embedded in the bundle and
// needs no fetch, a realm that compiles WASM is a realm where libsodium
// takes the WASM path, and the next test drives a real vault encryption
// through it.
test("the popup compiles WebAssembly under the shipped CSP (#182)", async (env) => {
const ok = await pageCompilesWasm(env.page);
assert(
ok,
"the popup refused to compile WebAssembly. The shipped manifest CSP " +
"has lost 'wasm-unsafe-eval', so libsodium is back on its wasm2js " +
"fallback and every password derivation costs roughly 20x what it " +
"should — see the backend note in src/shared/vault.js",
);
});
test("wallet creation through the UI reaches the main view", async (env) => { test("wallet creation through the UI reaches the main view", async (env) => {
env.phrase = await createWallet(env.page); env.phrase = await createWallet(env.page);
assert( assert(

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);
});
});

View File

@@ -0,0 +1,52 @@
// The unit tests must exercise the libsodium backend that actually ships
// (#182). Before this, they could not: node compiles WebAssembly happily,
// the extension CSP refused it, and so the browser silently ran the
// wasm2js translation while every test ran the WASM build.
//
// With 'wasm-unsafe-eval' in both manifests the two agree, and these tests
// hold that agreement in place from the node side. tests/manifest.test.js
// holds up the CSP end of it, and the end-to-end suite observes the real
// popup.
const { cryptoBackend } = require("../src/shared/vault");
// The module libsodium-wrappers-sumo itself requires and drives. Not a new
// dependency: it is inspected here, never used to perform crypto, because
// it is the only thing that can say which backend is loaded.
const SODIUM_CORE = "libsodium-sumo";
describe("libsodium backend", () => {
test("this realm compiles WebAssembly, so the tests run the WASM build", async () => {
await expect(cryptoBackend()).resolves.toBe("wasm");
});
test("libsodium did not swap in the wasm2js fallback", async () => {
const core = require(SODIUM_CORE);
await require("libsodium-wrappers-sumo").ready;
// useBackupModule is the entry point to the fallback; taking it
// replaces the module's exports with the translation's, and the
// entry point goes with them. Still present after ready means the
// WASM module is the one in place. The test below is what keeps
// that inference honest.
expect(typeof core.useBackupModule).toBe("function");
});
// Deliberately last, and deliberately destructive: it takes the
// fallback, which replaces the loaded module for the rest of this
// file. Jest gives each test file its own module registry, so nothing
// outside sees it.
//
// Without this, the check above would be a claim about libsodium's
// internals with nothing holding it to account: if a future version
// kept useBackupModule on the fallback module too, the marker would
// quietly become true in both backends and the test would pass while
// measuring nothing. Forcing the fallback and watching the marker
// disappear is what makes its presence mean something.
test("the fallback marker distinguishes the two backends", async () => {
const core = require(SODIUM_CORE);
await require("libsodium-wrappers-sumo").ready;
expect(typeof core.useBackupModule).toBe("function");
await core.useBackupModule();
expect(typeof core.useBackupModule).toBe("undefined");
});
});