Files
AutistMask/src/background/index.js
clawbot f2e44ff4ab
All checks were successful
check / check (push) Successful in 22s
fix: use windows.create() for tx/sign approval popups instead of openPopup()
action.openPopup() is unreliable when called from the background script
during an async message handler — it requires a user gesture context.
tx and sign approvals are triggered programmatically by dApp RPC calls,
not by user clicking the toolbar icon, so openPopup() fails silently.

Use windows.create() directly for tx/sign approvals, matching the
standard extension pattern (used by MetaMask and others). Site-connection
approvals retain openPopup() since they can fall back to the user
clicking the toolbar icon.

Also updates popup window dimensions to 360x600 to match the standard
popup viewport specified in README.

Closes #4
2026-02-27 12:57:55 -08:00

810 lines
27 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 {
ETHEREUM_MAINNET_CHAIN_ID,
DEFAULT_RPC_URL,
} = require("../shared/constants");
const { getBytes } = require("ethers");
const { state, loadState, saveState } = 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 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: ETHEREUM_MAINNET_CHAIN_ID };
}
if (method === "net_version") {
return { result: "1" };
}
if (method === "wallet_switchEthereumChain") {
const chainId = params?.[0]?.chainId;
if (chainId === ETHEREUM_MAINNET_CHAIN_ID) {
return { result: null };
}
return {
error: {
code: 4902,
message: "AutistMask only supports Ethereum mainnet.",
},
};
}
if (method === "wallet_addEthereumChain") {
return {
error: {
code: 4902,
message: "AutistMask only supports Ethereum mainnet.",
},
};
}
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 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);
// 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;
}
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;
}
});