Compare commits

...

4 Commits

Author SHA1 Message Date
4e2ca87a06 harden: verify all approval fields and make failed signing retryable (closes #174)
All checks were successful
check / check (push) Successful in 27s
verifySignedTx compared only from, to, value and data, so a signed
transaction could differ from the approval in chain id, nonce, gas limit
or any fee field and still be broadcast. Worse, it named the fields it
checked and so admitted every field it did not name: a type 4 artifact
carrying an EIP-7702 authorization passed verification, paying the
approved amount to the approved recipient and, in the same transaction,
permanently installing another contract's code at the signer's own
account.

The check is now an allowlist in both directions. The transaction type
must be 0, 1 or 2 — the only types this wallet signs — so no later
EIP-2718 type can bring a field along; authorizationList, blobs, blob
commitments and blob gas fees are refused by name; and the access list
is compared with the approval. Every consequential field is compared and
any mismatch refuses outright: the chain id against the selected network
(and against the approval when the page fixed one), plus nonce, gas
limit, gasPrice, maxFeePerGas and maxPriorityFeePerGas wherever the
approval carries a value, together with the fee mechanism the approval
implies. Fields the approval does not carry are populated locally by the
popup and have no approved value to compare against, so they are held to
absolute ceilings instead. Verification then closes by rebuilding the
transaction from exactly those checked fields and comparing the unsigned
bytes, so an artifact carrying anything this module does not account for
is refused without having to be named first.

An approved value that is not a number now refuses like every other
quantity rather than escaping as a raw BigInt conversion error, which
was reported as retryable and left a live button that could never
succeed.

A failed signing attempt also left a button that could not succeed: the
background deleted the approval before it broadcast, so a retry found
nothing to sign. The approval is now retired once the request has an
outcome, and the background tells the popup which stage failed. A popup
that could not sign is retryable; a mismatch spends the approval; a
failed broadcast is terminal, because the node may have accepted the
transaction and still failed to answer and the popup's retry re-signs at
a freshly fetched nonce rather than re-broadcasting the same bytes,
which would send the approved transfer twice.

Keeping the approval alive for that retry cost it its single use: the
handler read it, then verified and broadcast asynchronously, so a second
AUTISTMASK_TX_RESPONSE carrying the same id started an independent
verify and broadcast instead of finding nothing. With the ordinary dApp
approval shape the page fixes no nonce, so two artifacts signed at
different nonces both verify and the approved transfer goes out twice; a
reloaded approval window during a slow broadcast is enough to send it,
since the only guard was popup-local button state. The approval is now
claimed synchronously, before the first await, and released only when an
attempt fails in a way the user may retry. Same interlock on
AUTISTMASK_SIGN_RESPONSE.

Surviving the whole verify-and-broadcast window put the approval within
reach of every other path that retires one, and those paths did not
consult the claim. Closing the approval popup, switching the active
address, or a reject arriving late each resolved the waiting promise
4001 while the attempt behind it ran to completion; the attempt's own
resolve then landed on a settled promise, so the transaction reached the
chain and the page was told the user rejected it. The user's natural
response is to redo the transfer from the site, which re-signs at a
fresh nonce and sends it twice — the outcome this change exists to
prevent, reached without an adversary, since the popup stays open across
the broadcast and a user closing an apparently-hung window is enough.

Every settlement now goes through one function. settleApproval() is the
only place an approval is resolved or removed, and it refuses a claimed
approval unless the caller holds the claim, so a path added later
inherits the interlock instead of having to remember it. The active-
address switch also leaves a claimed approval's window standing rather
than force-closing the window the attempt is reporting into. The
duplicate refusal on the sign path now carries a stage of its own, so
the popup stops telling the user to start again from the site while a
first attempt may still succeed.

Verification also compared only the decode against itself: both sides of
the closing byte comparison derive from one Transaction.from(), while
what is broadcast is the artifact string. An artifact re-encoded with a
leading zero byte on an RLP quantity therefore decoded to the approved
transaction, passed, and broadcast different bytes. The artifact is now
required to be the canonical encoding of its own decode, which is what
makes the claim that it *is* the approved transaction true.

The background's approval wiring had no tests, which is where these
defects lived. It has them now, driven through the real message listener
from eth_sendTransaction to broadcast, with windows.onRemoved captured
rather than stubbed away: each retirement path is asserted to leave a
mid-broadcast attempt alone and to still reject an approval no attempt
holds.
2026-08-12 08:45:01 +00:00
bd4bdcafc7 fix: explain a stored non-master xprv wallet instead of throwing at signing time (closes #234)
All checks were successful
check / check (push) Successful in 30s
2026-08-12 10:41:49 +02:00
ce4a0d7b8d fix: distinguish an unknown holder count from zero so a legitimate token is not filtered (closes #230)
All checks were successful
check / check (push) Successful in 39s
2026-08-12 10:34:45 +02:00
bf1dbec87c fix: run libsodium on WebAssembly under the extension CSP (closes #182)
All checks were successful
check / check (push) Successful in 26s
2026-08-12 10:30:15 +02:00
27 changed files with 3471 additions and 170 deletions

View File

@@ -123,16 +123,17 @@ unavailable). The suite lives in `tests/e2e/` and is driven by
`playwright-core`, whose version must stay matched to the container's Playwright
version — the browsers ship inside the image.
It covers popup load, wallet creation through the UI, the Add Token screen, the
transaction detail screen for an ERC-20 transfer, and the recovery phrase screen
— which wallet types are offered it, that it holds nothing before the password
is accepted, that a wrong password reveals nothing, that leaving it by either
route wipes it — including a leave taken while the decrypt is still running —
and that reopening the popup does not land on it. 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, the transaction detail screen for an ERC-20
transfer, and the recovery phrase screen — which wallet types are offered it,
that it holds nothing before the password is accepted, that a wrong password
reveals nothing, that leaving it by either route wipes it — including a leave
taken while the decrypt is still running — and that reopening the popup does not
land on it. 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
@@ -435,7 +436,11 @@ The core hierarchy is **Wallets → Addresses**:
multi-address behavior as an HD wallet, including the "+" button and the
address scan on import, but imported from an extended private key rather
than a recovery phrase. It therefore has no recovery phrase to display or
back up.
back up. Only a master key may be imported; an xprv wallet already in
storage that was imported from a non-master key is detected from the depth
of its stored `xpub` by `src/shared/walletDefects.js`, explained in the
wallet list, and blocked from signing, sending and private-key export. It
is never deleted or rewritten.
- An **address** holds ETH and ERC-20 tokens.
- The user can have multiple wallets, each with multiple addresses (HD) or a
single address (key).
@@ -1068,6 +1073,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

23
TODO.md
View File

@@ -44,6 +44,24 @@ undefined identifiers, which is how
# Completed Steps
- 2026-08-12: Approval verification became an allowlist — transaction type
restricted to 0/1/2 so an EIP-7702 delegation can no longer ride along on an
approved transfer, every consequential field compared, the artifact
re-serialized from the checked fields alone and its exact bytes required to be
the canonical encoding of what was broadcast. One approval now yields at most
one broadcast, and every path that retires a pending approval — popup close,
active-address change, a late reject — goes through a single chokepoint that
refuses to settle an attempt already claimed for signing and broadcast
([#174](https://git.eeqj.de/sneak/AutistMask/issues/174)).
- 2026-08-12: An xprv wallet already in storage that was imported from a
non-master key is detected from the depth of its stored `xpub`, explained in
the wallet list, and blocked from signing, sending and private-key export
instead of throwing on the send screen
([#234](https://git.eeqj.de/sneak/AutistMask/issues/234)).
- 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
@@ -51,6 +69,11 @@ undefined identifiers, which is how
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
(`hideSpoofedSymbols`), on by default, governing the transaction-history
filter and the fraud-contract learning it feeds

View File

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

View File

@@ -13,7 +13,16 @@ const {
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
const {
verifySignedTx,
verifySignature,
failureIsRetryable,
describeTxFailure,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
} = require("../shared/approvalVerify");
const {
isPhishingDomain,
refreshPhishingListOnSchedule,
@@ -107,6 +116,55 @@ function resetPopupUrl() {
}
}
// Settle a pending approval: hand `result` to the promise the requesting page
// is waiting on and retire the approval. This is the ONLY place an approval is
// resolved or removed — the popup closing, an active-address switch, a reject
// from the popup and the attempt that signs and broadcasts all come through
// here — because a settlement that bypasses the claim below is a fund-loss bug
// and enumerating the call sites has repeatedly missed one.
//
// A claimed approval belongs to the attempt holding the claim, and only that
// attempt may settle it. Anything else settling first would leave the attempt
// running to completion against an already-settled promise: the transaction
// reaches the chain while the page is told "User rejected the request", and the
// user's natural response is to send it again at a fresh nonce.
//
// Returns false when the approval is gone or claimed by someone else, so the
// caller can refuse instead of assuming it settled.
function settleApproval(id, result, options) {
const approval = pendingApprovals[id];
if (!approval) return false;
const holdsClaim = !!(options && options.holdsClaim);
if (approval.attemptInFlight && !holdsClaim) return false;
delete pendingApprovals[id];
approval.resolve(result);
resetPopupUrl();
return true;
}
// Take exclusive hold of a pending approval for one attempt, or refuse.
//
// An approval that failed retryably has to stay in pendingApprovals, so its
// presence cannot be the interlock against a second attempt; this flag is. It
// is set synchronously, before the handler's first await, so a second response
// carrying the same id — a reloaded approval window re-rendering a live
// Approve button, a popup that emits the message twice — finds the attempt
// already running instead of starting an independent verify and broadcast.
// Without it one approval can put two transactions on the chain: with the
// ordinary dApp approval shape the page fixes no nonce, so two artifacts
// signed at different nonces both verify.
function claimApproval(approval) {
if (approval.attemptInFlight) return false;
approval.attemptInFlight = true;
return true;
}
// Release an approval whose attempt failed in a way the user can retry.
// Nothing was broadcast, so the next attempt may claim it.
function releaseApproval(approval) {
approval.attemptInFlight = false;
}
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
@@ -215,8 +273,7 @@ runtime.onConnect.addListener((port) => {
// Keep pending — user can reopen the toolbar popup
return;
}
approval.resolve({ approved: false, remember: false });
delete pendingApprovals[id];
settleApproval(id, { approved: false, remember: false });
}
resetPopupUrl();
});
@@ -547,15 +604,21 @@ async function broadcastAccountsChanged() {
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// Reject and close any pending approval popups so they don't hang
// Reject and close any pending approval popups so they don't hang. An
// approval an attempt has already claimed is left alone entirely: it is
// being signed and broadcast right now, and neither rejecting it to the
// page nor closing the window it is reporting into is survivable.
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.type === "tx" || approval.type === "sign") {
approval.resolve({
error: { code: 4001, message: "User rejected the request." },
});
} else {
approval.resolve({ approved: false, remember: false });
}
const rejection =
approval.type === "tx" || approval.type === "sign"
? {
error: {
code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
if (!settleApproval(id, rejection)) continue;
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
if (runtime.lastError) {
@@ -563,7 +626,6 @@ async function broadcastAccountsChanged() {
}
});
}
delete pendingApprovals[id];
}
resetPopupUrl();
const s = await getState();
@@ -679,23 +741,26 @@ if (runtime.onStartup) {
}
startBackgroundJobs();
// When approval window is closed without a response, treat as rejection
// When approval window is closed without a response, treat as rejection.
// "Without a response" is the operative part: the popup stays open across the
// verify and broadcast it is waiting on, so a user closing an apparently-hung
// window is an ordinary event with an attempt already in flight behind it.
// settleApproval() refuses those, which leaves the attempt to report its real
// outcome to the page.
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId === windowId) {
if (approval.type === "tx" || approval.type === "sign") {
approval.resolve({
error: {
code: 4001,
message: "User rejected the request.",
},
});
} else {
approval.resolve({ approved: false, remember: false });
}
delete pendingApprovals[id];
}
if (approval.windowId !== windowId) continue;
const rejection =
approval.type === "tx" || approval.type === "sign"
? {
error: {
code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
settleApproval(id, rejection);
}
});
}
@@ -761,14 +826,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (approval) {
approval.resolve({
approved: msg.approved,
remember: msg.remember,
});
delete pendingApprovals[msg.id];
}
settleApproval(msg.id, {
approved: msg.approved,
remember: msg.remember,
});
resetPopupUrl();
return false;
}
@@ -776,21 +837,50 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
// A reject arriving while an attempt holds the approval is refused,
// not honoured: the attempt is on its way to broadcasting the
// transaction, and resolving 4001 here would tell the page the request
// was rejected while it goes out.
if (!msg.approved) {
approval.resolve({
error: { code: 4001, message: "User rejected the request." },
});
if (
!settleApproval(msg.id, {
error: {
code: 4001,
message: "User rejected the request.",
},
})
) {
sendResponse({
error: "This transaction is already being sent.",
retryable: false,
stage: TX_STAGE_BROADCAST,
});
return false;
}
return true;
}
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
// The popup signs; it reports back here when it could not. Keep the
// approval so the user can correct the problem and try again with the
// transaction they already saw.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
const outcome = describeTxFailure(TX_STAGE_SIGN, msg.error);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_SIGN,
});
return false;
}
// Exactly one broadcast per approval, whatever the popup sends.
if (!claimApproval(approval)) {
sendResponse({
error: "This transaction is already being sent.",
retryable: false,
stage: TX_STAGE_BROADCAST,
});
return false;
}
@@ -800,22 +890,63 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
const activeAddress = await getActiveAddress();
// The popup holds the secret, but the background stays the
// authority on what is broadcast: the raw transaction must be
// the approved one, signed by the approved address.
// the approved one, signed by the approved address, on the
// network that is selected.
verifySignedTx(
msg.rawSignedTx,
approval.txParams,
activeAddress,
currentNetwork().chainId,
);
} catch (e) {
// A signed transaction that is not the approved one is not
// retried against that approval; it is refused outright.
// Anything else that failed before the check ran is the
// user's to retry.
const outcome = describeTxFailure(TX_STAGE_VERIFY, e);
if (outcome.spendApproval) {
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
} else {
releaseApproval(approval);
}
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_VERIFY,
});
return;
}
try {
const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
approval.resolve({ txHash: tx.hash });
settleApproval(
msg.id,
{ txHash: tx.hash },
{ holdsClaim: true },
);
sendResponse({ txHash: tx.hash });
} catch (e) {
const errMsg = e.shortMessage || e.message;
approval.resolve({
error: { message: errMsg },
// Terminal, never retried: the node may have accepted the
// transaction and still failed to answer, and the popup's
// retry re-signs at a freshly fetched nonce rather than
// re-broadcasting these bytes. Retrying would send the
// approved transfer a second time.
const outcome = describeTxFailure(TX_STAGE_BROADCAST, e);
settleApproval(
msg.id,
{ error: { message: outcome.error } },
{ holdsClaim: true },
);
sendResponse({
error: outcome.error,
retryable: outcome.retryable,
stage: TX_STAGE_BROADCAST,
});
sendResponse({ error: errMsg });
}
})();
return true;
@@ -824,21 +955,43 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
delete pendingApprovals[msg.id];
resetPopupUrl();
// Same as the transaction path: a reject cannot retire an approval an
// attempt already holds.
if (!msg.approved) {
approval.resolve({
error: { code: 4001, message: "User rejected the request." },
});
if (
!settleApproval(msg.id, {
error: {
code: 4001,
message: "User rejected the request.",
},
})
) {
sendResponse({
error: "This request is already being signed.",
retryable: false,
stage: TX_STAGE_INFLIGHT,
});
return false;
}
return true;
}
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
// The popup signs; it reports back here when it could not. Keep the
// approval so the user can correct the problem and try again with the
// message they already saw.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
sendResponse({ error: msg.error, retryable: true });
return false;
}
// Exactly one signature handed back per approval.
if (!claimApproval(approval)) {
sendResponse({
error: "This request is already being signed.",
retryable: false,
stage: TX_STAGE_INFLIGHT,
});
return false;
}
@@ -851,14 +1004,21 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
// address.
const signature = msg.signature;
verifySignature(approval.signParams, signature, activeAddress);
approval.resolve({ signature });
settleApproval(msg.id, { signature }, { holdsClaim: true });
sendResponse({ signature });
} catch (e) {
const errMsg = e.shortMessage || e.message;
approval.resolve({
error: { message: errMsg },
});
sendResponse({ error: errMsg });
const retryable = failureIsRetryable(e);
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ holdsClaim: true },
);
} else {
releaseApproval(approval);
}
sendResponse({ error: errMsg, retryable });
}
})();
return true;

