harden: warn for token-permission typed data and show the primary type ethers signs (closes #400)
The typed-data screen listed a Permit or Permit2 signature as plain key/value lines, like a sign-in message. It now shows a red warning naming the spender and each token and amount, read only from the fields the signed type declares (a Permit's token is the domain's verifyingContract); anything those fields do not give reads Unknown. The screen printed the page's primaryType, but ethers signs the type it derives from types. It now shows that type and refuses typed data whose stated type is missing or differs: Sign disabled, checked again where signing starts. Deviation: the warning names no deadline or expiry; see the issue. Judgement call: DAI's older permit and Permit2's batch and witness transfer types are recognised too. Model: opus-5-5
This commit was merged in pull request #414.
This commit is contained in:
+257
-23
@@ -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,38 +395,245 @@ function decodeHexMessage(hex) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
// 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 {
|
||||
const data = JSON.parse(jsonStr);
|
||||
let html = "";
|
||||
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;
|
||||
}
|
||||
|
||||
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>`;
|
||||
// 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;
|
||||
|
||||
// One field of a struct as typed data signs it: the field's declared type and
|
||||
// the struct's value for it, or null when the struct's type declares no field
|
||||
// of that name. ethers signs only the fields a type declares and drops every
|
||||
// other key, so a key the page adds beside them is never read here.
|
||||
function declaredField(types, typeName, struct, name) {
|
||||
const field = (types[typeName] || []).find((f) => f.name === name);
|
||||
if (!field || !struct || typeof struct !== "object") return null;
|
||||
return { type: field.type, value: struct[name] };
|
||||
}
|
||||
|
||||
// The tokens and amounts a Permit2 message grants, from its `details` or
|
||||
// `permitted` field: one struct of `token` and `amount`, or a list of them,
|
||||
// as the field's declared type says. When the field cannot be read the one
|
||||
// grant returned has no token or amount, so the warning still lists it.
|
||||
function permit2Grants(types, primaryType, message, name, max) {
|
||||
const field = declaredField(types, primaryType, message, name);
|
||||
if (!field) return [{ max }];
|
||||
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
|
||||
const itemType = field.type.replace(/\[\d*\]$/, "");
|
||||
const items = itemType === field.type ? [field.value] : field.value;
|
||||
if (!Array.isArray(items)) return [{ max }];
|
||||
return items.map((item) => ({
|
||||
token: declaredField(types, itemType, item, "token")?.value,
|
||||
amount: declaredField(types, itemType, item, "amount")?.value,
|
||||
max,
|
||||
}));
|
||||
}
|
||||
|
||||
// The address or number a permission field holds, or null when it holds
|
||||
// none; the warning then shows `Unknown` rather than failing.
|
||||
function addressOrNull(value) {
|
||||
try {
|
||||
return getAddress(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function amountOrNull(value) {
|
||||
try {
|
||||
return getBigInt(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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. They are recognised by the
|
||||
// type ethers signs, `Permit` or one of Permit2's six signature types: a
|
||||
// contract checks the type's exact name, so a renamed copy of one of these
|
||||
// would not be honoured. Everything named is read only from the fields that
|
||||
// type declares, except a `Permit`'s token, which is the domain's
|
||||
// `verifyingContract`; whatever they do not give is shown as `Unknown`.
|
||||
function permitWarningHtml(types, 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 declares a `value`. DAI's older permit, signed under
|
||||
// the same name, declares only `allowed`: unlimited, or nothing.
|
||||
// Others, such as the permit for a Uniswap v3 position, declare
|
||||
// neither, and their amount is unknown.
|
||||
const value = declaredField(types, primaryType, message, "value");
|
||||
const allowed = declaredField(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"allowed",
|
||||
);
|
||||
let amount;
|
||||
if (value) amount = value.value;
|
||||
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
|
||||
grants = [
|
||||
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
|
||||
];
|
||||
break;
|
||||
}
|
||||
case "PermitSingle":
|
||||
case "PermitBatch":
|
||||
grants = permit2Grants(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"details",
|
||||
MAX_UINT160,
|
||||
);
|
||||
break;
|
||||
case "PermitTransferFrom":
|
||||
case "PermitWitnessTransferFrom":
|
||||
case "PermitBatchTransferFrom":
|
||||
case "PermitBatchWitnessTransferFrom":
|
||||
grants = permit2Grants(
|
||||
types,
|
||||
primaryType,
|
||||
message,
|
||||
"permitted",
|
||||
MaxUint256,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
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>`;
|
||||
const sources = {
|
||||
trackedTokens: state.trackedTokens,
|
||||
wallets: state.wallets,
|
||||
};
|
||||
const spender = addressOrNull(
|
||||
declaredField(types, primaryType, message, "spender")?.value,
|
||||
);
|
||||
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>`;
|
||||
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
|
||||
html += `</div>`;
|
||||
for (const grant of grants) {
|
||||
const token = addressOrNull(grant.token);
|
||||
const amount = amountOrNull(grant.amount);
|
||||
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
|
||||
// or base units when nothing knows the token's scale.
|
||||
let amountText = "Unknown";
|
||||
if (amount === grant.max) {
|
||||
amountText = "Unlimited";
|
||||
} else if (amount !== null && token === null) {
|
||||
amountText = unknownDecimalsAmount(amount);
|
||||
} else if (amount !== null) {
|
||||
amountText = tokenAmountText(
|
||||
amount,
|
||||
resolveTokenDecimals(token, sources),
|
||||
tokenLabel(token),
|
||||
).display;
|
||||
}
|
||||
|
||||
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>`;
|
||||
html += `<div class="mb-2"><div>Token</div>`;
|
||||
if (token) {
|
||||
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
|
||||
html += approvalAddressHtml(token);
|
||||
} else {
|
||||
html += `<div>Unknown</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
return html;
|
||||
}
|
||||
|
||||
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. Only typed data that cannot
|
||||
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
|
||||
function formatTypedDataHtml(jsonStr) {
|
||||
let data;
|
||||
let primaryType;
|
||||
try {
|
||||
data = JSON.parse(jsonStr);
|
||||
primaryType = signedPrimaryType(data.types);
|
||||
} catch {
|
||||
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
||||
}
|
||||
|
||||
let html = permitWarningHtml(
|
||||
data.types,
|
||||
primaryType,
|
||||
data.domain,
|
||||
data.message,
|
||||
);
|
||||
|
||||
// A value that is an object is shown as JSON: String() of it says
|
||||
// nothing, and throws for some objects a page can send.
|
||||
const display = (val) =>
|
||||
typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
|
||||
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(display(val))}</div>`;
|
||||
}
|
||||
html += `</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>`;
|
||||
for (const [key, val] of Object.entries(data.message)) {
|
||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function showSignApproval(details) {
|
||||
@@ -477,6 +688,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 +1000,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 +1101,10 @@ function init(_ctx) {
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { init, show, decodeCalldata };
|
||||
module.exports = {
|
||||
init,
|
||||
show,
|
||||
decodeCalldata,
|
||||
formatTypedDataHtml,
|
||||
typedDataRefusal,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user