Files
AutistMask/tests/typedDataPermit.test.js
T
sneak 2e10615598
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
fix: the typed data screen shows only the message fields that are signed (closes #539)
ethers signs only the fields a type declares and drops any other key, but
the Message section listed every key the site sent, so an added key read
as signed. The Message section now follows the signed type, at every level
of nesting and in the type's order. Typed data with other keys is not
refused: those keys are listed after the message under "Not part of the
signature", by their place in the message. README SignApproval says so.
The operator/spender test now also checks the Message section, and new
tests cover an added key in a nested struct and in a list item.

Model: opus-5-5
2026-10-14 10:09:28 +00:00

665 lines
24 KiB
JavaScript

// 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<"));
}
const NOT_SIGNED = ">Not part of the signature<";
// The Message section alone, without the keys listed after it as not part of
// the signature.
function messageOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const end = html.indexOf(NOT_SIGNED);
return html.slice(
html.indexOf(">Message<"),
end === -1 ? html.length : end,
);
}
// The keys listed as not part of the signature, or "" when there are none.
function notSignedOf(data) {
const html = formatTypedDataHtml(JSON.stringify(data));
const start = html.indexOf(NOT_SIGNED);
return start === -1 ? "" : html.slice(start);
}
function request(data) {
return { method: "eth_signTypedData_v4", typedData: JSON.stringify(data) };
}
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
state.networkId = "mainnet";
});
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. The Message section names
// only the operator, and the added key is listed apart as not signed.
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);
expect(messageOf(data)).toContain(SPENDER);
expect(messageOf(data)).not.toContain(DECOY);
expect(notSignedOf(data)).toContain("spender:");
expect(notSignedOf(data)).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("<div>Amount</div><div>Unknown</div>");
expect(html).toContain(">Domain<");
expect(html).toContain(">Primary type<");
expect(html).toContain("tokenId:");
});
});
// ethers signs only the declared fields at every level of nesting, so an
// added key inside a struct is not signed either. Such typed data is not
// refused; the added key is listed apart, with where it sits in the message.
describe("the Message section shows only the fields that are signed", () => {
const NOTE = "This signature only signs you in";
test("an added key inside a nested struct is listed as not signed", () => {
const data = {
...PERMIT_SINGLE,
message: {
...PERMIT_SINGLE.message,
details: { ...PERMIT_SINGLE.message.details, note: NOTE },
},
};
expect(messageOf(data)).toContain("expiration");
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details.note:");
expect(notSignedOf(data)).toContain(NOTE);
expect(typedDataRefusal(request(data))).toBeNull();
});
test("an added key inside a list item is listed with its index", () => {
const [first, second] = PERMIT_BATCH.message.details;
const data = {
...PERMIT_BATCH,
message: {
...PERMIT_BATCH.message,
details: [first, { ...second, note: NOTE }],
},
};
expect(messageOf(data)).not.toContain(NOTE);
expect(notSignedOf(data)).toContain("details[1].note:");
expect(notSignedOf(data)).toContain(NOTE);
});
test("nothing is listed apart when every key is declared", () => {
expect(notSignedOf(PERMIT_SINGLE)).toBe("");
expect(notSignedOf(LOGIN)).toBe("");
});
});
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",
origin: "https://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);
});
});
// EIP-712: a wallet refuses typed data whose domain names a chain other than
// the active one, because the signature is valid on the chain it names
// (https://git.eeqj.de/sneak/AutistMask/issues/519). The wallet is on Sepolia
// and the site asks for a mainnet USDC Permit.
describe("the sign screen refuses typed data for another chain", () => {
const REFUSAL =
"This typed data is for chain id 1, but the active network is Sepolia Testnet (chain id 11155111), so it cannot be signed.";
function withChainId(chainId) {
return { ...PERMIT, domain: { ...PERMIT.domain, chainId } };
}
beforeEach(() => {
decryptWithPassword.mockClear();
state.networkId = "sepolia";
});
test("another chain shows the error, with Sign disabled", async () => {
await openSignScreen(PERMIT);
expect(node("approve-sign-error").textContent).toBe(REFUSAL);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Sign clicked anyway decrypts and signs nothing", async () => {
const sent = await openSignScreen(PERMIT);
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);
});
// ethers signs 1, "1" and "0x1" as the same chain id.
test("the chain id is refused in each form ethers signs", () => {
for (const chainId of [1, "1", "0x1"]) {
expect(typedDataRefusal(request(withChainId(chainId)))).toBe(
REFUSAL,
);
}
});
test("a chain id that is not a number is refused", () => {
expect(typedDataRefusal(request(withChainId("mainnet")))).toBe(
"This typed data cannot be read, so it cannot be signed.",
);
});
test("the active chain leaves Sign enabled", async () => {
await openSignScreen(withChainId("0xaa36a7"));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("no chain id leaves Sign enabled", async () => {
await openSignScreen(withChainId(undefined));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
});
});