Compare commits
1
Commits
eaaf1593c0
...
786613ce2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
786613ce2d |
@@ -911,9 +911,10 @@ approximation but a different number — 1,000 units of a 6-decimal token
|
||||
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
|
||||
to state what is being authorized. Both amount paths of that screen take this
|
||||
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
|
||||
unbounded allowance or permit needs no scale to describe and is still shown as
|
||||
`Unlimited`.
|
||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
||||
token permission warning on the signature screen takes the same rule for its
|
||||
amounts. An unbounded allowance or permit needs no scale to describe and is
|
||||
still shown as `Unlimited`.
|
||||
|
||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||
sources are read as authoritative, so a value written into one of them cannot be
|
||||
@@ -1801,10 +1802,21 @@ view would leave a wallet one click from deletion.
|
||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||
- From: color dot + full address + etherscan link
|
||||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||
message fields (EIP-712 typed data)
|
||||
message fields (EIP-712 typed data). The primary type shown is the one
|
||||
ethers signs, derived from the typed data's `types`, not the type the site
|
||||
states.
|
||||
- Token permission warning, at the top of the message (typed data whose
|
||||
primary type is EIP-2612's `Permit` or one of Permit2's signature types):
|
||||
"⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens
|
||||
listed here from your address, without asking you again.", then the
|
||||
spender's full address and, for each token, its symbol, full address and
|
||||
amount (`Unlimited` for the largest amount the field holds)
|
||||
- Password input and an error line
|
||||
- "Sign" / "Reject" buttons
|
||||
- **Transitions**:
|
||||
- Typed data that states no primary type, or one other than the type it
|
||||
would be signed as, or that cannot be read → shown with the error line
|
||||
saying so and "Sign" disabled; only "Reject" remains
|
||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||
signature)
|
||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||
|
||||
@@ -45,6 +45,22 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-03: The typed-data signing screen warns for a token permission, and
|
||||
names the primary type ethers signs
|
||||
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
|
||||
signature lets its spender take tokens from the signer's address, and the
|
||||
screen listed it as plain key/value lines, exactly like a sign-in message. For
|
||||
EIP-2612's `Permit` (and DAI's older permit of the same name) and Permit2's
|
||||
six signature types, `src/popup/views/approval.js` now shows a red warning at
|
||||
the top of the message naming the spender and each token and amount, with
|
||||
`Unlimited` for the largest amount the field holds and the existing
|
||||
unknown-scale wording otherwise. The screen printed the page's `primaryType`,
|
||||
but ethers signs the type it derives from `types`; the screen now shows the
|
||||
derived type, and typed data whose stated type is missing or differs, or that
|
||||
cannot be read, is shown with an error line and Sign disabled, and is refused
|
||||
again where signing starts. The warning names no deadline or expiry: those
|
||||
fields mean different things across the shapes, and a date could read as the
|
||||
permission ending when it does not.
|
||||
- 2026-09-21: The network fee a transaction can commit is bounded by the product
|
||||
of the gas limit and the fee per gas, not by each field alone, and the
|
||||
wallet's own send is bounded the same way
|
||||
|
||||
+170
-6
@@ -14,9 +14,13 @@ const { networkByChainId } = require("../../shared/networks");
|
||||
const {
|
||||
formatEther,
|
||||
formatUnits,
|
||||
getAddress,
|
||||
getBigInt,
|
||||
getBytes,
|
||||
Interface,
|
||||
MaxUint256,
|
||||
toUtf8String,
|
||||
TypedDataEncoder,
|
||||
} = require("ethers");
|
||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||
const { ERC20_ABI } = require("../../shared/constants");
|
||||
@@ -391,10 +395,149 @@ function decodeHexMessage(hex) {
|
||||
}
|
||||
}
|
||||
|
||||
// The type ethers will sign typed data as. ethers does not read the page's
|
||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||
// refers to. Throws when the types name no such single struct, which ethers
|
||||
// would refuse to sign as well.
|
||||
function signedPrimaryType(types) {
|
||||
const structs = { ...types };
|
||||
// ethers derives EIP712Domain itself and rejects it as an input.
|
||||
delete structs.EIP712Domain;
|
||||
return TypedDataEncoder.getPrimaryType(structs);
|
||||
}
|
||||
|
||||
// Why a signature request cannot be signed, as a sentence for the error line,
|
||||
// or null when it can. Only typed data is refused: the `primaryType` the page
|
||||
// states has to be the type ethers will sign, or this screen would name one
|
||||
// message while another is signed.
|
||||
function typedDataRefusal(sp) {
|
||||
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
|
||||
let data;
|
||||
let signed;
|
||||
try {
|
||||
data = JSON.parse(sp.typedData);
|
||||
signed = signedPrimaryType(data.types);
|
||||
} catch {
|
||||
return "This typed data cannot be read, so it cannot be signed.";
|
||||
}
|
||||
if (!data.primaryType) {
|
||||
return "This typed data does not name its primary type, so it cannot be signed.";
|
||||
}
|
||||
if (data.primaryType !== signed) {
|
||||
return (
|
||||
"This typed data names its primary type as " +
|
||||
data.primaryType +
|
||||
", but it would be signed as " +
|
||||
signed +
|
||||
", so it cannot be signed."
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
|
||||
// it as an allowance that is never used up.
|
||||
const MAX_UINT160 = (1n << 160n) - 1n;
|
||||
|
||||
// A warning for typed data that lets a spender take your tokens, naming the
|
||||
// spender and each token and amount, or "" for any other typed data. These
|
||||
// signatures are how most wallet drains are done, and as plain key/value
|
||||
// lines they read exactly like a sign-in message. The shapes are EIP-2612's
|
||||
// `Permit` and Permit2's six signature types, recognised by the type ethers
|
||||
// signs: a token contract checks the type's exact name, so a renamed copy of
|
||||
// one of these would not be honoured.
|
||||
function permitWarningHtml(primaryType, domain, message) {
|
||||
// Each entry is a token, the amount, and the largest value its amount
|
||||
// field holds, which the contract treats as unlimited.
|
||||
let grants;
|
||||
switch (primaryType) {
|
||||
case "Permit": {
|
||||
// EIP-2612 states a `value`. DAI's older permit, signed under the
|
||||
// same name, states only `allowed`: unlimited, or nothing.
|
||||
let amount = message.value;
|
||||
if ("allowed" in message) {
|
||||
amount = message.allowed ? MaxUint256 : 0n;
|
||||
}
|
||||
grants = [
|
||||
{ token: domain.verifyingContract, amount, max: MaxUint256 },
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "PermitSingle":
|
||||
grants = [
|
||||
{
|
||||
token: message.details.token,
|
||||
amount: message.details.amount,
|
||||
max: MAX_UINT160,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "PermitBatch":
|
||||
grants = message.details.map((d) => ({
|
||||
token: d.token,
|
||||
amount: d.amount,
|
||||
max: MAX_UINT160,
|
||||
}));
|
||||
break;
|
||||
case "PermitTransferFrom":
|
||||
case "PermitWitnessTransferFrom":
|
||||
grants = [
|
||||
{
|
||||
token: message.permitted.token,
|
||||
amount: message.permitted.amount,
|
||||
max: MaxUint256,
|
||||
},
|
||||
];
|
||||
break;
|
||||
case "PermitBatchTransferFrom":
|
||||
case "PermitBatchWitnessTransferFrom":
|
||||
grants = message.permitted.map((p) => ({
|
||||
token: p.token,
|
||||
amount: p.amount,
|
||||
max: MaxUint256,
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
const sources = {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
};
|
||||
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
|
||||
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
|
||||
html += `<div class="mb-2"><div>Spender</div>${approvalAddressHtml(getAddress(message.spender))}</div>`;
|
||||
for (const grant of grants) {
|
||||
const token = getAddress(grant.token);
|
||||
const amount = getBigInt(grant.amount);
|
||||
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
|
||||
// or base units when nothing knows the token's scale.
|
||||
const amountText =
|
||||
amount === grant.max
|
||||
? "Unlimited"
|
||||
: tokenAmountText(
|
||||
amount,
|
||||
resolveTokenDecimals(token, sources),
|
||||
tokenLabel(token),
|
||||
).display;
|
||||
html += `<div class="mb-2"><div>Token</div>`;
|
||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
||||
html += `${approvalAddressHtml(token)}</div>`;
|
||||
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
// The typed data as the screen shows it. The primary type shown is the one
|
||||
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
|
||||
// from differing on anything that can be signed.
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
let html = "";
|
||||
const primaryType = signedPrimaryType(data.types);
|
||||
let html = permitWarningHtml(primaryType, data.domain, data.message);
|
||||
|
||||
if (data.domain) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
||||
@@ -404,10 +547,8 @@ function formatTypedDataHtml(jsonStr) {
|
||||
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>`;
|
||||
}
|
||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
||||
|
||||
if (data.message) {
|
||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||
@@ -477,6 +618,13 @@ function showSignApproval(details) {
|
||||
|
||||
showView("approve-sign");
|
||||
attachCopyHandlers("view-approve-sign");
|
||||
const refusal = typedDataRefusal(sp);
|
||||
if (refusal) {
|
||||
showError("approve-sign-error", refusal);
|
||||
$("btn-approve-sign").disabled = true;
|
||||
$("btn-approve-sign").classList.add("text-muted");
|
||||
return;
|
||||
}
|
||||
gateOnWalletDefect(
|
||||
"approve-sign-error",
|
||||
"btn-approve-sign",
|
||||
@@ -782,6 +930,16 @@ function init(_ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Checked again where the signing starts, not only when the screen
|
||||
// was drawn, and the button stays disabled: this request can never be
|
||||
// signed.
|
||||
const refusal = typedDataRefusal(pendingSignParams);
|
||||
if (refusal) {
|
||||
password = null;
|
||||
showError("approve-sign-error", refusal);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt here, in the popup. The password must never cross the
|
||||
// extension messaging boundary; only the signature does.
|
||||
let decryptedSecret;
|
||||
@@ -873,4 +1031,10 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, decodeCalldata };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
decodeCalldata,
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// The typed-data signing screen
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/400).
|
||||
//
|
||||
// A Permit or Permit2 signature lets its spender take tokens from the signer's
|
||||
// address, and it is how most wallet drains are done. Listed as plain
|
||||
// key/value lines it reads exactly like a sign-in message, so the screen has
|
||||
// to warn for it, naming the spender and the amount, and must not warn for a
|
||||
// sign-in message.
|
||||
//
|
||||
// ethers signs the type it derives from `types`, not the page's
|
||||
// `primaryType`, so the screen names the derived type, and a request whose
|
||||
// stated type is missing or differs is refused rather than shown under a name
|
||||
// it is not signed as.
|
||||
|
||||
globalThis.chrome = {
|
||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
||||
};
|
||||
|
||||
const { getAddress } = require("ethers");
|
||||
const { state } = require("../src/shared/state");
|
||||
const {
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
} = require("../src/popup/views/approval");
|
||||
|
||||
const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0");
|
||||
const OWNER = getAddress("0x0000000000000000000000000000000000000a11");
|
||||
// Bundled, so the symbol and the 6-decimal scale come from the list.
|
||||
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
|
||||
const DAI = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
|
||||
const PERMIT2 = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
|
||||
|
||||
const WARNING = "TOKEN PERMISSION";
|
||||
|
||||
// Permit2's PermitSingle, granting the largest uint160 allowance there is.
|
||||
const PERMIT_SINGLE = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
{ name: "verifyingContract", type: "address" },
|
||||
],
|
||||
PermitSingle: [
|
||||
{ name: "details", type: "PermitDetails" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "sigDeadline", type: "uint256" },
|
||||
],
|
||||
PermitDetails: [
|
||||
{ name: "token", type: "address" },
|
||||
{ name: "amount", type: "uint160" },
|
||||
{ name: "expiration", type: "uint48" },
|
||||
{ name: "nonce", type: "uint48" },
|
||||
],
|
||||
},
|
||||
primaryType: "PermitSingle",
|
||||
domain: { name: "Permit2", chainId: 1, verifyingContract: PERMIT2 },
|
||||
message: {
|
||||
details: {
|
||||
token: USDC,
|
||||
amount: ((1n << 160n) - 1n).toString(),
|
||||
expiration: "1790000000",
|
||||
nonce: "0",
|
||||
},
|
||||
spender: SPENDER,
|
||||
sigDeadline: "1790000000",
|
||||
},
|
||||
};
|
||||
|
||||
// EIP-2612's Permit for 5 USDC; the token is the domain's contract.
|
||||
const PERMIT = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
{ name: "verifyingContract", type: "address" },
|
||||
],
|
||||
Permit: [
|
||||
{ name: "owner", type: "address" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "value", type: "uint256" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "deadline", type: "uint256" },
|
||||
],
|
||||
},
|
||||
primaryType: "Permit",
|
||||
domain: {
|
||||
name: "USD Coin",
|
||||
version: "2",
|
||||
chainId: 1,
|
||||
verifyingContract: USDC,
|
||||
},
|
||||
message: {
|
||||
owner: OWNER,
|
||||
spender: SPENDER,
|
||||
value: "5000000",
|
||||
nonce: "0",
|
||||
deadline: "1790000000",
|
||||
},
|
||||
};
|
||||
|
||||
// DAI's older permit: the same name, no amount, all or nothing by `allowed`.
|
||||
const DAI_PERMIT = {
|
||||
types: {
|
||||
EIP712Domain: PERMIT.types.EIP712Domain,
|
||||
Permit: [
|
||||
{ name: "holder", type: "address" },
|
||||
{ name: "spender", type: "address" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
{ name: "expiry", type: "uint256" },
|
||||
{ name: "allowed", type: "bool" },
|
||||
],
|
||||
},
|
||||
primaryType: "Permit",
|
||||
domain: {
|
||||
name: "Dai Stablecoin",
|
||||
version: "1",
|
||||
chainId: 1,
|
||||
verifyingContract: DAI,
|
||||
},
|
||||
message: {
|
||||
holder: OWNER,
|
||||
spender: SPENDER,
|
||||
nonce: "0",
|
||||
expiry: "0",
|
||||
allowed: true,
|
||||
},
|
||||
};
|
||||
|
||||
const LOGIN = {
|
||||
types: {
|
||||
EIP712Domain: [
|
||||
{ name: "name", type: "string" },
|
||||
{ name: "version", type: "string" },
|
||||
{ name: "chainId", type: "uint256" },
|
||||
],
|
||||
Login: [
|
||||
{ name: "contents", type: "string" },
|
||||
{ name: "nonce", type: "uint256" },
|
||||
],
|
||||
},
|
||||
primaryType: "Login",
|
||||
domain: { name: "Example", version: "1", chainId: 1 },
|
||||
message: { contents: "Sign in to example.com", nonce: "7" },
|
||||
};
|
||||
|
||||
// The warning box alone. It comes before the key/value lines, which list the
|
||||
// spender and the raw amount too, so an assertion on the whole screen would
|
||||
// pass with no warning at all.
|
||||
function warningOf(data) {
|
||||
const html = formatTypedDataHtml(JSON.stringify(data));
|
||||
return html.slice(0, html.indexOf(">Domain<"));
|
||||
}
|
||||
|
||||
function request(data) {
|
||||
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.trackedTokens = [];
|
||||
state.wallets = [];
|
||||
});
|
||||
|
||||
describe("a token permission is warned about, naming spender and amount", () => {
|
||||
test("a Permit2 PermitSingle for an unlimited allowance", () => {
|
||||
const warning = warningOf(PERMIT_SINGLE);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain(USDC);
|
||||
expect(warning).toContain("Unlimited");
|
||||
});
|
||||
|
||||
test("an EIP-2612 Permit for 5 USDC", () => {
|
||||
const warning = warningOf(PERMIT);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain("5.0000 USDC");
|
||||
});
|
||||
|
||||
test("DAI's older permit, which grants everything", () => {
|
||||
const warning = warningOf(DAI_PERMIT);
|
||||
expect(warning).toContain(WARNING);
|
||||
expect(warning).toContain(SPENDER);
|
||||
expect(warning).toContain("Unlimited");
|
||||
});
|
||||
|
||||
test("a sign-in message carries no warning, and is still shown", () => {
|
||||
const html = formatTypedDataHtml(JSON.stringify(LOGIN));
|
||||
expect(html).not.toContain(WARNING);
|
||||
expect(html).toContain("Sign in to example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the primary type shown is the one ethers signs", () => {
|
||||
// A drain stated as a sign-in: the page says Login, the types say
|
||||
// PermitSingle, and ethers would sign PermitSingle.
|
||||
const disguised = { ...PERMIT_SINGLE, primaryType: "Login" };
|
||||
|
||||
test("a stated type that differs from the signed one is refused", () => {
|
||||
expect(typedDataRefusal(request(disguised))).toBe(
|
||||
"This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("the screen names the signed type, and still warns", () => {
|
||||
const html = formatTypedDataHtml(JSON.stringify(disguised));
|
||||
expect(html).toContain(">PermitSingle<");
|
||||
expect(html).not.toContain(">Login<");
|
||||
expect(html).toContain(WARNING);
|
||||
});
|
||||
|
||||
test("a missing primary type is refused", () => {
|
||||
const unnamed = { ...PERMIT_SINGLE, primaryType: undefined };
|
||||
expect(typedDataRefusal(request(unnamed))).toBe(
|
||||
"This typed data does not name its primary type, so it cannot be signed.",
|
||||
);
|
||||
});
|
||||
|
||||
test("a stated type that is the signed one is not refused", () => {
|
||||
expect(typedDataRefusal(request(PERMIT_SINGLE))).toBeNull();
|
||||
expect(typedDataRefusal(request(LOGIN))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user