// 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: 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", }, }; // 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 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("
Amount
Unknown
"); expect(html).toContain(">Domain<"); expect(html).toContain(">Primary type<"); expect(html).toContain("tokenId:"); }); }); 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(); }); }); // ------------------------------------------------------------ the sign screen function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", value: "", innerHTML: "", disabled: false, style: {}, dataset: {}, listeners: {}, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: (n, force) => { const on = force === undefined ? !classes.has(n) : force; if (on) classes.add(n); else classes.delete(n); return on; }, }, addEventListener: (name, fn) => { el.listeners[name] = el.listeners[name] || []; el.listeners[name].push(fn); }, querySelectorAll: () => [], }; return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; absent // is the state a non-debug, non-testnet popup is in. if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement(id)); return els.get(id); }, createElement: () => makeElement("created"), body: { prepend: () => {} }, }; } function node(id) { return globalThis.document.getElementById(id); } function click(id) { return Promise.all((node(id).listeners.click || []).map((fn) => fn())); } // Open the sign screen for a typed-data request from OWNER, the way the popup // does: the background hands over the request and show() draws it. Returns // every message the screen sends to the background. async function openSignScreen(data) { const sent = []; globalThis.document = makeDocument(); globalThis.chrome.runtime = { connect: () => ({ postMessage: () => {} }), sendMessage: (msg, reply) => { sent.push(msg); if (!reply) return; if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null); reply({ type: "sign", hostname: "dapp.example", isPhishingDomain: false, approvedFrom: OWNER, signParams: request(data), }); }, }; state.wallets = [ { type: "hd", name: "Wallet 1", xpub: "xpub-wallet-1", encryptedSecret: "encrypted-secret-1", nextIndex: 1, addresses: [ { address: OWNER, balance: "0.0000", tokenBalances: [] }, ], }, ]; approval.init({}); await approval.show(1); return sent; } describe("the sign screen refuses a mismatched primary type", () => { const disguised = { ...PERMIT_SINGLE, primaryType: "Login" }; const REFUSAL = "This typed data names its primary type as Login, but it would be signed as PermitSingle, so it cannot be signed."; beforeEach(() => { decryptWithPassword.mockClear(); }); test("a matching primary type leaves Sign enabled", async () => { await openSignScreen(PERMIT_SINGLE); expect(node("approve-sign-error").textContent).toBe(""); expect(node("btn-approve-sign").disabled).toBe(false); }); test("a mismatched one shows the error, with Sign disabled", async () => { await openSignScreen(disguised); expect(node("approve-sign-error").textContent).toBe(REFUSAL); expect(node("approve-sign-error").style.visibility).toBe("visible"); expect(node("btn-approve-sign").disabled).toBe(true); }); // The handler is called directly, as a button re-enabled by some other // path would call it: the refusal must not rest on the button alone. test("Sign clicked anyway decrypts and signs nothing", async () => { const sent = await openSignScreen(disguised); node("approve-sign-password").value = "any password"; await click("btn-approve-sign"); expect(decryptWithPassword).not.toHaveBeenCalled(); expect(sent.map((msg) => msg.type)).not.toContain( "AUTISTMASK_SIGN_RESPONSE", ); expect(node("approve-sign-error").textContent).toBe(REFUSAL); expect(node("btn-approve-sign").disabled).toBe(true); }); });