View File

@@ -29,6 +29,16 @@ const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
// The defect of the wallet the selected address belongs to, or null. Both the
// send and the private-key export path check it before asking for a password,
// so a wallet that cannot derive its keys says so instead of failing after the
// user has typed one in.
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
let ctx;
@@ -254,6 +264,11 @@ function init(_ctx) {
});
$("btn-send").addEventListener("click", () => {
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress
@@ -298,6 +313,14 @@ function init(_ctx) {
$("btn-export-privkey").addEventListener("click", () => {
moreDropdown.classList.add("hidden");
moreBtn.classList.remove("bg-fg", "text-bg");
// There is no private key to export for an address this wallet
// cannot derive. Without this the export screen would take a
// password and then report it as wrong.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
pushCurrentView();
const wallet = state.wallets[state.selectedWallet];
const addr = wallet.addresses[state.selectedAddress];

View File

@@ -35,6 +35,7 @@ const {
} = require("./send");
const { log } = require("../../shared/log");
const makeBlockie = require("ethereum-blockies-base64");
const { walletDefect } = require("../../shared/walletDefects");
let ctx;
@@ -338,6 +339,11 @@ function init(_ctx) {
});
$("btn-address-token-send").addEventListener("click", () => {
const defect = walletDefect(state.wallets[state.selectedWallet]);
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr =
state.wallets[state.selectedWallet].addresses[
state.selectedAddress

View File

@@ -21,7 +21,9 @@ const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { getProvider } = require("../../shared/balances");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const runtime =
@@ -280,6 +282,7 @@ function showTxApproval(details) {
showView("approve-tx");
attachCopyHandlers("view-approve-tx");
gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
}
function decodeHexMessage(hex) {
@@ -379,6 +382,7 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
}
function show(id) {
@@ -431,6 +435,20 @@ function setSignButtonBusy(busy) {
$("btn-approve-sign").classList.toggle("text-muted", busy);
}
// Say so on the approval screen itself, and disable the approve button, when
// the active address belongs to a wallet whose keys cannot be derived. Without
// this the screen would take a password and fail after deriving it. Reject
// stays available; the wallet is not touched. Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId) {
const active = findActiveWallet();
const defect = active ? walletDefect(active.wallet) : null;
if (!defect) return false;
showError(errorId, defect.shortMessage);
$(buttonId).disabled = true;
$(buttonId).classList.add("text-muted");
return true;
}
// Locate the wallet and the address index owning the currently active
// address. Returns null when no wallet holds it.
function findActiveWallet() {
@@ -492,6 +510,14 @@ function init(ctx) {
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-tx-error", defect.shortMessage);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
@@ -546,10 +572,20 @@ function init(ctx) {
runtime.sendMessage(payload, (response) => {
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
const msg =
(response && response.error) || "Transaction failed.";
txStatus.showError(pendingTxDetails, null, msg);
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
});
@@ -583,6 +619,14 @@ function init(ctx) {
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-sign-error", defect.shortMessage);
setSignButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
@@ -644,11 +688,18 @@ function init(ctx) {
runtime.sendMessage(payload, (response) => {
if (response && response.signature) {
window.close();
} else {
const msg = (response && response.error) || "Signing failed.";
showError("approve-sign-error", msg);
setSignButtonBusy(false);
return;
}
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
});

View File

@@ -21,6 +21,10 @@ const {
resetSendValidation,
} = require("./send");
const { deriveAddressFromXpub } = require("../../shared/wallet");
const {
walletDefect,
walletDefectHtml,
} = require("../../shared/walletDefects");
const {
formatUsd,
getPrice,
@@ -214,25 +218,23 @@ async function loadHomeTxs(ctx) {
}
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
// The wallet list markup. Pure: it reads state and returns a string, so the
// list can be asserted on without a DOM.
function walletListHtml() {
let html = "";
state.wallets.forEach((wallet, wi) => {
const defect = walletDefect(wallet);
html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
if (wallet.type === "hd" || wallet.type === "xprv") {
// No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce
// under the standard path.
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
}
html += `</div>`;
html += walletDefectHtml(wallet);
wallet.addresses.forEach((addr, ai) => {
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
@@ -260,7 +262,20 @@ function render(ctx) {
html += `</div>`;
});
container.innerHTML = html;
return html;
}
function render(ctx) {
const container = $("wallet-list");
if (state.wallets.length === 0) {
container.innerHTML =
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
renderTotalValue();
renderActiveAddress();
return;
}
container.innerHTML = walletListHtml();
container.querySelectorAll(".address-row").forEach((row) => {
row.addEventListener("click", async () => {
@@ -348,6 +363,13 @@ function render(ctx) {
loadHomeTxs(ctx);
}
// The defect of the wallet the selected address belongs to, or null. Call
// after selectActiveAddress().
function selectedWalletDefect() {
if (state.selectedWallet === null) return null;
return walletDefect(state.wallets[state.selectedWallet]);
}
function selectActiveAddress() {
for (let wi = 0; wi < state.wallets.length; wi++) {
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
@@ -371,6 +393,13 @@ function init(ctx) {
showFlash("No active address selected.");
return;
}
// Before the balance check and before any password is asked for: this
// wallet cannot sign at all, so the send screen is a dead end.
const defect = selectedWalletDefect();
if (defect) {
showFlash(defect.shortMessage);
return;
}
const addr = currentAddress();
if (!addr.balance || parseFloat(addr.balance) === 0) {
showFlash("Cannot send \u2014 zero balance.");
@@ -396,4 +425,4 @@ function init(ctx) {
});
}
module.exports = { init, render };
module.exports = { init, render, walletListHtml };

View File

@@ -13,6 +13,7 @@ const { state, currentAddress } = require("../../shared/state");
let ctx;
const { getProvider } = require("../../shared/balances");
const { KNOWN_SYMBOLS, resolveSymbol } = require("../../shared/tokenList");
const { isLowHolderCount } = require("../../shared/holders");
const { getAddress } = require("ethers");
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
@@ -132,7 +133,10 @@ function renderSendTokenSelect(addr) {
for (const t of addr.tokenBalances || []) {
if (isSpoofedToken(t)) 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");
opt.value = t.address;
opt.textContent = t.symbol;

View File

@@ -7,17 +7,144 @@
// the signer from the artifact and checks it against the approval it is
// holding before acting on it. All recovery is delegated to ethers.
//
// The check is an allowlist, in both directions, because a denylist cannot be
// correct against a transaction format that keeps gaining fields:
//
// - only transaction types 0, 1 and 2 are accepted. Every later EIP-2718 type
// adds a field with consequences of its own — EIP-7702's authorizationList
// rewrites the code at the signer's own account, EIP-4844's blob
// commitments carry a separate fee — and a check that enumerates the fields
// it refuses admits every one of them by default.
// - after the per-field comparisons, the artifact is rebuilt from those
// checked fields and nothing else, and the two are compared byte for byte.
// Anything the artifact carries that this module does not name is absent
// from the rebuild and changes the bytes, so the final assertion is that
// the artifact *is* the approved transaction, not merely that it is not one
// of the tampered shapes that were thought of.
// - every comparison runs against the decode, but the string handed to
// broadcastTransaction() is the artifact. So the artifact is also required
// to be the canonical re-encoding of its own decode, which is what makes
// the checked transaction and the broadcast bytes the same object rather
// than two things that merely decode alike.
//
// Every consequential field is compared, and a mismatch is a refusal to act,
// never a warning: what the user approved is what gets broadcast, or nothing
// does.
//
// Fields the approval does not carry are not treated as zero. The popup
// populates nonce, gas limit, fee and chain id through populateTransaction()
// when the requesting page did not fix them, so there is no approved value to
// compare against; treating absent as zero would refuse every legitimate
// transaction. Those fields are instead held to the absolute ceilings below,
// and the chain id is always checked against the selected network rather than
// against the approval alone, which is what makes a cross-chain replay
// impossible.
//
// Every failure message is a full sentence, because these strings are shown to
// the user and returned to the dApp.
const {
Transaction,
accessListify,
getAddress,
getBytes,
verifyMessage,
verifyTypedData,
} = require("ethers");
// The only transaction types this wallet signs: legacy, EIP-2930 and
// EIP-1559. populateTransaction() produces nothing else, so nothing else can
// be an artifact of an approval this wallet raised.
const ALLOWED_TX_TYPES = [0, 1, 2];
// The serialized fields of each allowed type, which is also the complete set
// of fields the checks below compare or bound. The artifact is rebuilt from
// exactly these at the end of verification and compared byte for byte, so a
// field outside this table cannot ride along unexamined.
const SERIALIZED_FIELDS = {
0: ["chainId", "nonce", "gasPrice", "gasLimit", "to", "value", "data"],
1: [
"chainId",
"nonce",
"gasPrice",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
2: [
"chainId",
"nonce",
"maxPriorityFeePerGas",
"maxFeePerGas",
"gasLimit",
"to",
"value",
"data",
"accessList",
],
};
// Fields no allowed type may carry. The type allowlist already excludes every
// type that defines them, and the structural check at the end of verification
// would catch them anyway; they are named here so that an artifact carrying
// one is refused with a message that says what it was.
const FORBIDDEN_FIELDS = [
{
key: "authorizationList",
message:
"The signed transaction would hand the signing account over to another contract, which was not approved.",
},
{
key: "blobVersionedHashes",
message:
"The signed transaction carries blob commitments, which were not approved.",
},
{
key: "blobs",
message:
"The signed transaction carries blobs, which were not approved.",
},
{
key: "maxFeePerBlobGas",
message:
"The signed transaction carries a blob gas fee, which was not approved.",
},
];
// Above the block gas limit of every supported network (see networks.js), so
// no transaction that could ever be included is refused by it.
const MAX_GAS_LIMIT = 100000000n;
// 100,000 gwei per gas: orders of magnitude above the highest fee either
// supported network has produced, and low enough to catch a fee that would
// hand the validator the balance.
const MAX_FEE_PER_GAS = 100000000000000n;
// A refusal to act on an artifact: it is not the thing that was approved, so
// the approval it was offered against is spent and must not be retried. Every
// throw in this module is one of these; the background distinguishes them from
// transient failures (a busy node, a failed broadcast), which leave the
// approval standing so the user can try again.
class ApprovalMismatchError extends Error {
constructor(message) {
super(message);
this.name = "ApprovalMismatchError";
this.approvalMismatch = true;
}
}
function refuse(message) {
return new ApprovalMismatchError(message);
}
// Whether a signing failure leaves the approval usable. Anything that is not a
// mismatch is the user's to correct and retry.
function failureIsRetryable(err) {
return !(err && err.approvalMismatch === true);
}
// Case-insensitive address comparison that tolerates absent values on either
// side. Two absent addresses compare equal (contract creation has no `to`).
function sameAddress(a, b) {
@@ -31,11 +158,64 @@ function sameAddress(a, b) {
}
}
// Whether the approval fixed a value for a field at all.
function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether a field carries anything at all. An empty array is nothing: ethers
// reports an absent access list on a type 2 transaction as `[]`.
function carriesValue(v) {
if (!present(v)) return false;
if (Array.isArray(v)) return v.length > 0;
return true;
}
// Normalize a quantity that must be present, refusing anything that is not a
// number: an approval carrying junk in a fee field cannot be compared, and an
// uncomparable field is a refusal rather than a pass.
function normalizeQuantity(v, label) {
try {
return BigInt(v);
} catch {
throw refuse(
"The approved " +
label +
" is not a number, so it cannot be" +
" compared with the signed transaction.",
);
}
}
// Normalize a transaction value (hex string, decimal string, number or
// bigint) to a bigint. An absent value is zero, matching ethers.
// bigint) to a bigint. An absent value is zero, matching ethers. The value is
// page-controlled, so it goes through the same refusal as every other
// quantity rather than throwing a raw BigInt conversion error.
function normalizeValue(v) {
if (v === null || v === undefined || v === "") return 0n;
return BigInt(v);
if (!present(v)) return 0n;
return normalizeQuantity(v, "value");
}
// Normalize an access list to a comparable string. An absent or empty list is
// the empty string, so absent and `[]` are the same thing.
function normalizeAccessList(v) {
if (!carriesValue(v)) return "";
let list;
try {
list = accessListify(v);
} catch {
throw refuse(
"The approved access list is not a valid access list, so it cannot be compared with the signed transaction.",
);
}
return list
.map(
(entry) =>
String(entry.address).toLowerCase() +
":" +
entry.storageKeys.map((k) => String(k).toLowerCase()).join(","),
)
.join(";");
}
// Normalize call data to a lowercase hex string. Absent data is "0x".
@@ -44,44 +224,216 @@ function normalizeData(v) {
return String(v).toLowerCase();
}
// Quantity fields the requesting page may fix in the approval. Each is
// compared exactly when the approval carries it, and left to the ceilings
// above when it does not.
const APPROVED_QUANTITIES = [
{
key: "nonce",
label: "nonce",
message: "The signed transaction does not carry the approved nonce.",
},
{
key: "gasLimit",
label: "gas limit",
message:
"The signed transaction does not carry the approved gas limit.",
},
{
key: "gasPrice",
label: "gas price",
message:
"The signed transaction does not carry the approved gas price.",
},
{
key: "maxFeePerGas",
label: "maximum fee per gas",
message:
"The signed transaction does not carry the approved maximum fee per gas.",
},
{
key: "maxPriorityFeePerGas",
label: "maximum priority fee per gas",
message:
"The signed transaction does not carry the approved maximum priority fee per gas.",
},
];
// Refuse a field only a transaction type this wallet does not sign can carry.
// The type allowlist keeps these unreachable in production, which is exactly
// what they are for; it also means nothing else exercises them, so this is
// exported and tested on its own rather than left to be believed.
function assertNoForbiddenFields(parsed) {
for (const field of FORBIDDEN_FIELDS) {
if (carriesValue(parsed[field.key])) throw refuse(field.message);
}
}
// Closing structural check. Rebuild the transaction from the fields the
// comparisons cover, and nothing else, then compare the unsigned bytes. Every
// field carried by the artifact but absent from the rebuild changes the
// serialization, so this refuses anything this module does not account for —
// including a field a future ethers learns to parse onto an allowed type —
// instead of waving it through by not naming it. Also exported for its own
// test: nothing reachable today can make the bytes differ.
function assertNothingUnchecked(parsed) {
let rebuilt;
try {
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
rebuilt = Transaction.from(fields);
} catch {
throw refuse(
"The signed transaction could not be rebuilt from the fields that were checked, so it cannot be shown to be the approved transaction.",
);
}
if (rebuilt.unsignedSerialized !== parsed.unsignedSerialized) {
throw refuse(
"The signed transaction carries data beyond the fields that were checked against the approval.",
);
}
}
// The other half of the closing check, and the one that makes it bind on the
// bytes that actually leave: every comparison above runs against the decode,
// so on its own the rebuild proves only that the transaction ethers understood
// is the approved one. What the background hands to broadcastTransaction() is
// the artifact string itself. Requiring the artifact to be exactly the
// canonical re-encoding of its own decode closes the gap between the two —
// no encoding the decoder normalizes away (a leading zero byte on an RLP
// quantity, say) can differ from what was checked. Hex case is not part of the
// encoding, so only that is normalized before comparing.
function assertCanonicalBytes(parsed, rawSignedTx) {
if (parsed.serialized !== String(rawSignedTx).toLowerCase()) {
throw refuse(
"The signed transaction is not encoded canonically, so the bytes that would be broadcast are not the bytes that were checked.",
);
}
}
// Assert that a raw signed transaction is the transaction the user approved,
// signed by the address the approval was raised for. Returns the parsed
// ethers Transaction on success, throws otherwise.
function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
// signed by the address the approval was raised for, on the network that is
// selected. Returns the parsed ethers Transaction on success, throws
// otherwise.
function verifySignedTx(rawSignedTx, txParams, expectedFrom, selectedChainId) {
if (typeof rawSignedTx !== "string" || !rawSignedTx.startsWith("0x")) {
throw new Error("The signed transaction is missing or malformed.");
throw refuse("The signed transaction is missing or malformed.");
}
let parsed;
try {
parsed = Transaction.from(rawSignedTx);
} catch {
throw new Error("The signed transaction could not be decoded.");
throw refuse("The signed transaction could not be decoded.");
}
if (!parsed.from) {
throw new Error("The signed transaction carries no valid signature.");
throw refuse("The signed transaction carries no valid signature.");
}
if (!sameAddress(parsed.from, expectedFrom)) {
throw new Error(
throw refuse(
"The signed transaction was signed by a different address than the one that was approved.",
);
}
// Before any field is looked at: the type decides which fields exist at
// all, so an unrecognised type is refused outright rather than compared
// field by field against an approval that cannot describe it.
if (!ALLOWED_TX_TYPES.includes(parsed.type)) {
throw refuse(
"The signed transaction is of a type this wallet does not sign, so what it would do beyond the approved transfer cannot be checked.",
);
}
assertNoForbiddenFields(parsed);
// The selected network, not the artifact, is the authority on which chain
// this may be broadcast to; without it nothing can be verified.
if (!present(selectedChainId)) {
throw refuse(
"The selected network is unknown, so the signed transaction cannot be checked against it.",
);
}
if (parsed.chainId !== normalizeQuantity(selectedChainId, "network")) {
throw refuse(
"The signed transaction is for a different network than the one that is selected.",
);
}
if (
present(txParams.chainId) &&
parsed.chainId !== normalizeQuantity(txParams.chainId, "network")
) {
throw refuse(
"The signed transaction is for a different network than the one that was approved.",
);
}
if (!sameAddress(parsed.to, txParams.to)) {
throw new Error(
throw refuse(
"The signed transaction does not go to the approved recipient.",
);
}
if (normalizeValue(parsed.value) !== normalizeValue(txParams.value)) {
throw new Error(
throw refuse(
"The signed transaction does not carry the approved value.",
);
}
if (normalizeData(parsed.data) !== normalizeData(txParams.data)) {
throw new Error(
throw refuse(
"The signed transaction does not carry the approved call data.",
);
}
if (
normalizeAccessList(parsed.accessList) !==
normalizeAccessList(txParams.accessList)
) {
throw refuse(
"The signed transaction does not carry the approved access list.",
);
}
// An approval that fixed EIP-1559 fees must not be signed as a legacy
// transaction, and vice versa: the fee the user agreed to is only
// meaningful under the mechanism it was quoted in.
const approvedEip1559 =
present(txParams.maxFeePerGas) ||
present(txParams.maxPriorityFeePerGas);
const approvedLegacy = present(txParams.gasPrice);
const signedEip1559 = parsed.type === 2;
if (
(approvedEip1559 && !signedEip1559) ||
(approvedLegacy && signedEip1559)
) {
throw refuse(
"The signed transaction does not use the approved fee mechanism.",
);
}
for (const field of APPROVED_QUANTITIES) {
if (!present(txParams[field.key])) continue;
const approved = normalizeQuantity(txParams[field.key], field.label);
if (normalizeQuantity(parsed[field.key], field.label) !== approved) {
throw refuse(field.message);
}
}
if (parsed.gasLimit > MAX_GAS_LIMIT) {
throw refuse(
"The signed transaction sets a gas limit no network this wallet supports can accept.",
);
}
for (const key of ["gasPrice", "maxFeePerGas", "maxPriorityFeePerGas"]) {
const fee = parsed[key];
if (fee !== null && fee !== undefined && fee > MAX_FEE_PER_GAS) {
throw refuse(
"The signed transaction sets a fee per gas far above any plausible value.",
);
}
}
assertNothingUnchecked(parsed);
assertCanonicalBytes(parsed, rawSignedTx);
return parsed;
}
@@ -91,7 +443,7 @@ function verifySignedTx(rawSignedTx, txParams, expectedFrom) {
// address on success, throws otherwise.
function verifySignature(signParams, signature, expectedFrom) {
if (typeof signature !== "string" || !signature.startsWith("0x")) {
throw new Error("The signature is missing or malformed.");
throw refuse("The signature is missing or malformed.");
}
let recovered;
@@ -109,11 +461,11 @@ function verifySignature(signParams, signature, expectedFrom) {
recovered = verifyTypedData(domain, types, message, signature);
}
} catch {
throw new Error("The signature could not be verified.");
throw refuse("The signature could not be verified.");
}
if (!sameAddress(recovered, expectedFrom)) {
throw new Error(
throw refuse(
"The signature was produced by a different address than the one that was approved.",
);
}
@@ -121,4 +473,98 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered;
}
module.exports = { verifySignedTx, verifySignature, sameAddress };
// The stage a transaction approval failed at. Which stage it is decides
// whether the approval survives the failure.
const TX_STAGE_SIGN = "sign";
const TX_STAGE_VERIFY = "verify";
const TX_STAGE_BROADCAST = "broadcast";
// Not a failure of this request at all: a second response arrived for an
// approval an attempt already holds. The first attempt is still running and
// may yet succeed, so the one thing the popup must not say is "start again
// from the site".
const TX_STAGE_INFLIGHT = "inflight";
function errorText(err) {
if (typeof err === "string" && err !== "") return err;
if (err && (err.shortMessage || err.message)) {
return err.shortMessage || err.message;
}
return "The transaction could not be sent.";
}
// What the background does with a pending transaction approval after a failed
// attempt: what it tells the popup, and whether the approval is spent
// (resolved to the requesting page as an error and deleted) or left standing
// so the user can try the transaction they already saw again.
//
// - sign: the popup could not produce an artifact, almost always a wrong
// password. Nothing left the extension, so the approval stands.
// - verify: a mismatch is a refusal and spends the approval — an artifact
// that is not the approved transaction must never be retried against that
// approval. Anything else failed before the check ran and is retryable.
// - broadcast: always terminal. A broadcast that throws after the node
// accepted the transaction is routine (a timeout, a dropped response, a
// node answering "already known"), and the popup's retry does not
// re-broadcast these bytes — it re-runs populateTransaction() and signs
// again at a freshly fetched pending-tag nonce. Retrying would therefore
// put a second transaction on the chain for one approval.
function describeTxFailure(stage, err) {
const error = errorText(err);
const retryable =
stage === TX_STAGE_SIGN ||
(stage === TX_STAGE_VERIFY && failureIsRetryable(err));
return { error, retryable, spendApproval: !retryable };
}
// What the popup shows and does after the background reports a failed signing
// attempt. A retryable failure leaves the approval pending in the background,
// so the button goes back to being usable; a refusal spent the approval, and
// the popup says so rather than offering a button that cannot succeed.
//
// A failed broadcast gets its own wording: the transaction may already be on
// the network, so telling the user to start again from the site is exactly the
// wrong instruction.
function describeSigningFailure(response, fallbackMessage) {
let message = (response && response.error) || fallbackMessage;
if (!/[.!?]$/.test(message)) message += ".";
const retryable = !!(response && response.retryable);
const stage = response && response.stage;
if (!retryable) {
if (stage === TX_STAGE_BROADCAST) {
message +=
" The transaction may still have reached the network." +
" Check the account before sending it again.";
} else if (stage === TX_STAGE_INFLIGHT) {
message +=
" The first attempt is still running and may still succeed." +
" Wait for it rather than starting again.";
} else {
message +=
" This request can no longer be signed. Please start it" +
" again from the site.";
}
}
return { message, retryable };
}
module.exports = {
verifySignedTx,
verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
assertCanonicalBytes,
sameAddress,
failureIsRetryable,
describeTxFailure,
describeSigningFailure,
ApprovalMismatchError,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
};

View File

@@ -12,6 +12,7 @@ const { ERC20_ABI } = require("./constants");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
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
// "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;
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 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
if (!isKnown && !isTracked && !hasEnoughHolders) continue;
@@ -278,6 +289,7 @@ async function scanForAddresses(xpub, rpcUrl, gapLimit = 5) {
}
module.exports = {
fetchTokenBalances,
refreshBalances,
lookupTokenInfo,
getProvider,

32
src/shared/holders.js Normal file
View 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,
};

View File

@@ -9,6 +9,7 @@
const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("./log");
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
// over the address, not part of its identity. Every address comparison in
@@ -116,7 +117,10 @@ function parseTokenTransfer(tt, addrLower) {
contractAddress: normalizeAddress(
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;
}
// 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 (
filters.hideLowHolderTokens &&
tx.contractAddress &&
tx.holders !== null &&
tx.holders < 1000
isLowHolderCount(tx.holders)
) {
continue;
}

View File

@@ -1,14 +1,80 @@
// 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": 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;
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 +125,4 @@ async function decryptWithPassword(encrypted, password) {
return sodium.to_string(plaintext);
}
module.exports = { encryptWithPassword, decryptWithPassword };
module.exports = { cryptoBackend, decryptWithPassword, encryptWithPassword };

View File

@@ -120,9 +120,24 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
const node =
masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
return node.deriveChild(addrIndex);
// Checked here rather than through masterXprvOrThrow so the message
// fits the situation: nobody is importing anything at signing time,
// and this wallet is already in storage. src/shared/walletDefects.js
// catches it at list-render time; this is the backstop behind that.
const node = parseExtendedKey(decryptedSecret);
if (!node || !node.privateKey) {
throw new Error(
"This wallet's stored key is not a valid extended private " +
"key, so it cannot sign.",
);
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"This wallet was imported from an extended private key that " +
"is not a master key, so it cannot sign.",
);
}
return node.derivePath(BIP44_ETH_PATH).deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
}
@@ -142,6 +157,7 @@ function walletHasRecoveryPhrase(walletData) {
module.exports = {
generateMnemonic,
parseExtendedKey,
deriveAddressFromXpub,
hdWalletFromMnemonic,
hdWalletFromXprv,

View File

@@ -0,0 +1,86 @@
// Wallets already in stored state whose key cannot be used, and the copy that
// explains them.
//
// Refusing a non-master extended private key at import time does nothing for a
// wallet imported before that refusal existed. Such a wallet is detected here,
// at wallet-list render time, so the user meets the explanation on the list
// screen rather than an exception on the send screen. Nothing here modifies or
// removes a wallet: the record is the user's data.
const { parseExtendedKey } = require("./wallet");
const NON_MASTER_XPRV = "non-master-xprv";
// An "xprv" wallet stores the neutered BIP-44 Ethereum node, four levels below
// the key that was imported: the current import path derives the absolute
// m/44'/60'/0'/0 from a depth-0 key, and the pre-#210 path derived the same
// four levels as a relative path beneath whatever depth it was given. A master
// import therefore stores a depth-4 xpub and a depth-d import stores depth
// d + 4, which makes the stored xpub an exact read on the imported key's
// depth — and it is readable without the password, unlike the key itself.
const BIP44_ETH_XPUB_DEPTH = 4;
const DEFECTS = {
[NON_MASTER_XPRV]: {
id: NON_MASTER_XPRV,
heading: "This wallet's addresses were derived incorrectly.",
paragraphs: [
"This wallet was imported from an extended private key that is " +
"not a master key. An earlier version applied the Ethereum " +
"derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " +
"produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " +
"do descend from the extended private key you imported, so " +
"anything they hold is still reachable by software that " +
"repeats the same non-standard derivation. Check them in a " +
"block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " +
"changed or removed, and this wallet stays until you delete " +
"it yourself.",
],
// One sentence for the places that have room for one: the flash on a
// blocked Send, the inline error on the approval screens.
shortMessage:
"This wallet cannot sign, because it was imported from an " +
"extended private key that is not a master key. The wallet list " +
"explains what happened.",
},
};
// The defect record for a wallet, or null if there is nothing wrong with it
// that this module can see. Read-only.
//
// A wallet whose xpub will not parse gets null rather than a defect: there is
// no basis in that case to tell the user their key was not a master key, and a
// wrong explanation is worse than none.
function walletDefect(walletData) {
if (!walletData || walletData.type !== "xprv") return null;
const node = parseExtendedKey(walletData.xpub);
if (!node) return null;
if (node.depth === BIP44_ETH_XPUB_DEPTH) return null;
return DEFECTS[NON_MASTER_XPRV];
}
// The notice block for the wallet list, or "" for a wallet with no defect.
// The copy is fixed text from this module, so it needs no escaping.
function walletDefectHtml(walletData) {
const defect = walletDefect(walletData);
if (!defect) return "";
let html =
'<div class="border border-red-500 border-dashed p-2 my-1 text-xs text-red-500">';
html += `<div class="font-bold mb-1">${defect.heading}</div>`;
for (const p of defect.paragraphs) {
html += `<p class="mb-1">${p}</p>`;
}
html += "</div>";
return html;
}
module.exports = {
NON_MASTER_XPRV,
walletDefect,
walletDefectHtml,
};

View File

@@ -1,8 +1,28 @@
const { Network, Transaction, Wallet } = require("ethers");
const {
Network,
Transaction,
Wallet,
decodeRlp,
encodeRlp,
} = require("ethers");
const {
verifySignedTx,
verifySignature,
assertNoForbiddenFields,
assertNothingUnchecked,
assertCanonicalBytes,
sameAddress,
failureIsRetryable,
describeTxFailure,
describeSigningFailure,
ALLOWED_TX_TYPES,
SERIALIZED_FIELDS,
FORBIDDEN_FIELDS,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
MAX_GAS_LIMIT,
MAX_FEE_PER_GAS,
} = require("../src/shared/approvalVerify");
const { getSignerForAddress } = require("../src/shared/wallet");
@@ -18,6 +38,10 @@ const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_RECIPIENT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
// The chain id of the selected network, as networks.js carries it.
const SELECTED = "0x1";
const SEPOLIA = "0xaa36a7";
// Approved parameters as a dApp would supply them over eth_sendTransaction.
const TX_PARAMS = {
from: signer.address,
@@ -27,25 +51,38 @@ const TX_PARAMS = {
gas: "0x5208",
};
// The values populateTransaction() fills in when the dApp fixed none of them.
const POPULATED = {
chainId: 1,
nonce: 7,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
type: 2,
};
// Build a signable transaction from approved params. The popup does the same
// thing through populateTransaction(); here the fields are fixed so the test
// needs no provider.
function txFor(params) {
// needs no provider. `overrides` stands in for what a tampered or misbuilt
// popup would put on the wire.
function txFor(params, overrides) {
return {
chainId: 1,
nonce: 7,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
type: 2,
...POPULATED,
to: params.to,
value: params.value === undefined ? 0n : BigInt(params.value),
data: params.data || "0x",
...(overrides || {}),
};
}
async function signedFor(params, withWallet) {
return (withWallet || signer).signTransaction(txFor(params));
async function signedFor(params, withWallet, overrides) {
return (withWallet || signer).signTransaction(txFor(params, overrides));
}
// Sign the approved transaction with one field changed from what was
// populated, which is the shape of every tamper case below.
async function signedWith(overrides) {
return signedFor(TX_PARAMS, signer, overrides);
}
describe("sameAddress", () => {
@@ -71,7 +108,7 @@ describe("sameAddress", () => {
describe("verifySignedTx", () => {
test("accepts the approved transaction signed by the approved address", async () => {
const raw = await signedFor(TX_PARAMS);
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address);
const parsed = verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
expect(parsed.from).toBe(signer.address);
expect(parsed.hash).toBe(Transaction.from(raw).hash);
});
@@ -79,14 +116,16 @@ describe("verifySignedTx", () => {
test("accepts a contract creation with no recipient", async () => {
const params = { to: undefined, value: "0x0", data: "0x600160005500" };
const raw = await signedFor(params);
expect(() => verifySignedTx(raw, params, signer.address)).not.toThrow();
expect(() =>
verifySignedTx(raw, params, signer.address, SELECTED),
).not.toThrow();
});
test("accepts an absent value as zero", async () => {
const approved = { to: RECIPIENT, data: "0x" };
const raw = await signedFor(approved);
expect(() =>
verifySignedTx(raw, approved, signer.address),
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
@@ -94,7 +133,7 @@ describe("verifySignedTx", () => {
const approved = { to: RECIPIENT, value: "0x0", data: "0xDEADBEEF" };
const raw = await signedFor(approved);
expect(() =>
verifySignedTx(raw, approved, signer.address),
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
@@ -103,9 +142,9 @@ describe("verifySignedTx", () => {
...TX_PARAMS,
to: OTHER_RECIPIENT,
});
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved recipient/,
);
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved recipient/);
});
test("rejects an inflated value", async () => {
@@ -113,48 +152,48 @@ describe("verifySignedTx", () => {
...TX_PARAMS,
value: "0x4563918244f40000",
});
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved value/,
);
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved value/);
});
test("rejects substituted call data", async () => {
const raw = await signedFor({ ...TX_PARAMS, data: "0xc0ffee" });
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/approved call data/,
);
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved call data/);
});
test("rejects a transaction signed by a different address", async () => {
const raw = await signedFor(TX_PARAMS, other);
expect(() => verifySignedTx(raw, TX_PARAMS, signer.address)).toThrow(
/different address/,
);
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/different address/);
});
test("rejects an unsigned transaction", () => {
const unsigned = Transaction.from(txFor(TX_PARAMS)).unsignedSerialized;
expect(() =>
verifySignedTx(unsigned, TX_PARAMS, signer.address),
verifySignedTx(unsigned, TX_PARAMS, signer.address, SELECTED),
).toThrow(/no valid signature/);
});
test("rejects a missing or malformed payload", () => {
expect(() =>
verifySignedTx(undefined, TX_PARAMS, signer.address),
verifySignedTx(undefined, TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/);
expect(() => verifySignedTx("nope", TX_PARAMS, signer.address)).toThrow(
/missing or malformed/,
);
expect(() =>
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address),
verifySignedTx("nope", TX_PARAMS, signer.address, SELECTED),
).toThrow(/missing or malformed/);
expect(() =>
verifySignedTx("0xc0ffee", TX_PARAMS, signer.address, SELECTED),
).toThrow(/could not be decoded/);
});
test("every rejection message is a full sentence", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try {
verifySignedTx(raw, TX_PARAMS, signer.address);
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection");
} catch (e) {
expect(e.message).toMatch(/^[A-Z].*\.$/);
@@ -162,6 +201,606 @@ describe("verifySignedTx", () => {
});
});
// One case per consequential field: the field alone differs from what was
// approved, and that alone must refuse the signature.
describe("verifySignedTx field comparison", () => {
test("rejects a chain id that is not the selected network", async () => {
const raw = await signedWith({ chainId: 11155111 });
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/different network than the one that is selected/);
});
test("rejects a chain id that is not the approved one", async () => {
// Selected network and signed chain id agree; the dApp asked for a
// different chain, so the artifact is not what was approved.
const approved = { ...TX_PARAMS, chainId: SEPOLIA };
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/different network than the one that was approved/);
});
test("refuses when the selected network is unknown", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, undefined),
).toThrow(/selected network is unknown/);
});
test("rejects a substituted nonce", async () => {
const approved = { ...TX_PARAMS, nonce: 7 };
const raw = await signedWith({ nonce: 8 });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved nonce/);
});
test("rejects a substituted gas limit", async () => {
const approved = { ...TX_PARAMS, gasLimit: "0x186a0" };
const raw = await signedWith({ gasLimit: 250000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas limit/);
});
test("rejects a substituted maximum fee per gas", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({ maxFeePerGas: 900000000000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum fee per gas/);
});
test("rejects a substituted maximum priority fee per gas", async () => {
const approved = { ...TX_PARAMS, maxPriorityFeePerGas: "0x3b9aca00" };
const raw = await signedWith({ maxPriorityFeePerGas: 1500000000n });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved maximum priority fee per gas/);
});
test("rejects a substituted legacy gas price", async () => {
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
const legacy = {
type: 0,
gasPrice: 9000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
};
const raw = await signedWith(legacy);
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved gas price/);
});
test("rejects an approved legacy fee signed as an EIP-1559 fee", async () => {
const approved = { ...TX_PARAMS, gasPrice: "0x77359400" };
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved fee mechanism/);
});
test("rejects an approved EIP-1559 fee signed as a legacy fee", async () => {
const approved = { ...TX_PARAMS, maxFeePerGas: "0x77359400" };
const raw = await signedWith({
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).toThrow(/approved fee mechanism/);
});
test("rejects a gas limit above anything a supported network accepts", async () => {
const raw = await signedWith({ gasLimit: MAX_GAS_LIMIT + 1n });
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/gas limit no network this wallet supports/);
});
test("rejects an absurd fee per gas the approval never fixed", async () => {
const raw = await signedWith({
maxFeePerGas: MAX_FEE_PER_GAS + 1n,
maxPriorityFeePerGas: MAX_FEE_PER_GAS + 1n,
});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).toThrow(/fee per gas far above any plausible value/);
});
test("every field mismatch is a refusal, not a warning", async () => {
const raw = await signedWith({ nonce: 8 });
try {
verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: 7 },
signer.address,
SELECTED,
);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
}
});
});
// The transaction type decides which fields exist, so an artifact of a type
// this wallet does not sign carries consequences the approval cannot describe
// and none of the field comparisons can see. The approval used here is the
// ordinary dApp shape with no fee fields — the common case, since
// populateTransaction() fills them — which is exactly the case the
// fee-mechanism check cannot catch by accident.
describe("verifySignedTx transaction type", () => {
const BARE_APPROVAL = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
};
// An EIP-7702 artifact that pays the approved amount to the approved
// recipient and, in the same transaction, installs the attacker's code at
// the signer's own account for good. Every field the approval screen shows
// matches; only the type and the authorization list do not.
test("refuses a type 4 artifact that delegates the signer's own account", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4,
authorizationList: [authorization],
});
const parsed = Transaction.from(raw);
expect(parsed.type).toBe(4);
expect(parsed.authorizationList[0].address).toBe(OTHER_RECIPIENT);
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/type this wallet does not sign/);
});
test("refuses a type 3 blob artifact", async () => {
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 3,
maxFeePerBlobGas: 1000000000n,
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
});
expect(Transaction.from(raw).type).toBe(3);
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/type this wallet does not sign/);
});
test("refuses every type outside the allowlist, not just the known ones", async () => {
for (const type of [3, 4]) {
expect(ALLOWED_TX_TYPES).not.toContain(type);
}
expect(ALLOWED_TX_TYPES).toEqual([0, 1, 2]);
});
test("a type refusal is a refusal, not a warning", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const raw = await signedFor(BARE_APPROVAL, signer, {
type: 4,
authorizationList: [authorization],
});
try {
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED);
throw new Error("expected a rejection");
} catch (e) {
expect(e.approvalMismatch).toBe(true);
expect(e.message).toMatch(/^[A-Z].*\.$/);
}
});
test("accepts a legacy type 0 transaction", async () => {
const approved = { ...BARE_APPROVAL, gasPrice: "0x77359400" };
const raw = await signedFor(approved, signer, {
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts a type 1 transaction whose access list is the approved one", async () => {
const accessList = [{ address: OTHER_RECIPIENT, storageKeys: [] }];
const approved = {
...BARE_APPROVAL,
gasPrice: "0x77359400",
accessList,
};
const raw = await signedFor(approved, signer, {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
accessList,
});
expect(Transaction.from(raw).type).toBe(1);
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("refuses an access list the approval never carried", async () => {
const raw = await signedFor(BARE_APPROVAL, signer, {
accessList: [{ address: OTHER_RECIPIENT, storageKeys: [] }],
});
expect(() =>
verifySignedTx(raw, BARE_APPROVAL, signer.address, SELECTED),
).toThrow(/approved access list/);
});
test("treats an absent access list and an empty one as the same thing", async () => {
const approved = { ...BARE_APPROVAL, accessList: [] };
const raw = await signedFor(BARE_APPROVAL, signer, {});
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
});
// The allowlist is only exhaustive while it accounts for every field an
// artifact can carry. These tests are what makes that claim checkable rather
// than asserted.
describe("verifySignedTx exhaustiveness", () => {
// Every accessor ethers exposes on a parsed transaction, and where this
// module deals with it. If an ethers upgrade adds a transaction field,
// this fails and forces a decision about it instead of letting it default
// to unchecked.
test("every field ethers can parse is accounted for", () => {
const derived = [
// Recovered from the signature or computed from the payload, not
// independent content: covered by the signer check and by the
// fields below.
"from",
"fromPublicKey",
"hash",
"serialized",
"signature",
"type",
"typeName",
"unsignedHash",
"unsignedSerialized",
// Blob sidecar machinery, meaningful only alongside `blobs`,
// which is refused outright.
"kzg",
"blobWrapperVersion",
];
const accounted = new Set([
...derived,
...FORBIDDEN_FIELDS.map((f) => f.key),
...Object.values(SERIALIZED_FIELDS).flat(),
]);
const exposed = Object.getOwnPropertyNames(Transaction.prototype)
.filter((name) => {
const d = Object.getOwnPropertyDescriptor(
Transaction.prototype,
name,
);
return d && typeof d.get === "function";
})
.sort();
expect(exposed.filter((name) => !accounted.has(name))).toEqual([]);
});
// The two layers behind the type allowlist. Nothing reachable through
// verifySignedTx can trip either of them while the allowlist holds — that
// is what they are for — so they are exercised directly rather than taken
// on trust.
test("a forbidden field is refused even on an allowed type", async () => {
const authorization = await signer.authorize({
address: OTHER_RECIPIENT,
chainId: 1,
nonce: 8,
});
const carriers = {
authorizationList: [authorization],
blobVersionedHashes: ["0x01" + "ab".repeat(31)],
blobs: ["0x00"],
maxFeePerBlobGas: 1n,
};
for (const key of Object.keys(carriers)) {
expect(FORBIDDEN_FIELDS.map((f) => f.key)).toContain(key);
let thrown;
try {
assertNoForbiddenFields({ type: 2, [key]: carriers[key] });
throw new Error("expected a rejection");
} catch (e) {
thrown = e;
}
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/^[A-Z].*\.$/);
}
expect(() => assertNoForbiddenFields({ type: 2 })).not.toThrow();
});
// Stands in for a future ethers that parses a field this module does not
// know about onto an allowed type: every field the module checks is
// identical, and the bytes are not.
test("an artifact carrying more than the checked fields is refused", async () => {
const parsed = Transaction.from(await signedWith({}));
const smuggled = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
smuggled[key] = parsed[key];
}
smuggled.unsignedSerialized = parsed.unsignedSerialized + "ff";
expect(() => assertNothingUnchecked(smuggled)).toThrow(
/beyond the fields that were checked/,
);
expect(() => assertNothingUnchecked(parsed)).not.toThrow();
});
// The closing check rebuilds the artifact from the fields the module
// compared and compares the bytes, so an artifact carrying anything else
// is refused without the module having to name it. Assert the rebuild is
// faithful for every accepted shape, since a rebuild that dropped a
// legitimate field would refuse honest transactions.
test("an accepted artifact of each allowed type rebuilds byte for byte", async () => {
const shapes = [
{
approved: { ...TX_PARAMS, gasPrice: "0x77359400" },
overrides: {
type: 0,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
},
},
{
approved: {
...TX_PARAMS,
gasPrice: "0x77359400",
accessList: [
{
address: RECIPIENT,
storageKeys: ["0x" + "11".repeat(32)],
},
],
},
overrides: {
type: 1,
gasPrice: 2000000000n,
maxFeePerGas: null,
maxPriorityFeePerGas: null,
accessList: [
{
address: RECIPIENT,
storageKeys: ["0x" + "11".repeat(32)],
},
],
},
},
{ approved: TX_PARAMS, overrides: {} },
];
for (const shape of shapes) {
const raw = await signedFor(
shape.approved,
signer,
shape.overrides,
);
const parsed = verifySignedTx(
raw,
shape.approved,
signer.address,
SELECTED,
);
const fields = { type: parsed.type };
for (const key of SERIALIZED_FIELDS[parsed.type]) {
fields[key] = parsed[key];
}
expect(Transaction.from(fields).unsignedSerialized).toBe(
parsed.unsignedSerialized,
);
}
});
});
// Every comparison above runs against the decode, but the string that is
// handed to broadcastTransaction() is the artifact. An encoding the decoder
// normalizes away therefore checks as one transaction and broadcasts as
// different bytes, so the artifact must be the canonical encoding of itself.
describe("verifySignedTx canonical encoding", () => {
// Re-encode a signed type-2 artifact with a leading zero byte on the RLP
// value field. It decodes to exactly the approved transaction — same
// value, same signer, same everything the field comparisons look at — and
// it is not the same string.
async function nonCanonical() {
const raw = await signedWith({});
const items = decodeRlp("0x" + raw.slice(4));
// type 2 payload order: chainId, nonce, maxPriorityFeePerGas,
// maxFeePerGas, gasLimit, to, value, data, accessList, then the
// signature.
const padded = items.slice();
padded[6] = "0x00" + items[6].slice(2);
return "0x02" + encodeRlp(padded).slice(2);
}
test("the mutation decodes to the approved transaction and is not it", async () => {
const raw = await signedWith({});
const mutated = await nonCanonical();
const parsed = Transaction.from(mutated);
expect(mutated).not.toBe(raw);
expect(mutated.length).toBeGreaterThan(raw.length);
expect(parsed.value).toBe(BigInt(TX_PARAMS.value));
expect(parsed.from).toBe(signer.address);
expect(parsed.serialized).not.toBe(mutated);
});
test("refuses an artifact that is not its own canonical encoding", async () => {
const mutated = await nonCanonical();
expect(() =>
verifySignedTx(mutated, TX_PARAMS, signer.address, SELECTED),
).toThrow(/not encoded canonically/);
});
test("assertCanonicalBytes accepts what ethers itself produced", async () => {
const raw = await signedWith({});
expect(() =>
assertCanonicalBytes(Transaction.from(raw), raw),
).not.toThrow();
});
test("hex case is not part of the encoding", async () => {
const raw = await signedWith({});
const upper = "0x" + raw.slice(2).toUpperCase();
expect(() =>
verifySignedTx(upper, TX_PARAMS, signer.address, SELECTED),
).not.toThrow();
});
});
// The approval and the artifact spell the same values differently. None of
// these differences is tampering, so none may refuse the signature.
describe("verifySignedTx normalization", () => {
test("accepts a decimal chain id against a hex selected network", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, 1),
).not.toThrow();
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, "1"),
).not.toThrow();
});
test("accepts an approved chain id written in hex", async () => {
const raw = await signedWith({});
const approved = { ...TX_PARAMS, chainId: "0x1" };
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts a hex nonce against a numeric one", async () => {
const raw = await signedWith({ nonce: 7 });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, nonce: "0x7" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts a decimal gas limit against a hex one", async () => {
const raw = await signedWith({ gasLimit: 100000n });
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, gasLimit: "100000" },
signer.address,
SELECTED,
),
).not.toThrow();
});
test("accepts fee fields spelled as hex, decimal, number and bigint", async () => {
const raw = await signedWith({});
for (const maxFee of [
"0x77359400",
"2000000000",
2000000000,
2000000000n,
]) {
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, maxFeePerGas: maxFee },
signer.address,
SELECTED,
),
).not.toThrow();
}
});
test("accepts an approval that fixes no nonce, gas or fee at all", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED),
).not.toThrow();
});
test("accepts an approval whose recipient case differs", async () => {
const raw = await signedWith({});
const approved = { ...TX_PARAMS, to: RECIPIENT.toLowerCase() };
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("accepts absent call data against 0x", async () => {
const approved = { to: RECIPIENT, value: "0x0" };
const raw = await signedFor({ ...approved, data: "0x" });
expect(() =>
verifySignedTx(raw, approved, signer.address, SELECTED),
).not.toThrow();
});
test("refuses an approved quantity that is not a number", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, maxFeePerGas: "cheap" },
signer.address,
SELECTED,
),
).toThrow(/is not a number/);
});
// The value is page-controlled. A refusal is correct; a raw BigInt
// conversion error is not, because it is not a mismatch, so it would be
// reported retryable and leave the approval unspent behind a live button
// that can never succeed.
test("refuses an approved value that is not a number, as a mismatch", async () => {
const raw = await signedWith({});
for (const value of ["cheap", 1.5, "1e18", {}]) {
let thrown;
try {
verifySignedTx(
raw,
{ ...TX_PARAMS, value },
signer.address,
SELECTED,
);
throw new Error("expected a rejection");
} catch (e) {
thrown = e;
}
expect(thrown.approvalMismatch).toBe(true);
expect(thrown.message).toMatch(/approved value is not a number/);
expect(failureIsRetryable(thrown)).toBe(false);
}
});
test("refuses an approved access list that is not an access list", async () => {
const raw = await signedWith({});
expect(() =>
verifySignedTx(
raw,
{ ...TX_PARAMS, accessList: ["nope"] },
signer.address,
SELECTED,
),
).toThrow(/not a valid access list/);
});
});
const TYPED_DATA = JSON.stringify({
domain: {
name: "AutistMask Test",
@@ -281,6 +920,147 @@ describe("verifySignature", () => {
});
});
// What happens after a signing attempt fails: the background keeps the
// approval for anything the user can correct, and the popup only offers the
// button again when it did.
describe("signing failure and retry", () => {
test("a failure that is not a mismatch leaves the approval retryable", () => {
expect(failureIsRetryable(new Error("The node is unreachable."))).toBe(
true,
);
expect(failureIsRetryable(undefined)).toBe(true);
});
test("a mismatch spends the approval", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
try {
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
throw new Error("expected a rejection");
} catch (e) {
expect(failureIsRetryable(e)).toBe(false);
}
});
test("a retryable failure keeps the button usable and says only what failed", () => {
const outcome = describeSigningFailure(
{ error: "The node rejected the transaction.", retryable: true },
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(true);
expect(outcome.message).toBe("The node rejected the transaction.");
});
test("a refusal tells the user to start again from the site", () => {
const outcome = describeSigningFailure(
{
error: "The signed transaction does not go to the approved recipient.",
retryable: false,
},
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/start it again from the site\.$/);
});
test("a refusal for an attempt already running does not say to start again", () => {
const outcome = describeSigningFailure(
{
error: "This request is already being signed.",
retryable: false,
stage: "inflight",
},
"The message could not be signed.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).not.toMatch(/start it again from the site/);
expect(outcome.message).toMatch(/first attempt is still running/);
});
test("a response the background never sent is treated as a spent approval", () => {
const outcome = describeSigningFailure(
undefined,
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/^The transaction could not be sent\./);
});
test("every failure message is a full sentence", () => {
const outcome = describeSigningFailure(
{ error: "The node is on fire", retryable: true },
"The transaction could not be sent.",
);
expect(outcome.message).toMatch(/^[A-Z].*\.$/);
});
test("a popup that could not sign leaves the approval standing", () => {
const outcome = describeTxFailure(
TX_STAGE_SIGN,
"That password is incorrect. Please try again.",
);
expect(outcome.retryable).toBe(true);
expect(outcome.spendApproval).toBe(false);
expect(outcome.error).toMatch(/password is incorrect/);
});
test("a mismatch found at verification spends the approval", async () => {
const raw = await signedFor({ ...TX_PARAMS, to: OTHER_RECIPIENT });
let outcome;
try {
verifySignedTx(raw, TX_PARAMS, signer.address, SELECTED);
} catch (e) {
outcome = describeTxFailure(TX_STAGE_VERIFY, e);
}
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
});
test("a failure before the check ran is still retryable", () => {
const outcome = describeTxFailure(
TX_STAGE_VERIFY,
new Error("The wallet state could not be read."),
);
expect(outcome.retryable).toBe(true);
expect(outcome.spendApproval).toBe(false);
});
// A broadcast that throws after the node took the transaction is routine:
// a timeout, a dropped response, a node answering "already known". The
// popup's retry does not re-broadcast the same bytes — it re-populates and
// re-signs at a freshly fetched nonce — so a retryable broadcast failure
// would put the approved transfer on the chain twice.
test("a failed broadcast is terminal, whatever the node said", () => {
for (const message of [
"already known",
"timeout of 30000ms exceeded",
"could not coalesce error",
"replacement transaction underpriced",
]) {
const outcome = describeTxFailure(
TX_STAGE_BROADCAST,
new Error(message),
);
expect(outcome.retryable).toBe(false);
expect(outcome.spendApproval).toBe(true);
expect(outcome.error).toBe(message);
}
});
test("a failed broadcast does not tell the user to send it again", () => {
const outcome = describeSigningFailure(
{
error: "The node did not answer.",
retryable: false,
stage: TX_STAGE_BROADCAST,
},
"The transaction could not be sent.",
);
expect(outcome.retryable).toBe(false);
expect(outcome.message).toMatch(/may still have reached the network/);
expect(outcome.message).not.toMatch(/start it again from the site/);
});
});
// End-to-end over the messaging boundary, without a browser: run the exact
// sequence the approval popup runs, then hand the artifact to the exact check
// the background runs before it broadcasts or resolves. Only what the popup
@@ -314,7 +1094,12 @@ describe("popup signing sequence to background verification", () => {
test("a populated, signed transaction is accepted and broadcastable", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS);
const parsed = verifySignedTx(rawSignedTx, TX_PARAMS, signer.address);
const parsed = verifySignedTx(
rawSignedTx,
TX_PARAMS,
signer.address,
SELECTED,
);
expect(parsed.nonce).toBe(7);
expect(parsed.chainId).toBe(1n);
expect(parsed.gasLimit).toBe(21000n);
@@ -349,7 +1134,14 @@ describe("popup signing sequence to background verification", () => {
to: OTHER_RECIPIENT,
});
expect(() =>
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address),
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SELECTED),
).toThrow(/approved recipient/);
});
test("the background rejects a transaction populated on another network", async () => {
const rawSignedTx = await popupSignsTx(TX_PARAMS);
expect(() =>
verifySignedTx(rawSignedTx, TX_PARAMS, signer.address, SEPOLIA),
).toThrow(/different network than the one that is selected/);
});
});

View File

@@ -0,0 +1,679 @@
// The background's approval message wiring, driven end to end: a dApp
// eth_sendTransaction raises a pending approval, and the popup answers it with
// AUTISTMASK_TX_RESPONSE / AUTISTMASK_SIGN_RESPONSE.
//
// What this exists for is the duplicate response. The handler verifies and
// broadcasts asynchronously, and the approval deliberately survives a
// retryable failure so the user can try again with the transaction they
// already saw — which means the entry being present is not by itself proof
// that no attempt is running. A second response carrying the same id (a
// reloaded approval window re-rendering a live Approve button, a popup that
// emits the message twice) must not start a second verify and broadcast: with
// the ordinary dApp approval shape the page fixes no nonce, so two artifacts
// signed at different nonces both verify, and the approved transfer would go
// out twice.
const { Wallet } = require("ethers");
const SIGNER_KEY =
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
// What the dApp asks for: no nonce, no gas, no fees. This is the shape that
// makes a duplicate broadcast possible at all.
const TX_PARAMS = {
from: signer.address,
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0x",
};
// The fields the popup's populateTransaction() would fill in. The nonce is a
// parameter because the duplicate case turns on the two artifacts differing
// in exactly the field nothing constrains.
function populated(nonce) {
return {
type: 2,
chainId: 1,
nonce,
gasLimit: 100000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
to: TX_PARAMS.to,
value: BigInt(TX_PARAMS.value),
data: TX_PARAMS.data,
};
}
function signedAtNonce(nonce) {
return signer.signTransaction(populated(nonce));
}
// A promise whose settlement the test controls, so a broadcast can be held in
// flight while the second response arrives.
function deferred() {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
// Load the background worker against stubbed browser and network APIs and
// return the handles the tests drive it through. Everything that would touch
// the network or the browser's own schedulers is mocked; the approval
// verification is the real module, because that is what the handler under
// test is wired to.
function loadBackground(options) {
const opts = options || {};
jest.resetModules();
const broadcastTransaction = jest.fn();
const loadState = jest.fn(opts.loadState || (async () => {}));
jest.doMock("../src/shared/state", () => ({
state: { rpcUrl: "https://rpc.invalid", wallets: [] },
loadState,
saveState: jest.fn(async () => {}),
currentNetwork: () => ({ chainId: "0x1" }),
}));
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({ broadcastTransaction }),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
refreshPhishingListOnSchedule: jest.fn(async () => {}),
initPhishingList: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
PHISHING_REFRESH_ALARM: "phishing",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
const persisted = {
wallets: [
{ name: "Wallet 1", type: "hd", addresses: [signer.address] },
],
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
let messageListener = null;
let windowRemovedListener = null;
const created = [];
const removed = [];
global.chrome = {
storage: {
local: {
get: jest.fn(async () => ({ autistmask: persisted })),
set: jest.fn(async () => {}),
},
},
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options2, cb) => {
created.push(options2);
cb({ id: created.length });
},
remove: (id, cb) => {
removed.push(id);
if (cb) cb();
},
// Captured, not swallowed: closing the approval window is the
// event that used to retire an approval out from under a live
// broadcast, and a no-op stub here hides exactly that.
onRemoved: {
addListener: (fn) => {
windowRemovedListener = fn;
},
},
},
tabs: {
query: (q, cb) => cb([]),
sendMessage: () => {},
},
action: { setPopup: () => {} },
};
require("../src/background/index");
// Send a message the way the browser would, and hand back whatever the
// handler passed to sendResponse.
function send(msg, sender) {
const sendResponse = jest.fn();
const kept = messageListener(msg, sender || {}, sendResponse);
return { sendResponse, kept };
}
// Raise a pending transaction approval the way a dApp does, and dig the
// approval id back out of the popup URL the background opened.
function requestTx() {
let rpcResult = null;
const sendResponse = jest.fn((r) => {
rpcResult = r;
});
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_sendTransaction",
params: [TX_PARAMS],
},
{ origin: ORIGIN },
sendResponse,
);
return {
id: () => new URL(created[0].url).searchParams.get("approval"),
result: () => rpcResult,
};
}
// The user closes the approval popup. `created` is index-aligned with the
// ids the window stub hands back, so window 1 is the first popup opened.
function closeWindow(windowId) {
windowRemovedListener(windowId);
}
return {
send,
requestTx,
closeWindow,
broadcastTransaction,
loadState,
created,
removed,
fromPopup: { url: EXT_URL + "src/popup/index.html" },
};
}
// Let the handler's promise chain run to the next suspension point.
async function settle() {
for (let i = 0; i < 10; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
describe("one approval, one broadcast", () => {
test("a second AUTISTMASK_TX_RESPONSE for the same id does not broadcast again", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
expect(id).toBeTruthy();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
// The popup answers. Verification passes and the broadcast is held
// open, which is the whole window the second message arrives in.
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
// A reloaded approval window signs the same approval again. Nothing
// in the approval fixes a nonce, so this artifact verifies just as
// well as the first one.
const second = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(8),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(second.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/already being sent/),
retryable: false,
}),
);
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
test("the same artifact sent twice broadcasts once", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
const raw = await signedAtNonce(7);
const msg = {
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: raw,
};
bg.send(msg, { url: bg.fromPopup.url });
bg.send(msg, { url: bg.fromPopup.url });
await settle();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
test("a response arriving after the broadcast finished finds nothing to send", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
const late = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(8),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(late.sendResponse).not.toHaveBeenCalled();
});
test("a second AUTISTMASK_SIGN_RESPONSE for the same id is refused", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
// Hold the transaction approval in flight, then answer it a second
// time as if it were a sign approval: the sign handler must apply the
// same interlock rather than running its own verification.
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
const second = bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id,
approved: true,
signature: "0x00",
},
{ url: bg.fromPopup.url },
);
await settle();
expect(second.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.stringMatching(/already being signed/),
retryable: false,
}),
);
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
});
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
let failNext = true;
const bg = loadBackground({
loadState: async () => {
if (failNext) {
failNext = false;
throw new Error("storage unavailable");
}
},
});
const pending = bg.requestTx();
await settle();
const id = pending.id();
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(first.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({ retryable: true }),
);
bg.broadcastTransaction.mockResolvedValue({ hash: "0xfeed" });
const retry = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
expect(retry.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
expect(pending.result()).toEqual({ result: "0xfeed" });
});
test("a mismatched artifact spends the approval outright", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
// Signed for a different recipient than the one that was approved.
const wrong = await signer.signTransaction({
...populated(7),
to: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
});
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: wrong,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(first.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({ retryable: false, stage: "verify" }),
);
const retry = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(retry.sendResponse).not.toHaveBeenCalled();
});
});
// The claim is what makes one approval one broadcast, so it has to hold
// against everything else that retires an approval, not just against a second
// AUTISTMASK_TX_RESPONSE. Each of these paths used to resolve the waiting
// promise 4001 while the attempt behind it ran to completion: the transaction
// reached the chain and the page was told the user rejected it, which invites
// the user to send it a second time at a fresh nonce.
describe("a claimed approval outlives every other retirement path", () => {
// The approval popup stays open across the broadcast it is waiting on, so
// a user closing an apparently-hung window needs no adversary at all.
test("closing the approval window mid-broadcast still reports the result", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
const first = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
// The user closes the window while the broadcast is still open.
bg.closeWindow(1);
await settle();
expect(pending.result()).toBeNull();
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(first.sendResponse).toHaveBeenCalledWith({ txHash: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
test("switching the active address mid-broadcast still reports the result", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
// The user switches account in the toolbar popup, which rejects and
// force-closes every pending approval.
bg.send(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
// The window an in-flight attempt reports into is left standing too.
expect(bg.removed).toEqual([]);
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
test("a reject arriving mid-broadcast is refused, not honoured", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
const reject = bg.send(
{ type: "AUTISTMASK_TX_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toBeNull();
expect(reject.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({
retryable: false,
stage: "broadcast",
}),
);
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
expect(bg.broadcastTransaction).toHaveBeenCalledTimes(1);
});
// The refusals above must not cost the rejection its ordinary meaning.
test("with no attempt running, closing the window still rejects", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.closeWindow(1);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
});
test("with no attempt running, an active-address switch still rejects and closes", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
bg.send(
{ type: "AUTISTMASK_ACTIVE_CHANGED" },
{ url: bg.fromPopup.url },
);
await settle();
expect(pending.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.removed).toEqual([1]);
});
// A sign approval held by a running verification is the same shape, and
// the refusal must not tell the user to start again from the site while
// the first attempt may still hand back a signature.
test("a reject during a sign attempt is refused with the in-flight stage", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const inFlight = deferred();
bg.broadcastTransaction.mockReturnValue(inFlight.promise);
bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: bg.fromPopup.url },
);
await settle();
const reject = bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: false },
{ url: bg.fromPopup.url },
);
await settle();
expect(reject.sendResponse).toHaveBeenCalledWith(
expect.objectContaining({ retryable: false, stage: "inflight" }),
);
inFlight.resolve({ hash: "0xfeed" });
await settle();
expect(pending.result()).toEqual({ result: "0xfeed" });
});
});
describe("popup-only messages", () => {
test("a page sender cannot answer an approval", async () => {
const bg = loadBackground();
const pending = bg.requestTx();
await settle();
const id = pending.id();
const spoof = bg.send(
{
type: "AUTISTMASK_TX_RESPONSE",
id,
approved: true,
rawSignedTx: await signedAtNonce(7),
},
{ url: ORIGIN + "/index.html" },
);
await settle();
expect(bg.broadcastTransaction).not.toHaveBeenCalled();
expect(spoof.sendResponse).toHaveBeenCalledWith({
error: "Unauthorized sender",
});
});
});

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);
@@ -293,5 +307,6 @@ module.exports = {
launch,
openAddressDetail,
openPopup,
pageCompilesWasm,
visible,
};

