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.
This commit is contained in:
2026-08-12 08:44:41 +00:00
parent bd4bdcafc7
commit 4e2ca87a06
6 changed files with 2227 additions and 123 deletions

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;