// 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 only the fields a type declares in `types` and drops any other // key in the message, so the warning reads the spender, tokens and amounts // from those fields alone, except a `Permit`'s token, which is the domain's // `verifyingContract`: a key the page adds beside them must not change what // the warning says. // // 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. The refusal is checked on the sign screen itself, // driven against a minimal DOM stub in the shape // tests/deleteWalletLostPassword.test.js uses. jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { getAddress, MaxUint256 } = require("ethers"); const { state } = require("../src/shared/state"); const { decryptWithPassword } = require("../src/shared/vault"); const approval = require("../src/popup/views/approval"); const { formatTypedDataHtml, typedDataRefusal } = approval; const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0"); const DECOY = getAddress("0xdec0000000000000000000000000000000000dec"); 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", }, }; // Permit2's PermitBatch: 5 USDC and 7 DAI, a line for each token. const PERMIT_BATCH = { types: { EIP712Domain: PERMIT_SINGLE.types.EIP712Domain, PermitBatch: [ { name: "details", type: "PermitDetails[]" }, { name: "spender", type: "address" }, { name: "sigDeadline", type: "uint256" }, ], PermitDetails: PERMIT_SINGLE.types.PermitDetails, }, primaryType: "PermitBatch", domain: PERMIT_SINGLE.domain, message: { details: [ { token: USDC, amount: "5000000", expiration: "1790000000", nonce: "0", }, { token: DAI, amount: "7000000000000000000", expiration: "1790000000", nonce: "0", }, ], spender: SPENDER, sigDeadline: "1790000000", }, }; // One of Permit2's four transfer types, letting SPENDER take 5 USDC. The // batch types take a list of `TokenPermissions`; the witness types add a // struct of the site's own as their last field. function permit2Transfer(primaryType, { batch = false, witness = false }) { const fields = [ { name: "permitted", type: batch ? "TokenPermissions[]" : "TokenPermissions", }, { name: "spender", type: "address" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ]; const types = { EIP712Domain: PERMIT_SINGLE.types.EIP712Domain, [primaryType]: fields, TokenPermissions: [ { name: "token", type: "address" }, { name: "amount", type: "uint256" }, ], }; const permitted = { token: USDC, amount: "5000000" }; const message = { permitted: batch ? [permitted] : permitted, spender: SPENDER, nonce: "0", deadline: "1790000000", }; if (witness) { fields.push({ name: "witness", type: "Order" }); types.Order = [{ name: "recipient", type: "address" }]; message.witness = { recipient: OWNER }; } return { types, primaryType, domain: PERMIT_SINGLE.domain, message }; } // 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("a Permit2 PermitBatch names both tokens and both amounts", () => { const warning = warningOf(PERMIT_BATCH); expect(warning).toContain(WARNING); expect(warning).toContain(SPENDER); expect(warning).toContain(USDC); expect(warning).toContain("5.0000 USDC"); expect(warning).toContain(DAI); expect(warning).toContain("7.0000 DAI"); }); test.each([ ["PermitTransferFrom", {}], ["PermitWitnessTransferFrom", { witness: true }], ["PermitBatchTransferFrom", { batch: true }], ["PermitBatchWitnessTransferFrom", { batch: true, witness: true }], ])("a Permit2 %s", (primaryType, shape) => { const warning = warningOf(permit2Transfer(primaryType, shape)); expect(warning).toContain(WARNING); expect(warning).toContain(SPENDER); expect(warning).toContain(USDC); expect(warning).toContain("5.0000 USDC"); }); 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 warning reads only the fields the signed type declares", () => { // An unlimited EIP-2612 permit with DAI's `allowed` added as a key the // type does not declare. ethers signs exactly the unlimited permit. test("an added key does not change the amount", () => { const data = { ...PERMIT, message: { ...PERMIT.message, value: MaxUint256.toString(), allowed: false, }, }; expect(warningOf(data)).toContain("Unlimited"); }); // A Permit whose type names its spender `operator`; the page adds a // `spender` key the type does not declare. test("an added key is not named as the spender", () => { const data = { ...PERMIT, types: { ...PERMIT.types, Permit: [ { name: "owner", type: "address" }, { name: "operator", type: "address" }, { name: "value", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }, message: { ...PERMIT.message, operator: SPENDER, spender: DECOY }, }; const warning = warningOf(data); expect(warning).toContain(WARNING); expect(warning).not.toContain(DECOY); }); // The permit for a Uniswap v3 position NFT: a `Permit` with no amount // field at all, which ethers signs and its contract accepts. test("a Permit with no amount still warns, above the normal lines", () => { const data = { types: { EIP712Domain: PERMIT.types.EIP712Domain, Permit: [ { name: "spender", type: "address" }, { name: "tokenId", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }, primaryType: "Permit", domain: { name: "Uniswap V3 Positions NFT-V1", version: "1", chainId: 1, verifyingContract: "0xC36442b4a4522E871399CD717aBDD847Ab11FE88", }, message: { spender: SPENDER, tokenId: "12345", nonce: "0", deadline: "1790000000", }, }; const html = formatTypedDataHtml(JSON.stringify(data)); const warning = warningOf(data); expect(warning).toContain(WARNING); expect(warning).toContain(SPENDER); expect(warning).toContain("