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.

+ +
Type
diff --git a/src/popup/views/addWallet.js b/src/popup/views/addWallet.js index 2e8e20e..eed7ac9 100644 --- a/src/popup/views/addWallet.js +++ b/src/popup/views/addWallet.js @@ -49,8 +49,8 @@ function init(ctx) { showFlash("Please choose a password."); return; } - if (pw.length < 8) { - showFlash("Password must be at least 8 characters."); + if (pw.length < 12) { + showFlash("Password must be at least 12 characters."); return; } if (pw !== pw2) { diff --git a/src/popup/views/approval.js b/src/popup/views/approval.js index 509557f..359b506 100644 --- a/src/popup/views/approval.js +++ b/src/popup/views/approval.js @@ -294,6 +294,18 @@ function showSignApproval(details) { } } + // Display danger warning for eth_sign (raw hash signing) + const warningEl = $("approve-sign-danger-warning"); + if (warningEl) { + if (sp.dangerWarning) { + warningEl.textContent = sp.dangerWarning; + warningEl.classList.remove("hidden"); + } else { + warningEl.textContent = ""; + warningEl.classList.add("hidden"); + } + } + $("approve-sign-password").value = ""; $("approve-sign-error").classList.add("hidden"); $("btn-approve-sign").disabled = false; @@ -373,6 +385,7 @@ function init(ctx) { type: "AUTISTMASK_TX_RESPONSE", id: approvalId, approved: true, + // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage password: password, }, (response) => { @@ -412,6 +425,7 @@ function init(ctx) { type: "AUTISTMASK_SIGN_RESPONSE", id: approvalId, approved: true, + // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage password: password, }, (response) => { diff --git a/src/popup/views/confirmTx.js b/src/popup/views/confirmTx.js index e7a4ca6..f11cf68 100644 --- a/src/popup/views/confirmTx.js +++ b/src/popup/views/confirmTx.js @@ -334,8 +334,13 @@ function init(ctx) { tx = await contract.transfer(pendingTx.to, amount); } + // 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. + decryptedSecret = null; txStatus.showWait(pendingTx, tx.hash); } catch (e) { + decryptedSecret = null; const hash = tx ? tx.hash : null; txStatus.showError(pendingTx, hash, e.shortMessage || e.message); } diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index de33fee..5d9daa8 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -85,7 +85,7 @@ function showFlash(msg, duration = 2000) { function balanceLine(symbol, amount, price, tokenId) { const qty = amount.toFixed(4); - const usd = price ? formatUsd(amount * price) : ""; + const usd = price ? formatUsd(amount * price) || " " : " "; const tokenAttr = tokenId ? ` data-token="${tokenId}"` : ""; const clickClass = tokenId ? " cursor-pointer hover:bg-hover balance-row" @@ -222,6 +222,41 @@ function formatAddressHtml(address, ensName, maxLen, title) { return `
${dot}${escapeHtml(displayAddr)}
`; } +function isoDate(timestamp) { + const d = new Date(timestamp * 1000); + const pad = (n) => String(n).padStart(2, "0"); + return ( + d.getFullYear() + + "-" + + pad(d.getMonth() + 1) + + "-" + + pad(d.getDate()) + + " " + + pad(d.getHours()) + + ":" + + pad(d.getMinutes()) + + ":" + + pad(d.getSeconds()) + ); +} + +function timeAgo(timestamp) { + const seconds = Math.floor(Date.now() / 1000 - timestamp); + if (seconds < 60) return seconds + " seconds ago"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) + return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago"; + const hours = Math.floor(minutes / 60); + if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago"; + const days = Math.floor(hours / 24); + if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago"; + const months = Math.floor(days / 30); + if (months < 12) + return months + " month" + (months !== 1 ? "s" : "") + " ago"; + const years = Math.floor(days / 365); + return years + " year" + (years !== 1 ? "s" : "") + " ago"; +} + module.exports = { $, showError, @@ -236,4 +271,6 @@ module.exports = { addressTitle, formatAddressHtml, truncateMiddle, + isoDate, + timeAgo, }; diff --git a/src/popup/views/home.js b/src/popup/views/home.js index 7fd2262..62b352b 100644 --- a/src/popup/views/home.js +++ b/src/popup/views/home.js @@ -3,6 +3,8 @@ const { showView, showFlash, balanceLinesForAddress, + isoDate, + timeAgo, addressDotHtml, escapeHtml, truncateMiddle, @@ -87,41 +89,6 @@ function renderActiveAddress() { } } -function timeAgo(timestamp) { - const seconds = Math.floor(Date.now() / 1000 - timestamp); - if (seconds < 60) return seconds + " seconds ago"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) - return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago"; - const hours = Math.floor(minutes / 60); - if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago"; - const days = Math.floor(hours / 24); - if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago"; - const months = Math.floor(days / 30); - if (months < 12) - return months + " month" + (months !== 1 ? "s" : "") + " ago"; - const years = Math.floor(days / 365); - return years + " year" + (years !== 1 ? "s" : "") + " ago"; -} - -function isoDate(timestamp) { - const d = new Date(timestamp * 1000); - const pad = (n) => String(n).padStart(2, "0"); - return ( - d.getFullYear() + - "-" + - pad(d.getMonth() + 1) + - "-" + - pad(d.getDate()) + - " " + - pad(d.getHours()) + - ":" + - pad(d.getMinutes()) + - ":" + - pad(d.getSeconds()) - ); -} - let homeTxs = []; function renderHomeTxList(ctx) { diff --git a/src/popup/views/importKey.js b/src/popup/views/importKey.js index 874e6cf..a3324a3 100644 --- a/src/popup/views/importKey.js +++ b/src/popup/views/importKey.js @@ -30,8 +30,8 @@ function init(ctx) { showFlash("Please choose a password."); return; } - if (pw.length < 8) { - showFlash("Password must be at least 8 characters."); + if (pw.length < 12) { + showFlash("Password must be at least 12 characters."); return; } if (pw !== pw2) { diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 0ec1dc3..8ecbfe3 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -8,6 +8,8 @@ const { addressDotHtml, addressTitle, escapeHtml, + isoDate, + timeAgo, } = require("./helpers"); const { state } = require("../../shared/state"); const makeBlockie = require("ethereum-blockies-base64"); @@ -21,41 +23,6 @@ const EXT_ICON = let ctx; -function isoDate(timestamp) { - const d = new Date(timestamp * 1000); - const pad = (n) => String(n).padStart(2, "0"); - return ( - d.getFullYear() + - "-" + - pad(d.getMonth() + 1) + - "-" + - pad(d.getDate()) + - " " + - pad(d.getHours()) + - ":" + - pad(d.getMinutes()) + - ":" + - pad(d.getSeconds()) - ); -} - -function timeAgo(timestamp) { - const seconds = Math.floor(Date.now() / 1000 - timestamp); - if (seconds < 60) return seconds + " seconds ago"; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) - return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago"; - const hours = Math.floor(minutes / 60); - if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago"; - const days = Math.floor(hours / 24); - if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago"; - const months = Math.floor(days / 30); - if (months < 12) - return months + " month" + (months !== 1 ? "s" : "") + " ago"; - const years = Math.floor(days / 365); - return years + " year" + (years !== 1 ? "s" : "") + " ago"; -} - function copyableHtml(text, extraClass) { const cls = "underline decoration-dashed cursor-pointer" +