View File

@@ -15,6 +15,7 @@ const {
launch,
openAddressDetail,
openPopup,
pageCompilesWasm,
visible,
} = require("./harness");
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);
});
// 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) => {
env.phrase = await createWallet(env.page);
assert(

166
tests/holders.test.js Normal file
View 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
View File

@@ -0,0 +1,105 @@
// The shipped Content Security Policy, pinned in both directions.
//
// This is the anti-regression check for #182. libsodium decides its
// backend by trying to compile WebAssembly and catching the failure, so a
// CSP that refuses WASM demotes the vault to the wasm2js translation —
// roughly 20x slower per Argon2id derivation — and says so only in a
// console message nobody reads. Dropping 'wasm-unsafe-eval' from either
// manifest therefore has to fail a check, not a log line.
//
// It is equally a check against loosening. 'wasm-unsafe-eval' is granted
// deliberately and narrowly (see the backend note in src/shared/vault.js);
// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and
// an exact match on the token set is what keeps the next edit from
// smuggling one in alongside.
//
// build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships.
const fs = require("fs");
const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
const EXPECTED_OBJECT_SRC = ["'self'"];
const FORBIDDEN_SOURCES = [
"'unsafe-eval'",
"'unsafe-inline'",
"http:",
"https:",
"data:",
"blob:",
"*",
];
function readManifest(name) {
return JSON.parse(
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
);
}
// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... }
function parseCsp(policy) {
const directives = {};
for (const part of policy.split(";")) {
const tokens = part.trim().split(/\s+/).filter(Boolean);
if (tokens.length === 0) continue;
directives[tokens[0]] = tokens.slice(1);
}
return directives;
}
function assertPolicy(policy) {
const directives = parseCsp(policy);
expect(Object.keys(directives).sort()).toEqual([
"object-src",
"script-src",
]);
expect(directives["script-src"].slice().sort()).toEqual(
EXPECTED_SCRIPT_SRC,
);
expect(directives["object-src"].slice().sort()).toEqual(
EXPECTED_OBJECT_SRC,
);
for (const source of FORBIDDEN_SOURCES) {
expect(directives["script-src"]).not.toContain(source);
expect(directives["object-src"]).not.toContain(source);
}
}
describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs.
test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("chrome").content_security_policy;
expect(typeof csp).toBe("object");
expect(Object.keys(csp)).toEqual(["extension_pages"]);
assertPolicy(csp.extension_pages);
});
// MV2 takes the policy as a bare string. Firefox does not require
// 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
// Bugzilla 1770909 is still open — so that token is future-proofing
// for when it lands, not a mandate, and it stays inside Firefox's MV2
// base-CSP ceiling. object-src 'self' is the load-bearing half: a
// Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, different manifest shape.
test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("firefox").content_security_policy;
expect(typeof csp).toBe("string");
assertPolicy(csp);
});
// The two targets share one codebase and one crypto path; a policy
// that drifts apart between them means one of the two builds is
// running a backend nothing tests.
test("both targets ship the same policy", () => {
const chrome =
readManifest("chrome").content_security_policy.extension_pages;
const firefox = readManifest("firefox").content_security_policy;
expect(firefox).toBe(chrome);
});
});

