Files
AutistMask/src/background/index.js
clawbot 4e2ca87a06
All checks were successful
check / check (push) Successful in 27s
harden: verify all approval fields and make failed signing retryable (closes #174)
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

1037 lines
36 KiB
JavaScript

// AutistMask background service worker
// Handles EIP-1193 RPC requests from content scripts and proxies
// non-sensitive calls to the configured Ethereum JSON-RPC endpoint.
const { DEFAULT_RPC_URL } = require("../shared/constants");
const { SUPPORTED_CHAIN_IDS, networkByChainId } = require("../shared/networks");
const { onChainSwitch } = require("../shared/chainSwitch");
const {
state,
loadState,
saveState,
currentNetwork,
} = require("../shared/state");
const { refreshBalances, getProvider } = require("../shared/balances");
const { debugFetch, log } = require("../shared/log");
const {
verifySignedTx,
verifySignature,
failureIsRetryable,
describeTxFailure,
TX_STAGE_SIGN,
TX_STAGE_VERIFY,
TX_STAGE_BROADCAST,
TX_STAGE_INFLIGHT,
} = require("../shared/approvalVerify");
const {
isPhishingDomain,
refreshPhishingListOnSchedule,
initPhishingList,
} = require("../shared/phishingDomains");
const {
BALANCE_REFRESH_ALARM,
PHISHING_REFRESH_ALARM,
BALANCE_REFRESH_PERIOD_MINUTES,
ensureRecurringAlarms,
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const windowsApi =
typeof browser !== "undefined" ? browser.windows : chrome.windows;
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
const actionApi =
typeof browser !== "undefined" ? browser.browserAction : chrome.action;
// Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {};
async function getState() {
const result = await storageApi.get("autistmask");
return (
result.autistmask || {
wallets: [],
rpcUrl: DEFAULT_RPC_URL,
activeAddress: null,
allowedSites: {},
deniedSites: {},
}
);
}
async function getActiveAddress() {
const s = await getState();
if (s.activeAddress) return s.activeAddress;
// Fall back to first address
if (s.wallets.length > 0 && s.wallets[0].addresses.length > 0) {
return s.wallets[0].addresses[0].address;
}
return null;
}
async function getRpcUrl() {
const s = await getState();
return s.rpcUrl || DEFAULT_RPC_URL;
}
function extractHostname(origin) {
try {
return new URL(origin).hostname;
} catch {
return origin;
}
}
// Proxy an RPC call to the Ethereum node
async function proxyRpc(method, params) {
const rpcUrl = await getRpcUrl();
const resp = await debugFetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method,
params,
}),
});
const json = await resp.json();
if (json.error) {
throw new Error(json.error.message || "RPC error");
}
return json.result;
}
function resetPopupUrl() {
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({ popup: "src/popup/index.html" });
}
}
// 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.
function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => {
const opts = {
url: popupUrl,
type: "popup",
width: popupWidth,
height: popupHeight,
};
if (currentWin) {
opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2,
);
opts.top = Math.round(
currentWin.top + (currentWin.height - popupHeight) / 2,
);
}
windowsApi.create(opts, (win) => {
if (win) {
pendingApprovals[id].windowId = win.id;
}
});
});
}
// Open an approval popup and return a promise that resolves with the user decision.
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionApi.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
} else {
openApprovalWindow(id);
}
});
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
origin,
hostname,
txParams,
resolve,
type: "tx",
};
openApprovalWindow(id);
});
}
// Open a sign-approval popup and return a promise that resolves with { signature } or { error }.
// Uses windows.create() directly because sign approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
// unreliable in this context.
function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
origin,
hostname,
signParams,
resolve,
type: "sign",
};
openApprovalWindow(id);
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// Keep pending — user can reopen the toolbar popup
return;
}
settleApproval(id, { approved: false, remember: false });
}
resetPopupUrl();
});
}
});
// Handle connection requests (eth_requestAccounts, wallet_requestPermissions)
async function handleConnectionRequest(origin) {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress) {
return { error: { message: "No accounts available" } };
}
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const denied = s.deniedSites[activeAddress] || [];
// Check denied list
if (denied.includes(hostname)) {
return {
error: {
code: 4001,
message: "User rejected the request.",
},
};
}
// Check allowed list or in-memory connected
if (
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
}
// Open approval popup
const decision = await requestApproval(origin, hostname);
if (decision.approved) {
if (decision.remember) {
// Reload state to get latest, add to allowed, persist
await loadState();
if (!state.allowedSites[activeAddress]) {
state.allowedSites[activeAddress] = [];
}
if (!state.allowedSites[activeAddress].includes(hostname)) {
state.allowedSites[activeAddress].push(hostname);
}
await saveState();
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await loadState();
if (!state.deniedSites[activeAddress]) {
state.deniedSites[activeAddress] = [];
}
if (!state.deniedSites[activeAddress].includes(hostname)) {
state.deniedSites[activeAddress].push(hostname);
}
await saveState();
}
return {
error: {
code: 4001,
message: "User rejected the request.",
},
};
}
}
// Methods that are safe to proxy directly to the RPC node
const PROXY_METHODS = [
"eth_blockNumber",
"eth_call",
"eth_chainId",
"eth_estimateGas",
"eth_gasPrice",
"eth_getBalance",
"eth_getBlockByHash",
"eth_getBlockByNumber",
"eth_getCode",
"eth_getLogs",
"eth_getStorageAt",
"eth_getTransactionByHash",
"eth_getTransactionCount",
"eth_getTransactionReceipt",
"eth_maxPriorityFeePerGas",
"eth_sendRawTransaction",
"net_version",
"web3_clientVersion",
"eth_feeHistory",
"eth_getBlockTransactionCountByHash",
"eth_getBlockTransactionCountByNumber",
];
async function handleRpc(method, params, origin) {
// Connection requests — go through approval flow
if (method === "eth_requestAccounts") {
return handleConnectionRequest(origin);
}
if (method === "eth_accounts") {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress) return { result: [] };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
}
return { result: [] };
}
if (method === "eth_chainId") {
return { result: currentNetwork().chainId };
}
if (method === "net_version") {
return { result: currentNetwork().networkVersion };
}
if (method === "wallet_switchEthereumChain") {
const chainId = params?.[0]?.chainId;
if (chainId === currentNetwork().chainId) {
return { result: null };
}
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
const target = networkByChainId(chainId);
await onChainSwitch(target.id);
broadcastChainChanged(target.chainId);
return { result: null };
}
return {
error: {
code: 4902,
message:
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
},
};
}
if (method === "wallet_addEthereumChain") {
const chainId = params?.[0]?.chainId;
if (SUPPORTED_CHAIN_IDS.has(chainId)) {
return { result: null };
}
return {
error: {
code: 4902,
message:
"AutistMask supports Ethereum Mainnet and Sepolia Testnet only.",
},
};
}
if (method === "wallet_requestPermissions") {
const connResult = await handleConnectionRequest(origin);
if (connResult.error) return connResult;
return {
result: [
{
parentCapability: "eth_accounts",
caveats: [
{
type: "restrictReturnedAccounts",
value: connResult.result,
},
],
},
],
};
}
if (method === "wallet_getPermissions") {
const s = await getState();
const activeAddress = await getActiveAddress();
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
const isConnected =
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
return { result: [] };
}
return {
result: [
{
parentCapability: "eth_accounts",
caveats: [
{
type: "restrictReturnedAccounts",
value: [activeAddress],
},
],
},
],
};
}
if (method === "personal_sign" || method === "eth_sign") {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
// personal_sign: params[0]=message, params[1]=address
// eth_sign: params[0]=address, params[1]=message
const signParams =
method === "personal_sign"
? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] };
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
const decision = await requestSignApproval(
origin,
hostname,
signParams,
);
if (decision.error) return { error: decision.error };
return { result: decision.signature };
}
if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
const signParams = { method, typedData: params[1], from: params[0] };
const decision = await requestSignApproval(
origin,
hostname,
signParams,
);
if (decision.error) return { error: decision.error };
return { result: decision.signature };
}
if (method === "eth_sendTransaction") {
const s = await getState();
const activeAddress = await getActiveAddress();
if (!activeAddress)
return { error: { message: "No accounts available" } };
const hostname = extractHostname(origin);
const allowed = s.allowedSites[activeAddress] || [];
if (
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
}
const txParams = params?.[0] || {};
const decision = await requestTxApproval(origin, hostname, txParams);
if (decision.error) return { error: decision.error };
return { result: decision.txHash };
}
// Proxy safe read-only methods to the RPC node
if (PROXY_METHODS.includes(method)) {
try {
const result = await proxyRpc(method, params);
return { result };
} catch (e) {
return { error: { message: e.message } };
}
}
return { error: { message: "Unsupported method: " + method } };
}
// Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) {
tabsApi.query({}, (tabs) => {
for (const tab of tabs) {
tabsApi.sendMessage(
tab.id,
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: chainId,
},
() => {
if (runtime.lastError) {
// expected for tabs without our content script
}
},
);
}
});
}
// Broadcast accountsChanged to all tabs, respecting per-address permissions
async function broadcastAccountsChanged() {
// Clear non-remembered approvals on address switch
for (const key of Object.keys(connectedSites)) {
delete connectedSites[key];
}
// 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)) {
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) {
// window already closed
}
});
}
}
resetPopupUrl();
const s = await getState();
const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
tabsApi.query({}, (tabs) => {
for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin);
const hasPermission =
activeAddress &&
(allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]);
tabsApi.sendMessage(
tab.id,
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
},
() => {
// Ignore errors for tabs without content script
if (runtime.lastError) {
// expected for tabs without our content script
}
},
);
}
});
}
// Background balance refresh: every 60 seconds when the popup isn't open.
// When the popup IS open, its 10-second interval keeps lastBalanceRefresh
// fresh, so this naturally skips.
//
// The alarm period alone sets the cadence; this guard only suppresses a
// refresh something else has just done, so it must stay strictly shorter than
// the period. Timed to the period it would veto every tick it gates —
// lastBalanceRefresh is stamped after the refresh runs, so a tick one period
// after the last one always lands inside a guard of equal length and the real
// cadence becomes two periods. Half the period keeps it comfortably above the
// popup's 10-second refresh, so an open popup still suppresses the background
// job, and comfortably below the alarm period, so the schedule always wins.
const BALANCE_REFRESH_PERIOD_MS = BALANCE_REFRESH_PERIOD_MINUTES * 60 * 1000;
const RECENT_BALANCE_REFRESH_MS = Math.floor(BALANCE_REFRESH_PERIOD_MS / 2);
async function backgroundRefresh() {
await loadState();
const now = Date.now();
if (now - (state.lastBalanceRefresh || 0) < RECENT_BALANCE_REFRESH_MS)
return;
if (state.wallets.length === 0) return;
await refreshBalances(
state.wallets,
state.rpcUrl,
state.blockscoutUrl,
state.trackedTokens,
);
state.lastBalanceRefresh = now;
await saveState();
}
// Both recurring jobs run off alarms, not timers. On Chrome MV3 this file is
// a service worker that the browser terminates after about 30 seconds idle,
// so a setInterval would only ever survive until the first idle period and
// module-level state does not outlive it. Alarms are held by the browser and
// wake the worker to deliver them.
registerAlarmHandlers({
[BALANCE_REFRESH_ALARM]: backgroundRefresh,
// The scheduled refresh, which restores persisted state on a freshly
// revived worker and then fetches unconditionally. The freshness guards
// belong to the startup path; applying them here would make the tick skip
// itself.
[PHISHING_REFRESH_ALARM]: refreshPhishingListOnSchedule,
});
// Everything the background context needs re-established on start. This runs
// on a fresh install, on browser startup, and on every revival of a
// terminated worker, so it must be idempotent: ensureRecurringAlarms() only
// creates alarms that are missing or carrying a stale period, and
// initPhishingList() fetches only when the persisted timestamps say the list
// is stale.
//
// On a fresh install the top-level call and the onInstalled listener both run,
// close enough together that both could see an alarm missing and create it.
// Sharing one in-flight run makes the "create only when missing" check
// race-free; the memo is dropped once it settles so a later onStartup runs
// again.
let backgroundJobsRun = null;
function startBackgroundJobs() {
if (backgroundJobsRun) return backgroundJobsRun;
backgroundJobsRun = Promise.all([
ensureRecurringAlarms(),
initPhishingList(),
])
.catch((err) => {
// An alarm that failed to schedule means a recurring job silently
// never runs again; it must not be an unhandled rejection.
log.errorf("background job startup failed:", err);
})
.finally(() => {
backgroundJobsRun = null;
});
return backgroundJobsRun;
}
if (runtime.onInstalled) {
runtime.onInstalled.addListener(startBackgroundJobs);
}
if (runtime.onStartup) {
runtime.onStartup.addListener(startBackgroundJobs);
}
startBackgroundJobs();
// 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) continue;
const rejection =
approval.type === "tx" || approval.type === "sign"
? {
error: {
code: 4001,
message: "User rejected the request.",
},
}
: { approved: false, remember: false };
settleApproval(id, rejection);
}
});
}
// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") {
// Derive origin from trusted sender info to prevent origin spoofing.
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
if (sender.origin) {
trustedOrigin = sender.origin;
} else if (sender.tab && sender.tab.url) {
try {
trustedOrigin = new URL(sender.tab.url).origin;
} catch {
// keep fallback
}
}
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
sendResponse(response);
});
return true;
}
// Validate that popup-only messages originate from the extension itself.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id];
if (approval) {
const resp = {
hostname: approval.hostname,
origin: approval.origin,
};
if (approval.type === "tx") {
resp.type = "tx";
resp.txParams = approval.txParams;
}
if (approval.type === "sign") {
resp.type = "sign";
resp.signParams = approval.signParams;
}
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
sendResponse(resp);
} else {
sendResponse(null);
}
return false;
}
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
settleApproval(msg.id, {
approved: msg.approved,
remember: msg.remember,
});
resetPopupUrl();
return false;
}
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
// 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) {
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. Keep the
// approval so the user can correct the problem and try again with the
// transaction they already saw.
if (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;
}
(async () => {
try {
await loadState();
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, 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);
settleApproval(
msg.id,
{ txHash: tx.hash },
{ holdsClaim: true },
);
sendResponse({ txHash: tx.hash });
} catch (e) {
// 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,
});
}
})();
return true;
}
if (msg.type === "AUTISTMASK_SIGN_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;
// Same as the transaction path: a reject cannot retire an approval an
// attempt already holds.
if (!msg.approved) {
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. Keep the
// approval so the user can correct the problem and try again with the
// message they already saw.
if (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;
}
(async () => {
try {
const activeAddress = await getActiveAddress();
// The popup holds the secret, but the background stays the
// authority on what is handed back to the page: the signature
// must cover the approved payload and recover to the approved
// address.
const signature = msg.signature;
verifySignature(approval.signParams, signature, activeAddress);
settleApproval(msg.id, { signature }, { holdsClaim: true });
sendResponse({ signature });
} catch (e) {
const errMsg = e.shortMessage || e.message;
const retryable = failureIsRetryable(e);
if (!retryable) {
settleApproval(
msg.id,
{ error: { message: errMsg } },
{ holdsClaim: true },
);
} else {
releaseApproval(approval);
}
sendResponse({ error: errMsg, retryable });
}
})();
return true;
}
if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") {
broadcastAccountsChanged();
return false;
}
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
// Popup already saved state; nothing else needed
return false;
}
});