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, exactly like a sign-in message. For EIP-2612's Permit and Permit2's signature types it now shows a red warning naming the spender and each token and amount, Unlimited for the field's largest value. The screen printed the page's primaryType, but ethers signs the type it derives from types. It now shows the derived type, and typed data whose stated type is missing or differs is refused: error line, Sign disabled, and 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 is contained in:
@@ -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