// 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 { getBytes } = require("ethers"); const { state, loadState, saveState, currentNetwork, } = require("../shared/state"); const { refreshBalances, getProvider } = require("../shared/balances"); const { debugFetch } = require("../shared/log"); const { decryptWithPassword } = require("../shared/vault"); const { getSignerForAddress } = require("../shared/wallet"); const { isPhishingDomain, updatePhishingList, startPeriodicRefresh, } = require("../shared/phishingDomains"); 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" }); } } // 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; } approval.resolve({ approved: false, remember: false }); delete pendingApprovals[id]; } 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)) { // Switch to the requested network const target = networkByChainId(chainId); state.networkId = target.id; state.rpcUrl = target.defaultRpcUrl; state.blockscoutUrl = target.defaultBlockscoutUrl; await saveState(); 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 for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.type === "tx" || approval.type === "sign") { approval.resolve({ error: { code: 4001, message: "User rejected the request." }, }); } else { approval.resolve({ approved: false, remember: false }); } if (approval.windowId) { windowsApi.remove(approval.windowId, () => { if (runtime.lastError) { // window already closed } }); } delete pendingApprovals[id]; } 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. const BACKGROUND_REFRESH_INTERVAL = 60000; async function backgroundRefresh() { await loadState(); const now = Date.now(); if (now - (state.lastBalanceRefresh || 0) < BACKGROUND_REFRESH_INTERVAL) return; if (state.wallets.length === 0) return; await refreshBalances( state.wallets, state.rpcUrl, state.blockscoutUrl, state.trackedTokens, ); state.lastBalanceRefresh = now; await saveState(); } setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); // Fetch the phishing domain blocklist delta on startup and refresh every 24h. // The vendored blocklist is bundled at build time; this fetches only new entries. updatePhishingList(); startPeriodicRefresh(); // When approval window is closed without a response, treat as rejection if (windowsApi && windowsApi.onRemoved) { windowsApi.onRemoved.addListener((windowId) => { for (const [id, approval] of Object.entries(pendingApprovals)) { if (approval.windowId === windowId) { if (approval.type === "tx" || approval.type === "sign") { approval.resolve({ error: { code: 4001, message: "User rejected the request.", }, }); } else { approval.resolve({ approved: false, remember: false }); } delete pendingApprovals[id]; } } }); } // 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") { const approval = pendingApprovals[msg.id]; if (approval) { approval.resolve({ approved: msg.approved, remember: msg.remember, }); delete pendingApprovals[msg.id]; } resetPopupUrl(); return false; } if (msg.type === "AUTISTMASK_TX_RESPONSE") { const approval = pendingApprovals[msg.id]; if (!approval) return false; delete pendingApprovals[msg.id]; resetPopupUrl(); if (!msg.approved) { approval.resolve({ error: { code: 4001, message: "User rejected the request." }, }); return true; } (async () => { try { await loadState(); const activeAddress = await getActiveAddress(); let wallet, addrIndex; for (const w of state.wallets) { for (let i = 0; i < w.addresses.length; i++) { if (w.addresses[i].address === activeAddress) { wallet = w; addrIndex = i; break; } } if (wallet) break; } if (!wallet) throw new Error("Wallet not found"); // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage let decrypted = await decryptWithPassword( wallet.encryptedSecret, msg.password, ); const signer = getSignerForAddress( wallet, addrIndex, decrypted, ); // Best-effort: clear decrypted secret after use. // Note: JS strings are immutable; this nulls the reference but // the original string may persist in memory until GC. decrypted = null; const provider = getProvider(state.rpcUrl); const connected = signer.connect(provider); const tx = await connected.sendTransaction(approval.txParams); approval.resolve({ txHash: tx.hash }); sendResponse({ txHash: tx.hash }); } catch (e) { const errMsg = e.shortMessage || e.message; approval.resolve({ error: { message: errMsg }, }); sendResponse({ error: errMsg }); } })(); return true; } if (msg.type === "AUTISTMASK_SIGN_RESPONSE") { const approval = pendingApprovals[msg.id]; if (!approval) return false; delete pendingApprovals[msg.id]; resetPopupUrl(); if (!msg.approved) { approval.resolve({ error: { code: 4001, message: "User rejected the request." }, }); return true; } (async () => { try { await loadState(); const activeAddress = await getActiveAddress(); let wallet, addrIndex; for (const w of state.wallets) { for (let i = 0; i < w.addresses.length; i++) { if (w.addresses[i].address === activeAddress) { wallet = w; addrIndex = i; break; } } if (wallet) break; } if (!wallet) throw new Error("Wallet not found"); // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage let decrypted = await decryptWithPassword( wallet.encryptedSecret, msg.password, ); const signer = getSignerForAddress( wallet, addrIndex, decrypted, ); // Best-effort: clear decrypted secret after use. // Note: JS strings are immutable; this nulls the reference but // the original string may persist in memory until GC. decrypted = null; const sp = approval.signParams; let signature; if (sp.method === "personal_sign" || sp.method === "eth_sign") { signature = await signer.signMessage(getBytes(sp.message)); } else { // eth_signTypedData_v4 / eth_signTypedData const typedData = JSON.parse(sp.typedData); const { domain, types, message } = typedData; // ethers handles EIP712Domain internally delete types.EIP712Domain; signature = await signer.signTypedData( domain, types, message, ); } approval.resolve({ signature }); sendResponse({ signature }); } catch (e) { const errMsg = e.shortMessage || e.message; approval.resolve({ error: { message: errMsg }, }); sendResponse({ error: errMsg }); } })(); 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; } });