Aegis: a permit is described from what the signature covers

describePermit read td.message directly, and the EIP-712 encoder ignores
keys a type does not declare. A dapp could put a decoy allowed:false or
details:{amount:"1"} beside an unlimited permit and the overlay showed
the decoy, not risky, under the plain Sign button, with the same digest.

The overlay and the message preview are now built from the declared
fields only, the permit variant is picked from the declared type, and
dropped fields are counted on the overlay. Amounts of 2^96 units and up
are flagged as effectively unlimited, marketplace orders and Safe
transactions get the warning too, and the encoder refuses a non-hex
address instead of signing it as zero bytes.
This commit is contained in:
Local Dev 2026-10-04 02:19:42 +02:00
parent b85605416e
commit e02d4698ea
2 changed files with 78 additions and 18 deletions

View file

@ -3164,13 +3164,50 @@ const ETH_RPC_NEVER = /^(eth_sign|eth_signTransaction|eth_sendTransaction|eth_ac
// PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the // PermitBatch / PermitTransferFrom / PermitBatchTransferFrom). Returns the
// overlay rows that say who may spend what until when, or null when the // overlay rows that say who may spend what until when, or null when the
// typed data is not a spending approval. // typed data is not a spending approval.
// What the signature actually covers. The EIP-712 encoder reads only the
// fields each type declares; anything else in `message` is ignored by the
// digest but was read by the overlay, so a dapp could add a decoy
// `allowed: false` or `details: {amount: "1"}` beside an unlimited permit and
// have the overlay show the decoy. Everything shown is built from this view.
function signedView(td) {
const types = (td && typeof td.types === "object" && td.types) || {};
let dropped = 0;
const walk = (type, v) => {
const arr = /\[\d*\]$/.exec(type);
if (arr) {
const inner = type.slice(0, arr.index);
return Array.isArray(v) ? v.map((x) => walk(inner, x)) : v;
}
const fields = types[type];
if (!Array.isArray(fields)) return v; // atomic
const src = (v && typeof v === "object") ? v : {};
for (const k of Object.keys(src)) if (!fields.some((f) => f && f.name === k)) dropped++;
const out = {};
for (const f of fields) if (f && typeof f.name === "string") out[f.name] = walk(String(f.type), src[f.name]);
return out;
};
return { message: walk(String(td?.primaryType || ""), td?.message), dropped };
}
function describePermit(td) { function describePermit(td) {
const primary = String(td?.primaryType || ""); const primary = String(td?.primaryType || "");
if (!/^Permit/.test(primary)) return null; if (!/^Permit/.test(primary)) return null;
const msg = (td && typeof td.message === "object" && td.message) || {}; const msg = signedView(td).message || {};
const declared = (t) => (Array.isArray(td?.types?.[t]) ? td.types[t].map((f) => f && f.name) : []);
const pf = declared(primary);
const has = (...names) => names.every((n) => pf.includes(n));
const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max const UNLIMITED = 1n << 159n; // ≥ half of uint160 covers Permit2's max and every uint256 max
// No token has a supply anywhere near this, so an amount this large is an
// unlimited approval in all but name (2^96 ≈ 7.9e28 base units: 79 billion
// tokens at 18 decimals).
const HUGE = 1n << 96n;
const big = (v) => { try { return BigInt(v); } catch { return null; } }; const big = (v) => { try { return BigInt(v); } catch { return null; } };
const amountText = (v) => { const b = big(v); return b === null ? String(v) : (b >= UNLIMITED ? "UNLIMITED (∞)" : b.toString() + " units"); }; const amountText = (v) => {
const b = big(v);
if (b === null) return String(v);
if (b >= UNLIMITED) return "UNLIMITED (∞)";
return b.toString() + " units" + (b >= HUGE ? " (effectively unlimited)" : "");
};
const when = (v) => { const when = (v) => {
const b = big(v); const b = big(v);
if (b === null) return String(v); if (b === null) return String(v);
@ -3179,23 +3216,34 @@ function describePermit(td) {
}; };
const rows = []; const rows = [];
let risky = false; let risky = false;
const spender = msg.spender; const spender = has("spender") ? msg.spender : null;
rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true }); rows.push({ label: "Spender", value: spender ? String(spender) : "(none named — anyone holding this signature)", mono: true, strong: true });
if (!spender) risky = true; if (!spender) risky = true;
const items = []; const items = [];
if (primary === "Permit") { // The variant is chosen by the declared type, never by what the message
// EIP-2612 has `value`; DAI has `allowed: true` (always unlimited). // happens to carry.
if ("allowed" in msg) items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n }); if (has("holder", "spender", "nonce", "expiry", "allowed")) {
else items.push({ token: td.domain?.verifyingContract, amount: msg.value }); // DAI-style: a bool, encoded by truthiness — so is this.
if (msg.deadline != null || msg.expiry != null) rows.push({ label: "Valid until", value: when(msg.deadline ?? msg.expiry) }); items.push({ token: td.domain?.verifyingContract, amount: msg.allowed ? UNLIMITED : 0n });
} else { rows.push({ label: "Valid until", value: when(msg.expiry) });
const list = Array.isArray(msg.details) ? msg.details : (msg.details ? [msg.details] : (Array.isArray(msg.permitted) ? msg.permitted : (msg.permitted ? [msg.permitted] : []))); } else if (has("owner", "spender", "value", "deadline")) {
// EIP-2612.
items.push({ token: td.domain?.verifyingContract, amount: msg.value });
rows.push({ label: "Valid until", value: when(msg.deadline) });
} else if (has("details", "spender", "sigDeadline")) {
// Permit2 PermitSingle / PermitBatch.
const list = Array.isArray(msg.details) ? msg.details : [msg.details];
for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration }); for (const d of list) items.push({ token: d?.token, amount: d?.amount, expiration: d?.expiration });
if (msg.sigDeadline != null || msg.deadline != null) rows.push({ label: "Signature valid until", value: when(msg.sigDeadline ?? msg.deadline) }); rows.push({ label: "Signature valid until", value: when(msg.sigDeadline) });
} else if (has("permitted", "spender", "deadline")) {
// Permit2 PermitTransferFrom / PermitBatchTransferFrom (and *Witness*).
const list = Array.isArray(msg.permitted) ? msg.permitted : [msg.permitted];
for (const d of list) items.push({ token: d?.token, amount: d?.amount });
rows.push({ label: "Signature valid until", value: when(msg.deadline) });
} }
items.slice(0, 10).forEach((it, i) => { items.slice(0, 10).forEach((it, i) => {
const b = big(it.amount); const b = big(it.amount);
if (b !== null && b >= UNLIMITED) risky = true; if (b === null || b >= HUGE) risky = true;
rows.push({ rows.push({
label: items.length > 1 ? `Token #${i + 1}` : "Token", label: items.length > 1 ? `Token #${i + 1}` : "Token",
value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`, value: `${it.token || "?"} — ${amountText(it.amount)}${it.expiration != null ? ` · allowance ${when(it.expiration)}` : ""}`,
@ -3203,7 +3251,7 @@ function describePermit(td) {
}); });
}); });
if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; } if (items.length > 10) { rows.push({ label: "Tokens", value: `… and ${items.length - 10} more` }); risky = true; }
if (!items.length) { rows.push({ label: "Token", value: "(could not read the approval — treat as unlimited)" }); risky = true; } if (!items.length) { rows.push({ label: "Token", value: "(not a permit shape Aegis can read — treat as unlimited)" }); risky = true; }
return { rows, risky }; return { rows, risky };
} }
@ -3708,7 +3756,9 @@ function registerPageMessages(api) {
} }
} }
const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)"; const domainSummary = [dom.name, dom.version && `v${dom.version}`, dom.chainId && `chain ${dom.chainId}`].filter(Boolean).join(" · ") || "(no domain)";
const messagePreview = JSON.stringify(td.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || ""; // Preview only what the signature covers (see signedView).
const view = signedView(td);
const messagePreview = JSON.stringify(view.message, (_k, v) => (typeof v === "bigint" ? v.toString() : v), 2) || "";
const rows = [{ label: "Domain", value: domainSummary }]; const rows = [{ label: "Domain", value: domainSummary }];
if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true }); if (dom.verifyingContract) rows.push({ label: "Contract", value: String(dom.verifyingContract), mono: true });
rows.push({ label: "Primary type", value: String(td.primaryType || "") }); rows.push({ label: "Primary type", value: String(td.primaryType || "") });
@ -3718,9 +3768,14 @@ function registerPageMessages(api) {
// and the deadline out of the message and say what they mean. // and the deadline out of the message and say what they mean.
const permit = describePermit(td); const permit = describePermit(td);
if (permit) for (const r of permit.rows) rows.push(r); if (permit) for (const r of permit.rows) rows.push(r);
// Marketplace orders and multisig transactions are not permits but move
// NFTs, tokens or a whole Safe just as surely once signed.
const MOVES_ASSETS = /^(OrderComponents|BulkOrder|Order|MakerOrder|TakerOrder|SafeTx|SafeMessage|MetaTransaction|ForwardRequest)$/;
const movesAssets = !permit && MOVES_ASSETS.test(String(td.primaryType || ""));
rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true }); rows.push({ label: "Message", value: previewText(messagePreview, 3000), mono: true });
if (view.dropped) rows.push({ label: "Not signed", value: `${view.dropped} field${view.dropped === 1 ? "" : "s"} the site sent are not covered by this signature and are not shown` });
rows.push({ label: "Address", value: snapTd.address, mono: true }); rows.push({ label: "Address", value: snapTd.address, mono: true });
const risky = !!(permit && permit.risky); const risky = !!(permit && permit.risky) || movesAssets;
return withOriginLock(origin, async () => { return withOriginLock(origin, async () => {
const pick = await api.approvalModal({ const pick = await api.approvalModal({
title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?", title: permit ? "Sign a token spending permit?" : "Sign typed data (EIP-712)?",
@ -3729,6 +3784,8 @@ function registerPageMessages(api) {
? (risky ? (risky
? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site." ? "WARNING: signing this lets the spender below take these tokens at any time, without another prompt and without a transaction from you. Only sign if you fully trust this site."
: "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.") : "Signing this lets the spender below move the stated amount of your tokens without a transaction from you.")
: movesAssets
? `WARNING: a signed ${String(td.primaryType)} can move your NFTs, tokens or Safe without another prompt. Only sign if you fully trust this site and the details below are what you expect.`
: "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.", : "The site is asking you to sign a structured message. Verify the domain matches the site you're on — a mismatched domain is the classic phishing tell.",
rows, rows,
actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }], actions: [{ id: "sign", label: risky ? "Sign anyway" : "Sign", primary: !risky, danger: risky }],

View file

@ -87,8 +87,11 @@ module.exports = function makeEip712({ keccak_256 }) {
return keccak_256(b); return keccak_256(b);
} }
if (type === "address") { if (type === "address") {
const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); // Validate before decoding: the hex decoder turns non-hex characters
if (h.length !== 20) throw new Error("address must be 20 bytes"); // into zero bytes, so a malformed address would sign as a different one.
const s = String(value ?? "").replace(/^0x/i, "");
if (!/^[0-9a-fA-F]{40}$/.test(s)) throw new Error("address must be 20 bytes of hex");
const h = hex2bytes(s);
const out = new Uint8Array(32); const out = new Uint8Array(32);
out.set(h, 12); out.set(h, 12);
return out; return out;