Files
AutistMask/src/popup/views/approval.js
clawbot 0a1786b406
All checks were successful
check / check (push) Successful in 30s
harden: verify all approval fields and make failed signing retryable (closes #174)
approvalVerify now compares every field of the signed artifact against the
approval, not a subset. Transaction types are allowlisted to 0/1/2 and any
field the module does not check is refused outright, so a future transaction
type cannot smuggle consequential fields past verification -- an EIP-7702
type-4 artifact that delegates the signer's own EOA while matching every
displayed field was accepted before this change. The serialized bytes handed
to broadcastTransaction are compared against the parsed artifact, so the
guarantee covers the bytes that actually go to the node.

Signing failures in the popup are retryable again. To make that safe, an
approval is claimed synchronously before the first await and every path that
resolves or removes one goes through a single chokepoint that refuses a claimed
approval. Without it, closing the approval window, switching the active address
or a late reject would report "User rejected the request." to the dApp while
the broadcast completed -- the user then redoes the transfer at a fresh nonce
and it sends twice.

Failure copy distinguishes the stage reached, so a user is never told to start
again from the site when the first attempt may already have reached the network.
2026-08-12 11:21:02 +02:00

735 lines
25 KiB
JavaScript

const {
$,
addressTitle,
escapeHtml,
showView,
showError,
hideError,
renderAddressHtml,
attachCopyHandlers,
onViewLeave,
} = require("./helpers");
const { state, saveState, currentNetwork } = require("../../shared/state");
const {
formatEther,
formatUnits,
getBytes,
Interface,
toUtf8String,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { decryptWithPassword } = require("../../shared/vault");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { getProvider } = require("../../shared/balances");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const erc20Iface = new Interface(ERC20_ABI);
function approvalAddressHtml(address) {
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
}
function formatTxValue(val) {
const parts = val.split(".");
if (parts.length === 1) return val + ".0000";
const dec = (parts[1] + "0000").slice(0, 4);
return parts[0] + "." + dec;
}
function tokenLabel(address) {
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
return t ? t.symbol : null;
}
// Try to decode calldata using known ABIs.
// Returns { name, description, details } or null.
function decodeCalldata(data, toAddress) {
if (!data || data === "0x" || data.length < 10) return null;
// Try ERC-20 (approve / transfer)
try {
const parsed = erc20Iface.parseTransaction({ data });
if (parsed) {
const token = TOKEN_BY_ADDRESS.get(toAddress.toLowerCase());
const tokenSymbol = token ? token.symbol : null;
const tokenDecimals = token ? token.decimals : 18;
const contractLabel = tokenSymbol
? tokenSymbol + " (" + toAddress + ")"
: toAddress;
if (parsed.name === "approve") {
const spender = parsed.args[0];
const rawAmount = parsed.args[1];
const maxUint = BigInt(
"0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
);
const isUnlimited = rawAmount === maxUint;
const amountRaw = isUnlimited
? "Unlimited"
: formatTxValue(formatUnits(rawAmount, tokenDecimals));
const amountStr = isUnlimited
? "Unlimited"
: amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Approval",
description: tokenSymbol
? "Approve spending of your " + tokenSymbol
: "Approve spending of an ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{
label: "Spender",
value: spender,
address: spender,
},
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
],
};
}
if (parsed.name === "transfer") {
const to = parsed.args[0];
const rawAmount = parsed.args[1];
const amountRaw = formatTxValue(
formatUnits(rawAmount, tokenDecimals),
);
const amountStr =
amountRaw + (tokenSymbol ? " " + tokenSymbol : "");
return {
name: "Token Transfer",
description: tokenSymbol
? "Transfer " + tokenSymbol
: "Transfer ERC-20 token",
details: [
{
label: "Token",
value: contractLabel,
address: toAddress,
isToken: true,
},
{ label: "Recipient", value: to, address: to },
{
label: "Amount",
value: amountStr,
rawValue: amountRaw,
},
],
};
}
}
} catch {
// Not ERC-20 — fall through
}
// Try Uniswap Universal Router
const routerResult = uniswap.decode(data, toAddress);
if (routerResult) return routerResult;
return null;
}
function showPhishingWarning(elementId, isPhishing) {
const el = $(elementId);
if (!el) return;
// The background script performs the authoritative phishing domain check
// and passes the result via the isPhishingDomain flag.
if (isPhishing) {
el.classList.remove("hidden");
} else {
el.classList.add("hidden");
}
}
function showTxApproval(details) {
showPhishingWarning(
"approve-tx-phishing-warning",
details.isPhishingDomain,
);
pendingTxParams = details.txParams;
const toAddr = details.txParams.to;
const token = toAddr ? TOKEN_BY_ADDRESS.get(toAddr.toLowerCase()) : null;
const ethValue = formatEther(details.txParams.value || "0");
// Build txInfo for status screens
pendingTxDetails = {
from: state.activeAddress,
to: toAddr || "",
amount: formatTxValue(ethValue),
token: "ETH",
tokenSymbol: token ? token.symbol : null,
};
// If this is an ERC-20 call, try to extract the real recipient and amount
const decoded = decodeCalldata(details.txParams.data, toAddr || "");
if (decoded && decoded.details) {
let decodedTokenAddr = null;
let decodedTokenSymbol = null;
for (const d of decoded.details) {
if (d.label === "Recipient" && d.address) {
pendingTxDetails.to = d.address;
}
if (d.label === "Amount") {
pendingTxDetails.amount = d.rawValue || d.value;
}
if (d.label === "Token In" && d.isToken && d.address) {
const t = TOKEN_BY_ADDRESS.get(d.address.toLowerCase());
if (t) {
decodedTokenAddr = d.address;
decodedTokenSymbol = t.symbol;
}
}
}
if (token) {
pendingTxDetails.token = toAddr;
pendingTxDetails.tokenSymbol = token.symbol;
} else if (decodedTokenAddr) {
pendingTxDetails.token = decodedTokenAddr;
pendingTxDetails.tokenSymbol = decodedTokenSymbol;
}
}
// Carry decoded calldata info through to success/error views
if (decoded) {
pendingTxDetails.decoded = {
name: decoded.name,
description: decoded.description,
details: decoded.details,
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(state.activeAddress);
// Show token symbol next to contract address if known
const symbol = toAddr ? tokenLabel(toAddr) : null;
if (toAddr) {
let toHtml = "";
if (symbol) {
toHtml += `<div class="font-bold mb-1">${escapeHtml(symbol)}</div>`;
}
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
}
const ethValueFormatted = formatTxValue(
formatEther(details.txParams.value || "0"),
);
const ethPrice = getPrice("ETH");
const ethUsd = ethPrice ? parseFloat(ethValueFormatted) * ethPrice : null;
const usdStr = formatUsd(ethUsd);
$("approve-tx-value").textContent =
ethValueFormatted + " ETH" + (usdStr ? " (" + usdStr + ")" : "");
// Decode calldata (reuse decoded from above)
const decodedEl = $("approve-tx-decoded");
if (decoded) {
$("approve-tx-action").textContent = decoded.name;
let detailsHtml = "";
if (decoded.description) {
detailsHtml += `<div class="mb-2">${escapeHtml(decoded.description)}</div>`;
}
for (const d of decoded.details) {
detailsHtml += `<div class="mb-2">`;
detailsHtml += `<div class="text-muted">${escapeHtml(d.label)}</div>`;
if (d.address) {
if (d.isToken) {
detailsHtml += `<div class="font-bold">${escapeHtml(tokenLabel(d.address) || "Unknown token")}</div>`;
}
detailsHtml += approvalAddressHtml(d.address);
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
detailsHtml += `</div>`;
}
$("approve-tx-decoded-details").innerHTML = detailsHtml;
decodedEl.classList.remove("hidden");
} else {
decodedEl.classList.add("hidden");
}
// Always show raw data when present
if (details.txParams.data && details.txParams.data !== "0x") {
$("approve-tx-data").textContent = details.txParams.data;
$("approve-tx-data-section").classList.remove("hidden");
} else {
$("approve-tx-data-section").classList.add("hidden");
}
$("approve-tx-password").value = "";
hideError("approve-tx-error");
showView("approve-tx");
attachCopyHandlers("view-approve-tx");
gateOnWalletDefect("approve-tx-error", "btn-approve-tx");
}
function decodeHexMessage(hex) {
try {
const bytes = Uint8Array.from(
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
} catch {
return null;
}
}
function formatTypedDataHtml(jsonStr) {
try {
const data = JSON.parse(jsonStr);
let html = "";
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
}
html += `</div>`;
}
if (data.primaryType) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
}
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
const display =
typeof val === "object" ? JSON.stringify(val) : String(val);
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
}
html += `</div>`;
}
return html;
} catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
}
}
function showSignApproval(details) {
showPhishingWarning(
"approve-sign-phishing-warning",
details.isPhishingDomain,
);
const sp = details.signParams;
pendingSignParams = sp;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(sp.from);
const isTyped =
sp.method === "eth_signTypedData_v4" ||
sp.method === "eth_signTypedData";
$("approve-sign-type").textContent = isTyped
? "Typed data (EIP-712)"
: "Personal message";
if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else {
const decoded = decodeHexMessage(sp.message);
if (decoded !== null) {
$("approve-sign-message").textContent = decoded;
} else {
$("approve-sign-message").textContent = sp.message;
}
}
// 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.style.visibility = "visible";
} else {
warningEl.textContent = "";
warningEl.style.visibility = "hidden";
}
}
$("approve-sign-password").value = "";
hideError("approve-sign-error");
$("btn-approve-sign").disabled = false;
$("btn-approve-sign").classList.remove("text-muted");
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
gateOnWalletDefect("approve-sign-error", "btn-approve-sign");
}
function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
if (!details) {
window.close();
return;
}
if (details.type === "tx") {
showTxApproval(details);
return;
}
if (details.type === "sign") {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
});
}
let approvalId = null;
let pendingTxDetails = null;
// The exact parameters shown to the user, kept so the popup signs what it
// displayed rather than re-fetching anything at approval time. Both are
// repopulated by show() when the popup is closed and reopened.
let pendingTxParams = null;
let pendingSignParams = null;
// Approve buttons stay disabled and muted while the popup derives the key and
// signs, which is slow enough (Argon2id) that a double click is likely.
function setTxButtonBusy(busy) {
$("btn-approve-tx").disabled = busy;
$("btn-approve-tx").classList.toggle("text-muted", busy);
}
function setSignButtonBusy(busy) {
$("btn-approve-sign").disabled = busy;
$("btn-approve-sign").classList.toggle("text-muted", busy);
}
// Say so on the approval screen itself, and disable the approve button, when
// the active address belongs to a wallet whose keys cannot be derived. Without
// this the screen would take a password and fail after deriving it. Reject
// stays available; the wallet is not touched. Returns true when it gated.
function gateOnWalletDefect(errorId, buttonId) {
const active = findActiveWallet();
const defect = active ? walletDefect(active.wallet) : null;
if (!defect) return false;
showError(errorId, defect.shortMessage);
$(buttonId).disabled = true;
$(buttonId).classList.add("text-muted");
return true;
}
// Locate the wallet and the address index owning the currently active
// address. Returns null when no wallet holds it.
function findActiveWallet() {
for (const wallet of state.wallets) {
for (let i = 0; i < wallet.addresses.length; i++) {
if (wallet.addresses[i].address === state.activeAddress) {
return { wallet, addrIndex: i };
}
}
}
return null;
}
// Drop the password from the DOM when either approval screen is left. The
// approval window navigates on after a signature — approve-tx goes to the
// wait screen — and the password must not sit in the hidden view for the
// life of that window.
function clearTxPassword() {
$("approve-tx-password").value = "";
hideError("approve-tx-error");
}
function clearSignPassword() {
$("approve-sign-password").value = "";
hideError("approve-sign-error");
}
function init(ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
$("approve-remember").addEventListener("change", async () => {
state.rememberSiteChoice = $("approve-remember").checked;
await saveState();
});
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
remember,
});
window.close();
});
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
remember,
});
window.close();
});
$("btn-approve-tx").addEventListener("click", async () => {
let password = $("approve-tx-password").value;
if (!password) {
showError("approve-tx-error", "Please enter your password.");
return;
}
hideError("approve-tx-error");
setTxButtonBusy(true);
const active = findActiveWallet();
if (!active) {
password = null;
showError(
"approve-tx-error",
"No wallet was found for the active address.",
);
setTxButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-tx-error", defect.shortMessage);
setTxButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signed transaction does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-tx-error",
"That password is incorrect. Please try again.",
);
setTxButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: true,
};
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
decryptedSecret,
);
const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider);
// This is the sequence ethers' own sendTransaction() runs
// internally, so nonce, gas, fee and chain id population are
// identical to when the background did the signing.
const populated =
await connected.populateTransaction(pendingTxParams);
delete populated.from;
payload.rawSignedTx = await connected.signTransaction(populated);
} catch (e) {
payload.error =
e.shortMessage || e.message || "Transaction signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
});
$("btn-reject-tx").addEventListener("click", () => {
runtime.sendMessage({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
});
window.close();
});
$("btn-approve-sign").addEventListener("click", async () => {
let password = $("approve-sign-password").value;
if (!password) {
showError("approve-sign-error", "Please enter your password.");
return;
}
hideError("approve-sign-error");
setSignButtonBusy(true);
const active = findActiveWallet();
if (!active) {
password = null;
showError(
"approve-sign-error",
"No wallet was found for the active address.",
);
setSignButtonBusy(false);
return;
}
const defect = walletDefect(active.wallet);
if (defect) {
password = null;
showError("approve-sign-error", defect.shortMessage);
setSignButtonBusy(false);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
try {
decryptedSecret = await decryptWithPassword(
active.wallet.encryptedSecret,
password,
);
} catch {
showError(
"approve-sign-error",
"That password is incorrect. Please try again.",
);
setSignButtonBusy(false);
return;
} finally {
// Best-effort: drop the password as soon as the key derivation
// is done. Note that JS strings are immutable; this clears the
// reference but the original string may persist until GC.
password = null;
}
const payload = {
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: true,
};
try {
const signer = getSignerForAddress(
active.wallet,
active.addrIndex,
decryptedSecret,
);
const sp = pendingSignParams;
if (sp.method === "personal_sign" || sp.method === "eth_sign") {
payload.signature = await signer.signMessage(
getBytes(sp.message),
);
} else {
// eth_signTypedData_v4 / eth_signTypedData
const typedData = JSON.parse(sp.typedData);
const { domain, types, message } = typedData;
// ethers handles EIP712Domain internally
delete types.EIP712Domain;
payload.signature = await signer.signTypedData(
domain,
types,
message,
);
}
} catch (e) {
payload.error = e.shortMessage || e.message || "Signing failed.";
} finally {
// Best-effort: clear the decrypted secret after use, with the
// same immutability caveat as the password above.
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.signature) {
window.close();
return;
}
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
});
$("btn-reject-sign").addEventListener("click", () => {
runtime.sendMessage({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,
});
window.close();
});
}
module.exports = { init, show, decodeCalldata };