diff --git a/README.md b/README.md index 8b89104..0030313 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform. Everything you need, nothing you don't. We import as few libraries as possible, don't implement any crypto, and don't send user-specific data anywhere but a (user-configurable) Ethereum RPC endpoint (which defaults to a public node). The -extension contacts precisely two external services: the configured RPC node for -blockchain interactions, and a public CoinDesk API (no API key) to get realtime -price information. +extension contacts exactly three external services: the configured RPC node for +blockchain interactions, a public CoinDesk API (no API key) for realtime price +information, and a Blockscout block-explorer API for transaction history and +token balances. All three endpoints are user-configurable. In the extension is a hardcoded list of the top ERC20 contract addresses. You can add any ERC20 contract by contract address if you wish, but the hardcoded @@ -534,7 +535,7 @@ transitions. ### External Services AutistMask is not a fully self-contained offline tool. It necessarily -communicates with two external services to function as a wallet: +communicates with three external services to function as a wallet: - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), @@ -543,11 +544,24 @@ communicates with two external services to function as a wallet: receipts. The default endpoint is a public RPC (configurable by the user to any endpoint they prefer, including a local node). By default the extension talks to `https://ethereum-rpc.publicnode.com`. + - **Data sent**: Ethereum addresses, transaction data, contract call + parameters. The RPC endpoint can see all on-chain queries and submitted + transactions. - **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for displaying fiat values. The price is cached for 5 minutes to avoid excessive requests. No API key required. No user data is sent — only a list of token symbols. Note that CoinDesk will receive your client IP. + - **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No + addresses or user-specific data. + +- **Blockscout block-explorer API**: Used to fetch transaction history (normal + transactions and ERC-20 token transfers), ERC-20 token balances, and token + holder counts (for spam filtering). The default endpoint is + `https://eth.blockscout.com/api/v2` (configurable by the user in Settings). + - **Data sent**: Ethereum addresses. Blockscout receives the user's + addresses to query their transaction history and token balances. No + private keys, passwords, or signing operations are sent. What the extension does NOT do: @@ -557,9 +571,10 @@ What the extension does NOT do: - No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No backend servers operated by the developer -The user's RPC endpoint and the CoinDesk price API are the only external -services. Users who want maximum privacy can point the RPC at their own node -(price fetching can be disabled in a future version). +These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are +the only external services. All three endpoints are user-configurable. Users who +want maximum privacy can point the RPC and Blockscout URLs at their own +self-hosted instances (price fetching can be disabled in a future version). ### Dependencies diff --git a/RULES.md b/RULES.md index 6c4e4a8..9644a38 100644 --- a/RULES.md +++ b/RULES.md @@ -1,3 +1,8 @@ +> **⚠️ THIS FILE MUST NEVER BE MODIFIED BY AGENTS.** RULES.md is maintained +> exclusively by the project owner. AI agents, bots, and automated tools must +> treat this file as read-only. If an audit finds a divergence between the code +> and this file, the code must be changed to match — never the other way around. + # AutistMask Rules Checklist This file is derived from README.md and REPO_POLICIES.md for use as an audit @@ -17,8 +22,8 @@ contradicts either, the originals govern. ## External Communication -- [ ] Extension contacts exactly two external services: configured RPC endpoint - and CoinDesk price API +- [ ] Extension contacts exactly three external services: configured RPC + endpoint, CoinDesk price API, and Blockscout block-explorer API - [ ] No analytics, telemetry, or tracking - [ ] No user-specific data sent except to the configured RPC endpoint - [ ] No Infura/Alchemy hard dependency diff --git a/src/background/index.js b/src/background/index.js index 4d81256..db30f43 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -30,7 +30,6 @@ const connectedSites = {}; // Pending approval requests: { id: { origin, hostname, resolve } } const pendingApprovals = {}; -let nextApprovalId = 1; async function getState() { const result = await storageApi.get("autistmask"); @@ -127,7 +126,7 @@ function openApprovalWindow(id) { // Prefers the browser-action popup (anchored to toolbar, no macOS Space switch). function requestApproval(origin, hostname) { return new Promise((resolve) => { - const id = nextApprovalId++; + const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, resolve }; if (actionApi && typeof actionApi.openPopup === "function") { @@ -152,7 +151,7 @@ function requestApproval(origin, hostname) { // Uses the toolbar popup only — no fallback window. function requestTxApproval(origin, hostname, txParams) { return new Promise((resolve) => { - const id = nextApprovalId++; + const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, @@ -184,7 +183,7 @@ function requestTxApproval(origin, hostname, txParams) { // popup URL is still set, so the user can click the toolbar icon to respond. function requestSignApproval(origin, hostname, signParams) { return new Promise((resolve) => { - const id = nextApprovalId++; + const id = crypto.randomUUID(); pendingApprovals[id] = { origin, hostname, @@ -216,7 +215,7 @@ function requestSignApproval(origin, hostname, signParams) { // popups naturally close on focus loss and the user can reopen them. runtime.onConnect.addListener((port) => { if (port.name.startsWith("approval:")) { - const id = parseInt(port.name.split(":")[1], 10); + const id = port.name.split(":")[1]; port.onDisconnect.addListener(() => { const approval = pendingApprovals[id]; if (approval) { @@ -442,6 +441,13 @@ async function handleRpc(method, params, origin) { ? { 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, @@ -611,12 +617,39 @@ if (windowsApi && windowsApi.onRemoved) { // 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) => { + // 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) { @@ -681,7 +714,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (wallet) break; } if (!wallet) throw new Error("Wallet not found"); - const decrypted = await decryptWithPassword( + // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage + let decrypted = await decryptWithPassword( wallet.encryptedSecret, msg.password, ); @@ -690,6 +724,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { 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); @@ -735,7 +773,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (wallet) break; } if (!wallet) throw new Error("Wallet not found"); - const decrypted = await decryptWithPassword( + // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage + let decrypted = await decryptWithPassword( wallet.encryptedSecret, msg.password, ); @@ -744,6 +783,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { 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; diff --git a/src/popup/index.html b/src/popup/index.html index 653093b..6922bcd 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -1015,6 +1015,17 @@ wants you to sign a message.
+ +