Files
AutistMask/src/background/index.js
sneak f37d52e04a
All checks were successful
check / check (push) Successful in 28s
security: decrypt and sign dApp approvals in the popup (closes #157)
The dApp transaction and signature approval paths sent the user's plaintext
password to the background over runtime.sendMessage and decrypted there. Both
now decrypt in the popup, where the password is typed, and put only the signed
artifact on the wire: the raw signed transaction, or the signature. Neither the
password, the recovery phrase, the xprv nor the private key crosses the
messaging boundary any more. This matches what the popup-side eth_sendTransaction
path in confirmTx.js already did.

The popup runs the same sequence ethers' own sendTransaction() runs internally
(populateTransaction, then signTransaction), so nonce, gas, fee and chain id
population are unchanged. The background keeps broadcast and approval
resolution, and when the popup cannot produce an artifact it reports the error
over the same message so the requesting page still gets a failure rather than
hanging.

Moving the secret out of the background must not turn the background into a
blind relay, so it re-derives the signer from the artifact and checks it
against the approval it is holding before acting: shared/approvalVerify.js
asserts that a raw transaction is the approved transaction signed by the
approved address, and that a signature covers the approved payload and recovers
to the approved address.

A wrong password is now caught in the popup before anything is sent, so it
fails with an inline full-sentence error and leaves the pending approval alive
to retry; previously it reached the background and destroyed the approval.
Rejection still resolves with EIP-1193 code 4001, and approvals still survive
popup close and reopen.

Removes the four standing TODO(security) markers, now that the flaw is gone.
2026-08-10 13:55:29 +00:00

814 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 { 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 } = require("../shared/log");
const { verifySignedTx, verifySignature } = require("../shared/approvalVerify");
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)) {
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
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;
}
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
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.
verifySignedTx(
msg.rawSignedTx,
approval.txParams,
activeAddress,
);
const provider = getProvider(state.rpcUrl);
const tx = await provider.broadcastTransaction(msg.rawSignedTx);
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;
}
// The popup signs; it reports back here when it could not. Fail the
// request the same way this handler used to when it did the signing.
if (msg.error) {
approval.resolve({ error: { message: msg.error } });
sendResponse({ error: msg.error });
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);
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;
}
});