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.
1037 lines
36 KiB
JavaScript
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;
|
|
}
|
|
});
|