Compare commits

...

2 Commits

Author SHA1 Message Date
cc89cb891b fix: run libsodium on WebAssembly under the extension CSP (closes #182)
Some checks failed
check / check (push) Has been cancelled
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-11 12:27:02 +00:00
19cb1ca1b0 docs: correct docs/README.md external services and remove competitor names (closes #163)
Some checks failed
check / check (push) Has been cancelled
2026-08-11 14:25:57 +02:00
10 changed files with 477 additions and 93 deletions

View File

@@ -107,12 +107,13 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen and
the transaction detail screen for an ERC-20 transfer. All outbound network is
intercepted at the browser level and served from fixtures in
`tests/e2e/network.js`, so the run is deterministic and fully offline;
unrecognised outbound requests are reported as failures rather than silently
allowed.
It covers popup load, WebAssembly compilation under the shipped CSP (see
[Content Security Policy](#content-security-policy)), wallet creation through
the UI, the Add Token screen and the transaction detail screen for an ERC-20
transfer. All outbound network is intercepted at the browser level and served
from fixtures in `tests/e2e/network.js`, so the run is deterministic and fully
offline; unrecognised outbound requests are reported as failures rather than
silently allowed.
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
@@ -751,6 +752,36 @@ battle-tested.
Exceptions require explicit authorization in a code comment referencing this
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
The `DEBUG` constant in the popup JS enables a red "DEBUG / INSECURE" banner and

View File

@@ -44,6 +44,14 @@ undefined identifiers, which is how
# Completed Steps
- 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: `docs/README.md` rewritten against the code: no competitor names,
all five network destinations documented, password/Settings/Add Wallet
sections corrected ([#163](https://git.eeqj.de/sneak/AutistMask/issues/163)).
- 2026-08-11: Wallet deletion repairs its own state — `hasWallet` follows the
remaining wallets, the selection only moves when it was deleted, and the
active-address change is broadcast to connected sites

View File

@@ -6,10 +6,10 @@ and ERC-20 tokens, and connects to web3 sites. Nothing else.
## Why AutistMask Exists
MetaMask has become bloated with swap UIs, portfolio dashboards, analytics,
tracking, and advertisements. It is no longer a simple wallet. Most alternatives
(Rabby, Rainbow, etc.) only support Chromium browsers, leaving Firefox users
without a usable option.
The most popular browser-based EVM wallet has become bloated with swap UIs,
portfolio dashboards, analytics, tracking, and advertisements. It is no longer a
simple wallet. The common alternatives only support Chromium browsers, leaving
Firefox users without a usable option.
AutistMask exists because a wallet should be a wallet. You should be able to see
your balances, send tokens, receive tokens, and connect to sites. That is all a
@@ -27,9 +27,10 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **Encrypt your recovery phrase and private keys at rest.** Your secrets are
encrypted on disk using Argon2id key derivation and XSalsa20-Poly1305
authenticated encryption (via libsodium). Your password is required only when
signing a transaction. Viewing balances and addresses never requires a
password.
authenticated encryption (via libsodium). Your password is required whenever a
secret has to be decrypted: signing a transaction, signing a message or typed
data, exporting a private key, and deleting a wallet. Viewing balances and
addresses never requires a password.
- **Let you choose your own RPC endpoint.** The default is a public Ethereum
RPC, but you can point it at your own node or any provider you trust. No
@@ -56,23 +57,25 @@ analytics, use a portfolio tracker. The wallet is not the place for any of that.
- **No NFT galleries or portfolio views.** This is a wallet, not a dashboard.
- **No token auto-discovery.** AutistMask does not scan the blockchain for
tokens you might hold. You add tokens manually by contract address. This
prevents scam tokens from appearing in your wallet uninvited.
- **No third-party token list APIs.** Token balances come from the same block
explorer you configure for transaction history, and the extension ships its
own hardcoded list of top ERC-20 contract addresses for symbol-spoofing
detection. Any token you want tracked across all your addresses, you add
yourself by contract address.
- **No phishing blocklists from third parties.** AutistMask does not phone home
to check URLs against a remote blocklist. It does maintain a local list of
known scam addresses, but this is shipped with the extension, not fetched from
a server.
- **No backend servers operated by the developer.** Nothing is sent to any
server run by AutistMask. Every network destination is listed below.
## How It Works
AutistMask is a browser extension that runs entirely in your browser. It does
not have a backend server. It communicates with three external services:
not have a backend server. It communicates with five external destinations:
three you configure yourself, and two fixed ones used for scam detection.
### External Services
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`)
**Ethereum JSON-RPC endpoint** (default: `ethereum-rpc.publicnode.com`;
`ethereum-sepolia-rpc.publicnode.com` on Sepolia)
This is how AutistMask talks to the Ethereum network. Every wallet needs an
Ethereum node to check balances, estimate gas, broadcast transactions, and
@@ -80,27 +83,73 @@ verify confirmations. The default is a free public RPC endpoint. You can change
this in Settings to any Ethereum JSON-RPC endpoint, including your own local
node.
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups). Your addresses are
necessarily visible to the RPC provider when querying balances.
When it is contacted: on every balance refresh (every 10 seconds while the popup
is open, every 60 seconds in the background), when you type an ENS name into the
Send screen, when a send is prepared and broadcast, while a pending transaction
is polled for its receipt, and for the reverse ENS lookups used to label
addresses (cached for 12 hours).
**Blockscout API** (default: `eth.blockscout.com/api/v2`)
What gets sent: standard Ethereum JSON-RPC requests (balance queries,
transaction broadcasts, gas estimates, ENS lookups, contract-code checks). Your
addresses are necessarily visible to the RPC provider when querying balances.
**Blockscout API** (default: `eth.blockscout.com/api/v2`;
`eth-sepolia.blockscout.com/api/v2` on Sepolia)
Used to fetch token balances and transaction history. Blockscout is an
open-source blockchain explorer. AutistMask queries it for your ERC-20 token
balances and recent transactions. You can change this in Settings to a
balances (including the holder counts used for spam filtering) and your recent
transactions and token transfers. You can change this in Settings to a
self-hosted Blockscout instance.
When it is contacted: on every balance refresh, and whenever a screen showing
transaction history is opened.
What gets sent: your Ethereum addresses (to look up balances and transactions).
**CoinDesk CADLI price API** (`data-api.coindesk.com`)
Used to fetch current USD prices for ETH and ERC-20 tokens. Prices are cached
for 5 minutes. No API key is required. No user data is sent -- only a list of
token symbols (e.g. "ETH", "USDC") to get their prices.
Used to fetch current USD prices for ETH and the top 25 tokens. Prices are
cached for 5 minutes. No API key is required. This endpoint is not
user-configurable, and it is not contacted at all while you are on a testnet,
where no USD values are shown.
What gets sent: token symbol names. No addresses, no balances, no identifying
information.
When it is contacted: while the popup is open, at most once every 5 minutes.
What gets sent: token symbol names (e.g. "ETH", "USDC"). No addresses, no
balances, no identifying information. As with any request, CoinDesk sees your IP
address.
**Phishing domain blocklist** (`raw.githubusercontent.com`)
A community-maintained list of phishing domains, used to warn you when a site
that asks to connect, or to have a transaction or signature approved, is a known
scam. A copy is bundled into the extension at build time, so the protection
works before any network request happens. At runtime the extension fetches the
live list to pick up newly added domains, keeping only the entries not already
in the bundled copy (persisted locally if under 256 KiB). This endpoint is not
user-configurable.
When it is contacted: once when the background script starts, and every 24 hours
after that. It is a plain download of a public file — nothing about you is sent,
but the host sees your IP address. If the fetch fails, the bundled copy is still
used.
**Etherscan address labels** (`etherscan.io`; `sepolia.etherscan.io` on Sepolia)
When you review a send, AutistMask fetches the recipient's public Etherscan
address page and looks for a "Fake_Phishing"/"Phish/Hack" label or a scam
warning, and shows a red warning if it finds one. This is a plain page fetch
with no API key, made by your browser. It is best-effort: if it fails, it is
silently ignored. This endpoint is not user-configurable.
When it is contacted: each time you reach the send confirmation screen.
What gets sent: the recipient address you are about to send to, and your IP
address. Your own addresses are not sent.
Etherscan links shown elsewhere in the UI (on addresses, transactions, and token
contracts) are ordinary links. They contact nothing until you click them.
### What Stays Local
@@ -123,8 +172,11 @@ word recovery phrase can restore your wallet on any device without your
password. The password only protects the copy stored in this browser. If you
lose your recovery phrase, your password cannot help you recover it.
Your password is only requested when you send a transaction. Viewing balances,
receiving funds, and browsing transaction history never require your password.
Your password is requested whenever an encrypted secret must be decrypted: when
you send a transaction, when a site asks you to sign a message or typed data,
when you export an address's private key, and when you delete a wallet. Viewing
balances, receiving funds, and browsing transaction history never require your
password.
## Installation
@@ -147,34 +199,46 @@ receiving funds, and browsing transaction history never require your password.
### Creating a New Wallet
1. Click the AutistMask icon in your browser toolbar.
2. Click "Add wallet".
3. Click the die button to generate a random 12-word recovery phrase.
2. Click "Add wallet" (on first use), or open Settings and click "+ Add wallet".
3. On the "From Phrase" tab, click the die button to generate a random 12-word
recovery phrase.
4. **Write down the recovery phrase and store it safely.** Anyone with these
words can take your funds. If you lose them, your wallet is gone. AutistMask
cannot recover them for you.
5. Choose a password. This encrypts your recovery phrase on this device.
6. Click "Add".
5. Choose a password and confirm it. This encrypts your recovery phrase on this
device.
6. Click "Import".
### Importing an Existing Wallet
**From a recovery phrase:** Follow the same steps as creating a wallet, but
paste your existing 12 or 24 word recovery phrase instead of generating a new
one. AutistMask uses the same derivation path as MetaMask (`m/44'/60'/0'/0`), so
your addresses will match.
The Add Wallet screen has three tabs:
**From a private key:** On the Add Wallet screen, click "Have a private key
instead?" and paste your private key. This creates a single-address wallet.
**From Phrase:** Paste your existing 12 or 24 word recovery phrase instead of
generating a new one. AutistMask uses the standard BIP-44 Ethereum derivation
path (`m/44'/60'/0'/0`), which is what other wallets use by default, so your
addresses will match and your phrase stays portable in both directions.
**From Key:** Paste a single private key. This creates a single-address wallet.
**From xprv:** Paste an extended private key. This imports the HD wallet and
scans for used addresses.
All three tabs ask for the same password fields, and the "Import" button
finishes the job.
### Adding More Addresses
HD wallets (created from a recovery phrase) can derive multiple addresses. On
the home screen, click the "+" button next to a wallet name to add the next
address. These are deterministic -- the same recovery phrase will always produce
the same sequence of addresses.
HD wallets (created from a recovery phrase or an xprv) can derive multiple
addresses. On the home screen, click the "+" button next to a wallet name to add
the next address. These are deterministic -- the same recovery phrase will
always produce the same sequence of addresses.
### Adding ERC-20 Tokens
AutistMask does not auto-discover tokens. To track a token:
Tokens you hold show up automatically only if they are in the extension's
bundled list of well-known tokens or have at least 1,000 holders; everything
else is treated as spam and hidden. To track a token explicitly (which also
shows it at zero balance), add it by contract address:
1. Go to an address detail view (click `[info]` on any address).
2. Click "+ Token".
@@ -183,12 +247,13 @@ AutistMask does not auto-discover tokens. To track a token:
4. Click "Add".
The token balance will appear on the address detail screen and on the home
screen.
screen. Tokens can also be added from Settings, under "Tracked Tokens".
## Sending
1. Click "Send" from the home screen or an address detail view.
2. Select what to send (ETH or any tracked ERC-20 token).
2. Select what to send (ETH, or any ERC-20 token with a balance on this address
that survives the spam filters).
3. Enter the recipient address or ENS name (e.g. `vitalik.eth`).
4. Enter the amount.
5. Click "Review" to see the confirmation screen.
@@ -201,11 +266,14 @@ The confirmation screen shows:
- **Amount** with USD estimate
- **Your current balance** with USD estimate
- **Estimated network fee** in ETH with USD estimate
- **Warnings** if the recipient is a contract, a burn address, one of your own
addresses, on the bundled scam-address list, or labelled as a phisher on
Etherscan
After reviewing, click "Send" and enter your password. The transaction will be
broadcast to the network and you will see a waiting screen with a timer. Once
confirmed (or after 60 seconds), you will see either a success or error screen
with the transaction hash and an Etherscan link.
After reviewing, enter your password and click "Sign & Send". The transaction
will be broadcast to the network and you will see a waiting screen with a timer.
Once confirmed (or after 60 seconds), you will see either a success or error
screen with the transaction hash and an Etherscan link.
### Sending a Specific Token
@@ -219,10 +287,10 @@ cannot accidentally switch to a different one.
1. Click "Receive" from the home screen or an address detail view.
2. Share the QR code or copy the address using the "Copy address" button.
When receiving ERC-20 tokens, make sure the sender is sending on the Ethereum
network. AutistMask is an Ethereum mainnet wallet. Tokens sent on other networks
(Polygon, Arbitrum, BSC, etc.) to the same address will not appear and may be
permanently lost.
When receiving ERC-20 tokens, make sure the sender is sending on the network you
are using. AutistMask supports Ethereum mainnet and the Sepolia testnet. Tokens
sent on other networks (Polygon, Arbitrum, BSC, etc.) to the same address will
not appear and may be permanently lost.
## Connecting to Web3 Sites
@@ -237,7 +305,12 @@ pages. When a site requests access to your wallet:
When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data). You must enter your
password and click "Confirm" to authorize it.
password and click "Confirm" to authorize it. Message and typed-data signature
requests work the same way, with a "Sign" button, and also require your
password.
If the requesting site's domain is on the phishing blocklist, all three approval
screens show a red phishing warning before you decide.
You can manage site permissions in Settings. Allowed and denied sites can be
individually removed to reset their permissions.
@@ -247,15 +320,16 @@ individually removed to reset their permissions.
AutistMask includes several defenses against common Ethereum scams, all enabled
by default:
**Known token symbol verification.** AutistMask ships a list of ~250 legitimate
ERC-20 tokens with their contract addresses. If a transaction claims to involve
a known symbol (like "ETH" or "USDT") but comes from an unrecognized contract,
it is identified as a spoof and hidden.
**Known token symbol verification.** AutistMask ships a list of roughly 500
legitimate ERC-20 tokens with their contract addresses. If a transaction or
balance claims to involve a known symbol (like "ETH" or "USDT") but comes from
an unrecognized contract, it is identified as a spoof and hidden.
**Low-holder token filtering.** Tokens with fewer than 1,000 holders are hidden
from transaction history and the send token list. Legitimate tokens have
substantial holder counts; scam tokens deployed for address poisoning typically
have zero.
from transaction history and the send token list, and are left out of your
balances unless they are on the bundled known-token list or you added them
yourself. Legitimate tokens have substantial holder counts; scam tokens deployed
for address poisoning typically have zero.
**Fraud contract blocklist.** When AutistMask detects a fraudulent transfer, it
adds the contract address to a local blocklist. Future transactions from that
@@ -266,31 +340,47 @@ ETH by default) are hidden. Scammers send dust from look-alike addresses to
plant them in your transaction history. The threshold is configurable in
Settings.
All of these filters can be individually disabled in Settings if you prefer to
**Scam address list.** A list of known fraud, drainer, and phishing addresses is
shipped with the extension. Sending to one of them raises a warning on the
confirmation screen. It contains only addresses involved in fraud -- it is not a
sanctions list.
**Phishing domain warnings.** Sites asking to connect or to have something
approved are checked against the phishing domain blocklist described under
External Services, and flagged with a red banner if they match.
The first four filters can be individually disabled in Settings if you prefer to
see everything unfiltered.
## Settings
Click the gear icon on the home screen to access settings:
- **Wallets**: Add a new wallet.
- **Display**: Toggle whether tracked tokens with zero balance are shown.
- **Wallets**: Your wallets, and "+ Add wallet".
- **Tracked Tokens**: The ERC-20 tokens tracked across all addresses, and "+ Add
token".
- **Display**: Toggle whether tracked tokens with zero balance are shown, and
choose the theme (System, Light, or Dark).
- **Network**: Switch between Ethereum Mainnet and Sepolia Testnet. Switching
resets the RPC and Blockscout endpoints to that network's defaults.
- **Ethereum RPC**: Change the Ethereum node endpoint. Default is a public RPC.
You can use your own node for maximum privacy.
- **Blockscout API**: Change the Blockscout instance used for token balances and
transaction history. You can use a self-hosted instance.
- **Token Spam Protection**: Toggle individual scam filters and set the dust
transaction threshold.
- **Token Spam Protection**: Toggle individual scam filters, set the dust
transaction threshold, and switch timestamps to UTC.
- **Allowed Sites / Denied Sites**: View and manage web3 site permissions.
- **About**: License, author, version, release date, and a link to the commit
this build came from.
## Frequently Asked Questions
**Is AutistMask compatible with MetaMask?**
**Can I use AutistMask alongside another wallet?**
Yes. AutistMask uses the same derivation path (`m/44'/60'/0'/0`) as MetaMask. If
you import the same recovery phrase, you will get the same addresses. You can
use both wallets side by side, though only one can be the active
`window.ethereum` provider at a time.
Yes. AutistMask uses the standard `m/44'/60'/0'/0` derivation path, so importing
the same recovery phrase gives you the same addresses as any other wallet using
that path. Two wallet extensions can be installed side by side, though only one
can be the active `window.ethereum` provider at a time.
**Can I use AutistMask with a hardware wallet?**
@@ -298,8 +388,9 @@ Not yet. Hardware wallet support may be added in the future.
**Does AutistMask support networks other than Ethereum mainnet?**
Not currently. AutistMask is Ethereum mainnet only. Multi-chain support may be
added in the future.
Ethereum mainnet and the Sepolia testnet, selectable in Settings. No other
networks are supported today. On Sepolia, USD values are not shown, because
testnet tokens have no market value.
**Where is my data stored?**
@@ -312,7 +403,7 @@ to any server operated by AutistMask.
Your data is deleted. Make sure you have your recovery phrase backed up before
uninstalling. With your recovery phrase, you can restore your wallet in
AutistMask or any other compatible wallet (MetaMask, etc.) at any time.
AutistMask or any other wallet that uses the standard derivation path.
**What happens if a transaction times out?**

View File

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

View File

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

View File

@@ -1,14 +1,75 @@
// Vault: password-based encryption of secrets using libsodium.
// Uses Argon2id for key derivation and XSalsa20-Poly1305 for encryption.
// 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 { 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": the libsodium backend in use in this realm.
async function cryptoBackend() {
try {
await WebAssembly.compile(EMPTY_WASM_MODULE);
return "wasm";
} catch (_) {
return "asmjs";
}
}
let ready = false;
async function ensureReady() {
if (!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;
}
}
@@ -59,4 +120,4 @@ async function decryptWithPassword(encrypted, password) {
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
// concession in an otherwise zero-tolerance policy: an uncaught error is
// how this harness caught issue #150 in the first place.
const ALLOWED_ERRORS = [
{
// libsodium ships a WASM build and an asm.js fallback. The
// extension CSP (script-src 'self', with no wasm-unsafe-eval)
// refuses the WASM module on every popup load; libsodium catches
// it and falls back to asm.js, so the wallet works. Deciding
// which backend actually ships is issue #182, and this entry gets
// deleted when that lands.
issue: "#182",
pattern: /Refused to compile or instantiate WebAssembly module/,
},
];
//
// Empty, and worth keeping that way. Its only entry was the WASM
// CompileError libsodium provoked on every popup load, deleted with #182
// when both manifests started allowing WASM; the run that used to need it
// is now the run that proves the fix.
const ALLOWED_ERRORS = [];
function isAllowed(text) {
return ALLOWED_ERRORS.some((a) => a.pattern.test(text));
@@ -247,6 +241,26 @@ async function visible(page, selector, timeout = 15000) {
await page.waitForSelector(selector, { state: "visible", timeout });
}
// An empty WebAssembly module: magic number and version header, no
// sections. Compiling it in the popup asks the one question that decides
// libsodium's backend — may this realm compile WebAssembly — of the real
// page under the real shipped manifest, which is the only place the
// answer can be observed. Kept independent of src/shared/vault.js on
// purpose: a bundle asked to grade itself proves less than an outside
// observation of the same realm.
const EMPTY_WASM_MODULE = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
async function pageCompilesWasm(page) {
return page.evaluate(async (bytes) => {
try {
await WebAssembly.compile(new Uint8Array(bytes));
return true;
} catch (_) {
return false;
}
}, EMPTY_WASM_MODULE);
}
async function openPopup(ctx, popupUrl) {
const page = await ctx.newPage();
await page.goto(popupUrl);
@@ -285,5 +299,6 @@ module.exports = {
launch,
openAddressDetail,
openPopup,
pageCompilesWasm,
visible,
};

View File

@@ -14,6 +14,7 @@ const {
launch,
openAddressDetail,
openPopup,
pageCompilesWasm,
visible,
} = require("./harness");
const { STUB_TOKEN, STUB_TX_HASH } = require("./network");
@@ -55,6 +56,27 @@ test("popup loads and reaches the welcome view", async (env) => {
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) => {
await createWallet(env.page);
const addrCount = await env.page

100
tests/manifest.test.js Normal file
View File

@@ -0,0 +1,100 @@
// 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, and Firefox 102 and later
// require 'wasm-unsafe-eval' for extension pages exactly as Chrome
// does. Same policy, 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");
});
});