// 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(); }); });