Merge branch 'main' into fix/low-severity-security
All checks were successful
check / check (push) Successful in 22s

This commit is contained in:
2026-02-27 20:58:09 +01:00
11 changed files with 156 additions and 92 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -1015,6 +1015,17 @@
wants you to sign a message.
</p>
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div>
<div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div>

View File

@@ -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) {

View File

@@ -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) => {

View File

@@ -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);
}

View File

@@ -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) || "&nbsp;" : "&nbsp;";
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 `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
}
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,
};

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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" +