Compare commits
4 Commits
d74dd40743
...
e32d6896f2
| Author | SHA1 | Date | |
|---|---|---|---|
| e32d6896f2 | |||
| ce4a0d7b8d | |||
| bf1dbec87c | |||
| ba35282092 |
107
README.md
107
README.md
@@ -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
|
||||||
@@ -1021,7 +1022,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
|
||||||
|
|
||||||
@@ -1081,6 +1082,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
|
||||||
@@ -1149,8 +1180,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)
|
||||||
@@ -1208,26 +1239,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.
|
||||||
|
|||||||
16
TODO.md
16
TODO.md
@@ -50,6 +50,22 @@ undefined identifiers, which is how
|
|||||||
end the wait, reported as an unreachable network rather than as a timeout),
|
end the wait, reported as an unreachable network rather than as a timeout),
|
||||||
and the wait now resumes after a popup close
|
and the wait now resumes after a popup close
|
||||||
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
|
([#155](https://git.eeqj.de/sneak/AutistMask/issues/155)).
|
||||||
|
- 2026-08-12: An unreported `holders_count` is now parsed as `null` rather than
|
||||||
|
`0`, so the low-holder rule declines to judge an unknown count instead of
|
||||||
|
hiding a legitimate token as spam, in both the transaction history and the
|
||||||
|
Send token selector ([#230](https://git.eeqj.de/sneak/AutistMask/issues/230)).
|
||||||
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const { state, currentAddress } = require("../../shared/state");
|
|||||||
let ctx;
|
let ctx;
|
||||||
const { getProvider } = require("../../shared/balances");
|
const { getProvider } = require("../../shared/balances");
|
||||||
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
|
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
|
||||||
|
const { isLowHolderCount } = require("../../shared/holders");
|
||||||
const { getAddress } = require("ethers");
|
const { getAddress } = require("ethers");
|
||||||
|
|
||||||
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
|
||||||
@@ -132,7 +133,10 @@ function renderSendTokenSelect(addr) {
|
|||||||
for (const t of addr.tokenBalances || []) {
|
for (const t of addr.tokenBalances || []) {
|
||||||
if (isSpoofedToken(t)) continue;
|
if (isSpoofedToken(t)) continue;
|
||||||
if (fraudSet.has(t.address.toLowerCase())) continue;
|
if (fraudSet.has(t.address.toLowerCase())) continue;
|
||||||
if (state.hideLowHolderTokens && (t.holders || 0) < 1000) continue;
|
// An unknown holder count does not withhold a token the user holds:
|
||||||
|
// only a count the explorer actually reported as below the threshold
|
||||||
|
// does. Otherwise a missing field makes a real asset unspendable.
|
||||||
|
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = t.address;
|
opt.value = t.address;
|
||||||
opt.textContent = t.symbol;
|
opt.textContent = t.symbol;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { ERC20_ABI } = require("./constants");
|
|||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { deriveAddressFromXpub } = require("./wallet");
|
const { deriveAddressFromXpub } = require("./wallet");
|
||||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
|
|
||||||
// Use a static network to skip auto-detection (which can fail and cause
|
// Use a static network to skip auto-detection (which can fail and cause
|
||||||
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
// "could not coalesce error" on some RPC endpoints like Cloudflare).
|
||||||
@@ -70,10 +71,20 @@ async function fetchTokenBalances(address, blockscoutUrl, trackedTokens) {
|
|||||||
if (bal === "0.0") continue;
|
if (bal === "0.0") continue;
|
||||||
|
|
||||||
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
const tokenAddr = (item.token.address_hash || "").toLowerCase();
|
||||||
const holders = parseInt(item.token.holders_count || "0", 10);
|
// null means the explorer reported no count, which is not the
|
||||||
|
// same as a count of zero. This gate is not the low-holder
|
||||||
|
// display filter: it has no user-facing off switch and governs
|
||||||
|
// the whole balance list, so it stays strict and admits a token
|
||||||
|
// only on a reported count — an unreported one is no evidence.
|
||||||
|
// A legitimate token still reaches the list through the known
|
||||||
|
// token list or by the user tracking it, and the null is carried
|
||||||
|
// through to the views, where the two low-holder filters treat
|
||||||
|
// an unknown count as "do not judge" rather than as zero.
|
||||||
|
const holders = parseHoldersCount(item.token.holders_count);
|
||||||
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
const isKnown = TOKEN_BY_ADDRESS.has(tokenAddr);
|
||||||
const isTracked = trackedSet.has(tokenAddr);
|
const isTracked = trackedSet.has(tokenAddr);
|
||||||
const hasEnoughHolders = holders >= 1000;
|
const hasEnoughHolders =
|
||||||
|
holders !== null && holders >= LOW_HOLDER_THRESHOLD;
|
||||||
|
|
||||||
// Skip spam tokens the user never asked to see
|
// Skip spam tokens the user never asked to see
|
||||||
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
|
||||||
@@ -278,6 +289,7 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
fetchTokenBalances,
|
||||||
refreshBalances,
|
refreshBalances,
|
||||||
lookupTokenInfo,
|
lookupTokenInfo,
|
||||||
getProvider,
|
getProvider,
|
||||||
|
|||||||
32
src/shared/holders.js
Normal file
32
src/shared/holders.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Holder counts, and the one rule that decides whether a count is "low".
|
||||||
|
//
|
||||||
|
// The block explorer's holders_count is optional: it is absent on a token it
|
||||||
|
// has only just indexed, and it goes missing on a degraded or changed API.
|
||||||
|
// Absent means the count is unknown. It does not mean the token has no
|
||||||
|
// holders, and collapsing the two hides a token the user really holds as if
|
||||||
|
// it were spam. Every call site reads the count through here so the
|
||||||
|
// distinction cannot be lost again in one place while holding in the others.
|
||||||
|
|
||||||
|
const LOW_HOLDER_THRESHOLD = 1000;
|
||||||
|
|
||||||
|
// Parse an explorer-supplied holders_count into a number, or null when the
|
||||||
|
// explorer did not report one. Anything unparseable is unknown too: a count
|
||||||
|
// we cannot read is not a count of zero.
|
||||||
|
function parseHoldersCount(raw) {
|
||||||
|
if (raw === null || raw === undefined || raw === "") return null;
|
||||||
|
const n = parseInt(raw, 10);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True only for a token the explorer reported as having fewer holders than
|
||||||
|
// the threshold. An unknown count is never low: showing a spam token the
|
||||||
|
// user can see is unusual costs less than hiding an asset they own.
|
||||||
|
function isLowHolderCount(holders) {
|
||||||
|
return holders != null && holders < LOW_HOLDER_THRESHOLD;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LOW_HOLDER_THRESHOLD,
|
||||||
|
parseHoldersCount,
|
||||||
|
isLowHolderCount,
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
const { formatEther, formatUnits } = require("ethers");
|
const { formatEther, formatUnits } = require("ethers");
|
||||||
const { log, debugFetch } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { KNOWN_SYMBOLS, TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
|
const { parseHoldersCount, isLowHolderCount } = require("./holders");
|
||||||
|
|
||||||
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
// Ethereum addresses are case-insensitive: EIP-55 mixed case is a checksum
|
||||||
// over the address, not part of its identity. Every address comparison in
|
// over the address, not part of its identity. Every address comparison in
|
||||||
@@ -116,7 +117,10 @@ function parseTokenTransfer(tt, addrLower) {
|
|||||||
contractAddress: normalizeAddress(
|
contractAddress: normalizeAddress(
|
||||||
tt.token?.address_hash || tt.token?.address || "",
|
tt.token?.address_hash || tt.token?.address || "",
|
||||||
),
|
),
|
||||||
holders: parseInt(tt.token?.holders_count || "0", 10),
|
// null when the explorer reported no count: unknown, not zero. The
|
||||||
|
// low-holder filter declines to judge a null, so a legitimate token
|
||||||
|
// is not hidden because a field went missing upstream.
|
||||||
|
holders: parseHoldersCount(tt.token?.holders_count),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,12 +296,13 @@ function filterTransactions(txs, filters = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter low-holder tokens (<1000) if setting is on
|
// Filter low-holder tokens (<1000) if setting is on. A token whose
|
||||||
|
// holder count the explorer did not report is kept: only a reported
|
||||||
|
// count below the threshold is "low".
|
||||||
if (
|
if (
|
||||||
filters.hideLowHolderTokens &&
|
filters.hideLowHolderTokens &&
|
||||||
tx.contractAddress &&
|
tx.contractAddress &&
|
||||||
tx.holders !== null &&
|
isLowHolderCount(tx.holders)
|
||||||
tx.holders < 1000
|
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
166
tests/holders.test.js
Normal file
166
tests/holders.test.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// Tests for src/shared/holders.js and the balance-list spam gate that reads
|
||||||
|
// it (issue #230).
|
||||||
|
//
|
||||||
|
// The rule these pin down: an explorer that reports no holders_count has told
|
||||||
|
// us nothing, and "nothing" must not be recorded as "zero holders". Zero is
|
||||||
|
// the strongest spam signal the wallet has, so handing it out for free turns
|
||||||
|
// a missing field into a hidden asset.
|
||||||
|
|
||||||
|
jest.mock("../src/shared/log", () => ({
|
||||||
|
log: {
|
||||||
|
debugf: () => {},
|
||||||
|
infof: () => {},
|
||||||
|
warnf: () => {},
|
||||||
|
errorf: () => {},
|
||||||
|
},
|
||||||
|
debugFetch: jest.fn(),
|
||||||
|
setRuntimeDebug: () => {},
|
||||||
|
isDebug: () => false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = jest.fn(() => {
|
||||||
|
throw new Error("tests must not perform network requests");
|
||||||
|
});
|
||||||
|
global.chrome = { storage: { local: {} } };
|
||||||
|
|
||||||
|
const {
|
||||||
|
LOW_HOLDER_THRESHOLD,
|
||||||
|
parseHoldersCount,
|
||||||
|
isLowHolderCount,
|
||||||
|
} = require("../src/shared/holders");
|
||||||
|
const { fetchTokenBalances } = require("../src/shared/balances");
|
||||||
|
const { debugFetch } = require("../src/shared/log");
|
||||||
|
|
||||||
|
const BLOCKSCOUT = "https://eth.blockscout.com/api/v2";
|
||||||
|
const HOLDER = "0x66133e8ea0f5d1d612d2502a968757d1048c214a";
|
||||||
|
const USDC_CONTRACT = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
|
||||||
|
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
describe("parseHoldersCount", () => {
|
||||||
|
test("a reported count parses to that number", () => {
|
||||||
|
expect(parseHoldersCount("3500000")).toBe(3500000);
|
||||||
|
expect(parseHoldersCount(3500000)).toBe(3500000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a reported "0" parses to 0, which is not null', () => {
|
||||||
|
expect(parseHoldersCount("0")).toBe(0);
|
||||||
|
expect(parseHoldersCount(0)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an omitted, null or empty count is unknown", () => {
|
||||||
|
expect(parseHoldersCount(undefined)).toBeNull();
|
||||||
|
expect(parseHoldersCount(null)).toBeNull();
|
||||||
|
expect(parseHoldersCount("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unparseable count is unknown rather than zero", () => {
|
||||||
|
expect(parseHoldersCount("many")).toBeNull();
|
||||||
|
expect(parseHoldersCount(NaN)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isLowHolderCount", () => {
|
||||||
|
test("the threshold is the documented 1,000 holders", () => {
|
||||||
|
expect(LOW_HOLDER_THRESHOLD).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reported count below the threshold is low", () => {
|
||||||
|
expect(isLowHolderCount(0)).toBe(true);
|
||||||
|
expect(isLowHolderCount(999)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a reported count at or above the threshold is not low", () => {
|
||||||
|
expect(isLowHolderCount(1000)).toBe(false);
|
||||||
|
expect(isLowHolderCount(1001)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown count is not low", () => {
|
||||||
|
expect(isLowHolderCount(null)).toBe(false);
|
||||||
|
expect(isLowHolderCount(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// fetchTokenBalances applies its own spam gate, which is not the low-holder
|
||||||
|
// display filter: it has no setting behind it and decides what the balance
|
||||||
|
// list contains at all. It stays strict on an unknown count — see the
|
||||||
|
// comment at the gate — but must stop recording that unknown as zero.
|
||||||
|
describe("the balance-list spam gate", () => {
|
||||||
|
function respondWith(items) {
|
||||||
|
debugFetch.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => items,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function item(overrides = {}) {
|
||||||
|
const { token, ...rest } = overrides;
|
||||||
|
return {
|
||||||
|
value: "12500000",
|
||||||
|
...rest,
|
||||||
|
token: {
|
||||||
|
type: "ERC-20",
|
||||||
|
address_hash: NOVEL_TOKEN,
|
||||||
|
symbol: "SPAMTKN",
|
||||||
|
name: "Spam Token",
|
||||||
|
decimals: "6",
|
||||||
|
holders_count: "50000",
|
||||||
|
...token,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
debugFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token with plenty of reported holders is listed", async () => {
|
||||||
|
respondWith([item()]);
|
||||||
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||||
|
expect(balances).toHaveLength(1);
|
||||||
|
expect(balances[0].holders).toBe(50000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token reporting zero holders is still excluded", async () => {
|
||||||
|
respondWith([item({ token: { holders_count: "0" } })]);
|
||||||
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown holder count does not admit an unvouched token", async () => {
|
||||||
|
respondWith([item({ token: { holders_count: null } })]);
|
||||||
|
expect(await fetchTokenBalances(HOLDER, BLOCKSCOUT, [])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The path that reaches the send selector and the history filter: a token
|
||||||
|
// the user vouched for by tracking it is listed whatever the explorer
|
||||||
|
// says, and it must carry the unknown count through as null, not as the
|
||||||
|
// zero that would then hide it downstream.
|
||||||
|
test("a tracked token with an unknown count is listed with holders null", async () => {
|
||||||
|
respondWith([item({ token: { holders_count: undefined } })]);
|
||||||
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, [
|
||||||
|
{ address: NOVEL_TOKEN.toUpperCase() },
|
||||||
|
]);
|
||||||
|
expect(balances).toHaveLength(1);
|
||||||
|
expect(balances[0].holders).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a known-list token with an unknown count is listed with holders null", async () => {
|
||||||
|
respondWith([
|
||||||
|
item({
|
||||||
|
token: {
|
||||||
|
address_hash: USDC_CONTRACT,
|
||||||
|
symbol: "USDC",
|
||||||
|
holders_count: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const balances = await fetchTokenBalances(HOLDER, BLOCKSCOUT, []);
|
||||||
|
expect(balances).toHaveLength(1);
|
||||||
|
expect(balances[0].holders).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no test in this file performed a network request", () => {
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
105
tests/manifest.test.js
Normal file
105
tests/manifest.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
tests/sendTokenSelect.test.js
Normal file
123
tests/sendTokenSelect.test.js
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// Tests for the token filtering in the Send view's token selector
|
||||||
|
// (src/popup/views/send.js).
|
||||||
|
//
|
||||||
|
// The selector decides which of the user's tokens can be spent at all, so
|
||||||
|
// over-filtering here is worse than in the history list: the asset is not
|
||||||
|
// merely hidden, it becomes unspendable through the UI. Issue #230: an
|
||||||
|
// explorer that omits holders_count was read as "zero holders" and the token
|
||||||
|
// disappeared from this list.
|
||||||
|
//
|
||||||
|
// renderSendTokenSelect only ever touches getElementById, createElement,
|
||||||
|
// innerHTML, value, textContent and appendChild, so a small stub document is
|
||||||
|
// enough to drive it; the real DOM behaviour of the view is covered by
|
||||||
|
// tests/e2e/run.js.
|
||||||
|
|
||||||
|
globalThis.chrome = {
|
||||||
|
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { state } = require("../src/shared/state");
|
||||||
|
const { renderSendTokenSelect } = require("../src/popup/views/send");
|
||||||
|
|
||||||
|
const USDC_CONTRACT = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||||
|
const NOVEL_TOKEN = "0x1111111111111111111111111111111111111111";
|
||||||
|
|
||||||
|
let select;
|
||||||
|
|
||||||
|
function installStubDocument() {
|
||||||
|
select = { innerHTML: "", children: [] };
|
||||||
|
select.appendChild = (child) => select.children.push(child);
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: (id) => (id === "send-token" ? select : null),
|
||||||
|
createElement: () => ({ value: "", textContent: "" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The symbols offered for sending, excluding the hardcoded ETH option that
|
||||||
|
// renderSendTokenSelect writes straight into innerHTML.
|
||||||
|
function offeredTokens() {
|
||||||
|
return select.children.map((opt) => opt.value.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenBalance(overrides) {
|
||||||
|
return {
|
||||||
|
address: NOVEL_TOKEN,
|
||||||
|
symbol: "SPAMTKN",
|
||||||
|
decimals: 18,
|
||||||
|
balance: "12.5",
|
||||||
|
holders: 50000,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(tokenBalances) {
|
||||||
|
installStubDocument();
|
||||||
|
renderSendTokenSelect({ address: "0x" + "a".repeat(40), tokenBalances });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.fraudContracts = [];
|
||||||
|
state.hideLowHolderTokens = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the low-holder rule in the send token selector", () => {
|
||||||
|
test("ETH is always offered", () => {
|
||||||
|
render([]);
|
||||||
|
expect(select.innerHTML).toBe('<option value="ETH">ETH</option>');
|
||||||
|
expect(offeredTokens()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token with plenty of holders is offered", () => {
|
||||||
|
render([tokenBalance()]);
|
||||||
|
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token reporting zero holders is withheld", () => {
|
||||||
|
render([tokenBalance({ holders: 0 })]);
|
||||||
|
expect(offeredTokens()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("boundary: 999 holders is withheld, 1000 is offered", () => {
|
||||||
|
render([tokenBalance({ holders: 999 })]);
|
||||||
|
expect(offeredTokens()).toEqual([]);
|
||||||
|
render([tokenBalance({ holders: 1000 })]);
|
||||||
|
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Issue #230: an unknown holder count must not read as zero. A token the
|
||||||
|
// user demonstrably holds — it has a balance — cannot be made unspendable
|
||||||
|
// by a field the block explorer failed to report.
|
||||||
|
test("a token whose holder count is unknown is still offered", () => {
|
||||||
|
render([tokenBalance({ holders: null })]);
|
||||||
|
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a token balance carrying no holders field at all is offered", () => {
|
||||||
|
const t = tokenBalance();
|
||||||
|
delete t.holders;
|
||||||
|
render([t]);
|
||||||
|
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the rule is bypassed entirely when the setting is off", () => {
|
||||||
|
state.hideLowHolderTokens = false;
|
||||||
|
render([tokenBalance({ holders: 0 })]);
|
||||||
|
expect(offeredTokens()).toEqual([NOVEL_TOKEN]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the other send-selector rules are unaffected", () => {
|
||||||
|
test("a token spoofing a known symbol from a wrong address is withheld", () => {
|
||||||
|
render([
|
||||||
|
tokenBalance({ symbol: "USDC", holders: null }),
|
||||||
|
tokenBalance({ address: USDC_CONTRACT, symbol: "USDC" }),
|
||||||
|
]);
|
||||||
|
expect(offeredTokens()).toEqual([USDC_CONTRACT.toLowerCase()]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a blocklisted fraud contract is withheld even with an unknown count", () => {
|
||||||
|
state.fraudContracts = [NOVEL_TOKEN.toUpperCase()];
|
||||||
|
render([tokenBalance({ holders: null })]);
|
||||||
|
expect(offeredTokens()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1473,6 +1473,65 @@ describe("fetchRecentTransactions merge and dedup", () => {
|
|||||||
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
expect(result.newFraudContracts).toEqual([FAKE_ETH_CONTRACT]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Regression guards (#230): the explorer's holders_count is optional. A
|
||||||
|
// missing field means the count is unknown; it does not mean the token
|
||||||
|
// has no holders. Recording the two as the same number both hides a
|
||||||
|
// legitimate token and makes the `holders !== null` guard in
|
||||||
|
// filterTransactions unreachable for token transfers.
|
||||||
|
describe("an unreported holders_count is unknown, not zero", () => {
|
||||||
|
function spamTransferWithToken(token) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
transaction_hash: "0x" + "9".repeat(64),
|
||||||
|
block_number: 21000070,
|
||||||
|
timestamp: TS,
|
||||||
|
from: { hash: ORDINARY_PEER },
|
||||||
|
to: { hash: VICTIM },
|
||||||
|
total: { value: "1500500000", decimals: "6" },
|
||||||
|
token: token,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const OMITTED = {
|
||||||
|
symbol: NOVEL_SPAM_SYMBOL,
|
||||||
|
address_hash: NOVEL_SPAM_CONTRACT,
|
||||||
|
};
|
||||||
|
const NULLED = { ...OMITTED, holders_count: null };
|
||||||
|
const ZERO = { ...OMITTED, holders_count: "0" };
|
||||||
|
|
||||||
|
test("an omitted holders_count parses to null", async () => {
|
||||||
|
respondWith([], spamTransferWithToken(OMITTED));
|
||||||
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
|
expect(txs[0].holders).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a null holders_count parses to null", async () => {
|
||||||
|
respondWith([], spamTransferWithToken(NULLED));
|
||||||
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
|
expect(txs[0].holders).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the transfer survives the low-holder filter", async () => {
|
||||||
|
respondWith([], spamTransferWithToken(OMITTED));
|
||||||
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
|
expect(filterTransactions(txs, filters()).transactions).toEqual(
|
||||||
|
txs,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The regression this fix could cause: a token that genuinely
|
||||||
|
// reports zero holders must keep being filtered. Unlike the fake
|
||||||
|
// "ETH" fixture above, this symbol is not in the token list, so the
|
||||||
|
// holder count is the only rule that can catch it.
|
||||||
|
test('a reported holders_count of "0" still parses to 0 and is filtered', async () => {
|
||||||
|
respondWith([], spamTransferWithToken(ZERO));
|
||||||
|
const txs = await fetchRecentTransactions(VICTIM, BLOCKSCOUT);
|
||||||
|
expect(txs[0].holders).toBe(0);
|
||||||
|
expect(filterTransactions(txs, filters()).transactions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("failed responses yield an empty list rather than throwing", async () => {
|
test("failed responses yield an empty list rather than throwing", async () => {
|
||||||
debugFetch.mockImplementation(async () => ({
|
debugFetch.mockImplementation(async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
@@ -339,6 +339,21 @@ describe("WaitTx persistence across popup close", () => {
|
|||||||
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
{ hash: TX_HASH, txInfo: {}, broadcastTime: Date.now() },
|
||||||
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
{ hash: TX_HASH, txInfo: [], broadcastTime: Date.now() },
|
||||||
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
{ hash: TX_HASH, txInfo: { to: 42 }, broadcastTime: Date.now() },
|
||||||
|
// Otherwise complete but for a non-string `to`: only the `to`
|
||||||
|
// check rejects this one, and without it addressTitle() throws
|
||||||
|
// out of restoreView().
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: { to: 42, amount: "0.0050" },
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
|
// Otherwise complete but an array: only Array.isArray() rejects
|
||||||
|
// it, since typeof [] is "object" and the fields are present.
|
||||||
|
{
|
||||||
|
hash: TX_HASH,
|
||||||
|
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
|
||||||
|
broadcastTime: Date.now(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
hash: TX_HASH,
|
hash: TX_HASH,
|
||||||
txInfo: { to: RECIPIENT },
|
txInfo: { to: RECIPIENT },
|
||||||
|
|||||||
52
tests/vaultBackend.test.js
Normal file
52
tests/vaultBackend.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user