Some checks failed
check / check (push) Has been cancelled
Verification compared the signed artifact against the dApp's request object. For every field the dApp omitted -- normally nonce, gas limit and all the fee fields, since the popup filled them in -- the number the user actually read on screen was verified by nothing, and only absolute ceilings stood behind it. The transaction is now populated in the background before the approval window opens, and that populated object is both what the popup displays and what the signed artifact is verified against. Every consequential field becomes an equality comparison; the ceilings remain as a backstop. Population failing means no approval and no window, and the error goes to the requesting page -- earlier than before, where the same estimate failed after the password had been typed. The account is pinned too: `from` is compared against the address named at approval time rather than whichever address is active at signing, so switching accounts mid-flow refuses instead of signing from an account the approval did not name. The message-signing path had the same defect and gets the same fix. Nonce selection moves earlier as a consequence; the concurrent-approval case that follows from it is tracked at #271.
1152 lines
41 KiB
JavaScript
1152 lines
41 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,
|
|
sameAddress,
|
|
ApprovalMismatchError,
|
|
TX_STAGE_SIGN,
|
|
TX_STAGE_VERIFY,
|
|
TX_STAGE_BROADCAST,
|
|
TX_STAGE_INFLIGHT,
|
|
} = require("../shared/approvalVerify");
|
|
const { prepareApprovalTx } = require("../shared/approvalTx");
|
|
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;
|
|
}
|
|
|
|
// Whether a request names a signing address other than the active one. Such a
|
|
// request is refused rather than quietly signed as whichever address happens
|
|
// to be active: the page asked for account A and would otherwise be handed
|
|
// something from account B.
|
|
function namesAnotherAddress(requested, activeAddress) {
|
|
return !!requested && !sameAddress(requested, activeAddress);
|
|
}
|
|
|
|
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.
|
|
//
|
|
// `approvedTx` is the fully populated transaction (see approvalTx.js): the
|
|
// object the popup displays, the object it signs, and the object the artifact
|
|
// is verified against. `approvedFrom` is the address that is active now, and
|
|
// it is pinned here rather than read again at signing time — an address switch
|
|
// between approval and signing must refuse, not sign from an account this
|
|
// screen never named.
|
|
function requestTxApproval(origin, hostname, approvedTx, approvedFrom) {
|
|
return new Promise((resolve) => {
|
|
const id = crypto.randomUUID();
|
|
pendingApprovals[id] = {
|
|
origin,
|
|
hostname,
|
|
approvedTx,
|
|
approvedFrom,
|
|
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, approvedFrom) {
|
|
return new Promise((resolve) => {
|
|
const id = crypto.randomUUID();
|
|
pendingApprovals[id] = {
|
|
origin,
|
|
hostname,
|
|
signParams,
|
|
approvedFrom,
|
|
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 (namesAnotherAddress(signParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to sign as an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
|
|
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,
|
|
activeAddress,
|
|
);
|
|
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] };
|
|
if (namesAnotherAddress(signParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to sign as an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
const decision = await requestSignApproval(
|
|
origin,
|
|
hostname,
|
|
signParams,
|
|
activeAddress,
|
|
);
|
|
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] || {};
|
|
if (namesAnotherAddress(txParams.from, activeAddress)) {
|
|
return {
|
|
error: {
|
|
code: 4100,
|
|
message:
|
|
"This site asked to send from an address that is not the active one.",
|
|
},
|
|
};
|
|
}
|
|
|
|
// Populate here, before any window opens, so that the transaction the
|
|
// user is shown is a complete one and is the same object the signed
|
|
// artifact is checked against. A failure raises no approval at all and
|
|
// is reported to the requesting page; see approvalTx.js.
|
|
let approvedTx;
|
|
try {
|
|
approvedTx = await prepareApprovalTx(
|
|
getProvider(await getRpcUrl()),
|
|
activeAddress,
|
|
txParams,
|
|
);
|
|
} catch (e) {
|
|
return { error: { message: e.message } };
|
|
}
|
|
|
|
// Population is a network round trip, and the user can switch address
|
|
// during it. Raising the approval anyway would put an account on the
|
|
// screen that the wallet is no longer on, and it could never be signed
|
|
// — the signing handler refuses exactly that. Refuse it here instead,
|
|
// while the page is still waiting and nothing has been displayed.
|
|
if (!sameAddress(await getActiveAddress(), activeAddress)) {
|
|
return {
|
|
error: {
|
|
message:
|
|
"The active address changed while this transaction was being prepared, so it was not sent.",
|
|
},
|
|
};
|
|
}
|
|
|
|
const decision = await requestTxApproval(
|
|
origin,
|
|
hostname,
|
|
approvedTx,
|
|
activeAddress,
|
|
);
|
|
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";
|
|
// The populated transaction, and the address it was raised
|
|
// for. The popup displays and signs exactly this and does not
|
|
// populate or re-read anything itself.
|
|
resp.approvedTx = approval.approvedTx;
|
|
resp.approvedFrom = approval.approvedFrom;
|
|
}
|
|
if (approval.type === "sign") {
|
|
resp.type = "sign";
|
|
resp.signParams = approval.signParams;
|
|
resp.approvedFrom = approval.approvedFrom;
|
|
}
|
|
// 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();
|
|
// An address switch between approval and signing refuses. The
|
|
// approval named one account; signing from whichever account
|
|
// is active now would send funds from an account this screen
|
|
// never showed. A switch normally rejects every pending
|
|
// approval on its way through broadcastAccountsChanged(), so
|
|
// this is the case where that did not reach the approval —
|
|
// and it is a refusal, not a retry, because the transaction
|
|
// the user saw is no longer the transaction that would go out.
|
|
if (!sameAddress(activeAddress, approval.approvedFrom)) {
|
|
throw new ApprovalMismatchError(
|
|
"The active address changed after this transaction was approved, so it was not sent.",
|
|
);
|
|
}
|
|
// The popup holds the secret, but the background stays the
|
|
// authority on what is broadcast: the raw transaction must be
|
|
// the transaction that was displayed, signed by the address
|
|
// the approval named, on the network that is selected.
|
|
verifySignedTx(
|
|
msg.rawSignedTx,
|
|
approval.approvedTx,
|
|
approval.approvedFrom,
|
|
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, so the wallet cannot
|
|
// tell a transaction that never left from one already in the
|
|
// mempool. The page has been given its outcome for this
|
|
// request; a second attempt would report a second one.
|
|
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();
|
|
// Same as the transaction path: the address the approval named
|
|
// is the one that must have signed, and a switch since then is
|
|
// a refusal rather than a signature from another account.
|
|
if (!sameAddress(activeAddress, approval.approvedFrom)) {
|
|
throw new ApprovalMismatchError(
|
|
"The active address changed after this request was approved, so it was not signed.",
|
|
);
|
|
}
|
|
// 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 address
|
|
// the approval named.
|
|
const signature = msg.signature;
|
|
verifySignature(
|
|
approval.signParams,
|
|
signature,
|
|
approval.approvedFrom,
|
|
);
|
|
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;
|
|
}
|
|
});
|