diff --git a/src/background/index.js b/src/background/index.js index 9d3b820..3b3a7da 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -15,29 +15,51 @@ const storageApi = : 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; -// Connected sites: { origin: [address, ...] } +// Connected sites (in-memory, non-persisted): { origin: true } const connectedSites = {}; +// Pending approval requests: { id: { origin, hostname, resolve } } +const pendingApprovals = {}; +let nextApprovalId = 1; + async function getState() { const result = await storageApi.get("autistmask"); - return result.autistmask || { wallets: [], rpcUrl: DEFAULT_RPC_URL }; + return ( + result.autistmask || { + wallets: [], + rpcUrl: DEFAULT_RPC_URL, + activeAddress: null, + allowedSites: [], + deniedSites: [], + } + ); } -async function getAccounts() { - const state = await getState(); - const accounts = []; - for (const wallet of state.wallets) { - for (const addr of wallet.addresses) { - accounts.push(addr.address); - } +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 accounts; + return null; } async function getRpcUrl() { - const state = await getState(); - return state.rpcUrl || DEFAULT_RPC_URL; + 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 @@ -60,6 +82,86 @@ async function proxyRpc(method, params) { return json.result; } +// Open an approval popup and return a promise that resolves with the user decision +function requestApproval(origin, hostname) { + return new Promise((resolve) => { + const id = nextApprovalId++; + pendingApprovals[id] = { origin, hostname, resolve }; + + const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id); + windowsApi.create( + { + url: popupUrl, + type: "popup", + width: 400, + height: 500, + }, + (win) => { + if (win) { + pendingApprovals[id].windowId = win.id; + } + }, + ); + }); +} + +// 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); + + // Check denied list + if (s.deniedSites.includes(hostname)) { + return { + error: { + code: 4001, + message: "User rejected the request.", + }, + }; + } + + // Check allowed list or in-memory connected + if (s.allowedSites.includes(hostname) || connectedSites[origin]) { + 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.includes(hostname)) { + state.allowedSites.push(hostname); + } + await saveState(); + } else { + connectedSites[origin] = true; + } + return { result: [activeAddress] }; + } else { + if (decision.remember) { + await loadState(); + if (!state.deniedSites.includes(hostname)) { + state.deniedSites.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", @@ -86,15 +188,20 @@ const PROXY_METHODS = [ ]; async function handleRpc(method, params, origin) { - // Methods that need wallet involvement - if (method === "eth_requestAccounts" || method === "eth_accounts") { - const accounts = await getAccounts(); - if (accounts.length === 0) { - return { error: { message: "No accounts available" } }; + // 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); + if (s.allowedSites.includes(hostname) || connectedSites[origin]) { + return { result: [activeAddress] }; } - // Auto-connect for now (approval flow is a future TODO) - connectedSites[origin] = accounts; - return { result: accounts }; + return { result: [] }; } if (method === "eth_chainId") { @@ -128,11 +235,8 @@ async function handleRpc(method, params, origin) { } if (method === "wallet_requestPermissions") { - const accounts = await getAccounts(); - if (accounts.length === 0) { - return { error: { message: "No accounts available" } }; - } - connectedSites[origin] = accounts; + const connResult = await handleConnectionRequest(origin); + if (connResult.error) return connResult; return { result: [ { @@ -140,7 +244,7 @@ async function handleRpc(method, params, origin) { caveats: [ { type: "restrictReturnedAccounts", - value: accounts, + value: connResult.result, }, ], }, @@ -149,8 +253,12 @@ async function handleRpc(method, params, origin) { } if (method === "wallet_getPermissions") { - const accounts = connectedSites[origin] || []; - if (accounts.length === 0) { + const s = await getState(); + const activeAddress = await getActiveAddress(); + const hostname = extractHostname(origin); + const isConnected = + s.allowedSites.includes(hostname) || connectedSites[origin]; + if (!isConnected || !activeAddress) { return { result: [] }; } return { @@ -160,7 +268,7 @@ async function handleRpc(method, params, origin) { caveats: [ { type: "restrictReturnedAccounts", - value: accounts, + value: [activeAddress], }, ], }, @@ -169,14 +277,12 @@ async function handleRpc(method, params, origin) { } if (method === "personal_sign" || method === "eth_sign") { - // TODO: implement signature approval flow return { error: { message: "Signing not yet implemented in AutistMask." }, }; } if (method === "eth_signTypedData_v4" || method === "eth_signTypedData") { - // TODO: implement typed data signing return { error: { message: @@ -186,8 +292,6 @@ async function handleRpc(method, params, origin) { } if (method === "eth_sendTransaction") { - // TODO: implement transaction signing approval flow - // For now, return an error directing the user to use the popup return { error: { message: @@ -209,6 +313,30 @@ async function handleRpc(method, params, origin) { return { error: { message: "Unsupported method: " + method } }; } +// Broadcast accountsChanged to all tabs +async function broadcastAccountsChanged() { + const activeAddress = await getActiveAddress(); + const accounts = activeAddress ? [activeAddress] : []; + tabsApi.query({}, (tabs) => { + for (const tab of tabs) { + tabsApi.sendMessage( + tab.id, + { + type: "AUTISTMASK_EVENT", + eventName: "accountsChanged", + data: accounts, + }, + () => { + // 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. @@ -227,14 +355,59 @@ async function backgroundRefresh() { setInterval(backgroundRefresh, BACKGROUND_REFRESH_INTERVAL); -// Listen for messages from content scripts -runtime.onMessage.addListener((msg, sender, sendResponse) => { - if (msg.type !== "AUTISTMASK_RPC") return; - - handleRpc(msg.method, msg.params, msg.origin).then((response) => { - sendResponse(response); +// 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) { + approval.resolve({ approved: false, remember: false }); + delete pendingApprovals[id]; + } + } }); +} - // Return true to indicate async response - return true; +// Listen for messages from content scripts and popup +runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (msg.type === "AUTISTMASK_RPC") { + handleRpc(msg.method, msg.params, msg.origin).then((response) => { + sendResponse(response); + }); + return true; + } + + if (msg.type === "AUTISTMASK_GET_APPROVAL") { + const approval = pendingApprovals[msg.id]; + if (approval) { + sendResponse({ + hostname: approval.hostname, + origin: approval.origin, + }); + } 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]; + } + return false; + } + + if (msg.type === "AUTISTMASK_ACTIVE_CHANGED") { + broadcastAccountsChanged(); + return false; + } + + if (msg.type === "AUTISTMASK_REMOVE_SITE") { + // Popup already saved state; nothing else needed + return false; + } }); diff --git a/src/popup/index.html b/src/popup/index.html index 934bdd2..503e068 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -520,6 +520,22 @@ Save + +
+

Allowed Sites

+

+ Sites that can connect to your wallet without asking. +

+
+
+ +
+

Denied Sites

+

+ Sites that are blocked from connecting to your wallet. +

+
+
@@ -559,18 +575,28 @@