View File

@@ -0,0 +1,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([]);
});
});

View File

@@ -1473,6 +1473,65 @@ describe("fetchRecentTransactions merge and dedup", () => {
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 () => {
debugFetch.mockImplementation(async () => ({
ok: false,

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

290
tests/walletDefects.test.js Normal file
View File

@@ -0,0 +1,290 @@
// Tests for the stored-state half of the non-master extended key problem.
//
// Refusing a non-master xprv at import time does nothing for a wallet that is
// already in storage: the import that created it ran before the refusal
// existed. Such a wallet used to sign for the wrong tree and now throws on the
// send screen instead. These tests pin down that it is named and explained in
// the wallet list, that nothing on the way there throws, and that a wallet
// imported from a real master key is untouched by any of it.
const { HDNodeWallet, Mnemonic } = require("ethers");
const wallet = require("../src/shared/wallet");
const {
walletDefect,
walletDefectHtml,
NON_MASTER_XPRV,
} = require("../src/shared/walletDefects");
// BIP-39 test vector phrase, published; never used for real funds.
const VECTOR_PHRASE =
"test test test test test test test test test test test junk";
function seedNode(phrase) {
return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed());
}
// The master (depth-0) key, which is what the import flow accepts today.
function masterXprv(phrase) {
return seedNode(phrase).extendedKey;
}
// The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to
// hold, and what the import flow used to accept.
function accountXprv(phrase) {
return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey;
}
// The wallet record the CURRENT import path writes for a master key: the
// neutered m/44'/60'/0'/0 node, four levels below a depth-0 key.
function healthyXprvWallet(name = "Master") {
const { xpub, firstAddress } = wallet.hdWalletFromXprv(
masterXprv(VECTOR_PHRASE),
);
return {
name,
type: "xprv",
xpub,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [{ address: firstAddress, balance: "0.0000" }],
};
}
// The wallet record the PRE-#210 import path wrote for an account-level key:
// the same four levels, but derived as a relative path *beneath* the key, so
// the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code
// built it rather than by calling the module under test, which now refuses.
function brokenXprvWallet(name = "Imported xprv") {
const node = HDNodeWallet.fromExtendedKey(
accountXprv(VECTOR_PHRASE),
).derivePath("44'/60'/0'/0");
return {
name,
type: "xprv",
xpub: node.neuter().extendedKey,
nextIndex: 1,
encryptedSecret: "irrelevant-to-these-tests",
addresses: [
{ address: node.deriveChild(0).address, balance: "0.0000" },
],
};
}
describe("the fixtures are what the two import paths actually produced", () => {
test("a master import stores a depth-4 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth,
).toBe(4);
});
test("the pre-fix account-level import stored a depth-7 xpub", () => {
expect(
HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth,
).toBe(7);
});
});
describe("walletDefect", () => {
test("names the defect on a stored non-master xprv wallet", () => {
const defect = walletDefect(brokenXprvWallet());
expect(defect).not.toBeNull();
expect(defect.id).toBe(NON_MASTER_XPRV);
});
test("a depth-0 xprv wallet has no defect", () => {
expect(walletDefect(healthyXprvWallet())).toBeNull();
});
test("hd and key wallets are never assessed", () => {
expect(
walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }),
).toBe(null);
expect(walletDefect({ type: "key" })).toBeNull();
});
test("an xprv wallet whose xpub cannot be parsed makes no claim", () => {
// No basis to say the key was non-master, so nothing is asserted
// about it rather than guessing.
expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull();
expect(walletDefect({ type: "xprv" })).toBeNull();
});
test("nothing about the wallet record is modified by the check", () => {
const w = brokenXprvWallet();
const before = JSON.stringify(w);
walletDefect(w);
expect(JSON.stringify(w)).toBe(before);
});
});
describe("the explanatory copy", () => {
const defect = walletDefect(brokenXprvWallet());
test("every sentence of it is a full sentence", () => {
for (const text of [defect.heading, ...defect.paragraphs]) {
expect(text).toMatch(/^[A-Z]/);
expect(text.trimEnd()).toMatch(/\.$/);
}
});
test("it says what was derived wrongly and that these are not the standard addresses", () => {
const body = defect.paragraphs.join(" ");
expect(body).toContain("not a master key");
expect(body).toMatch(/standard path/);
});
test("it does not claim the funds are safe and does not claim a loss", () => {
const all = [defect.heading, ...defect.paragraphs].join(" ");
expect(all).not.toMatch(/\bsafe\b/i);
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
});
test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/until you delete it yourself/);
expect(body).toMatch(/recovery phrase/);
});
test("it uses the project's vocabulary", () => {
const all = [
defect.heading,
...defect.paragraphs,
defect.shortMessage,
].join(" ");
expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i);
expect(all).not.toMatch(/\baccounts?\b/i);
});
});
describe("walletDefectHtml", () => {
test("renders the heading and every paragraph for a defective wallet", () => {
const defect = walletDefect(brokenXprvWallet());
const html = walletDefectHtml(brokenXprvWallet());
expect(html).toContain(defect.heading);
for (const p of defect.paragraphs) {
expect(html).toContain(p);
}
});
test("renders nothing at all for a healthy wallet", () => {
expect(walletDefectHtml(healthyXprvWallet())).toBe("");
});
});
describe("the wallet list", () => {
let home;
let state;
beforeAll(() => {
global.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
runtime: { sendMessage: () => {} },
};
home = require("../src/popup/views/home");
state = require("../src/shared/state").state;
});
afterEach(() => {
state.wallets = [];
state.activeAddress = null;
});
test("a stored depth-3 xprv wallet renders the explanation", () => {
state.wallets = [brokenXprvWallet("Imported xprv")];
const html = home.walletListHtml();
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(html).toContain("Imported xprv");
});
test("it does not offer to derive further addresses from that wallet", () => {
state.wallets = [brokenXprvWallet()];
expect(home.walletListHtml()).not.toContain("btn-add-address");
});
test("a normal depth-0 xprv wallet renders exactly as it did before", () => {
state.wallets = [healthyXprvWallet("Master")];
const html = home.walletListHtml();
expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading);
expect(html).toContain("btn-add-address");
expect(html).toContain(state.wallets[0].addresses[0].address);
});
test("the defective wallet's notice does not bleed onto a healthy one", () => {
state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")];
const html = home.walletListHtml();
const healthyPart = html.slice(html.indexOf("Fine"));
expect(html).toContain(walletDefect(state.wallets[0]).heading);
expect(healthyPart).not.toContain(
walletDefect(state.wallets[0]).heading,
);
expect(healthyPart).toContain("btn-add-address");
});
});
describe("no path throws an unhandled error for a defective wallet", () => {
test("address derivation from the stored xpub still works", () => {
// The stored xpub is at a non-standard depth but is a valid extended
// key; deriving from it is what the list render already does.
const w = brokenXprvWallet();
expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow();
expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe(
w.addresses[0].address,
);
});
test("the wallet list renders without throwing", () => {
const { state } = require("../src/shared/state");
const home = require("../src/popup/views/home");
state.wallets = [brokenXprvWallet()];
expect(() => home.walletListHtml()).not.toThrow();
state.wallets = [];
});
test("signing refuses with the named defect rather than a bare failure", () => {
// getSignerForAddress is the backstop behind the UI gate. It must
// still refuse, and it must say why in a sentence the user can read.
let thrown = null;
try {
wallet.getSignerForAddress(
{ type: "xprv" },
0,
accountXprv(VECTOR_PHRASE),
);
} catch (e) {
thrown = e;
}
expect(thrown).not.toBeNull();
expect(thrown.message).toMatch(/master key/);
expect(thrown.message.trimEnd()).toMatch(/\.$/);
});
test("a healthy xprv wallet signs as it always did", () => {
const signer = wallet.getSignerForAddress(
{ type: "xprv" },
0,
masterXprv(VECTOR_PHRASE),
);
expect(signer.address).toBe(healthyXprvWallet().addresses[0].address);
});
});