Files
AutistMask/tests/typedDataPermit.test.js
T
sneak e557f21bb0
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
harden: key remembered site permissions by full origin (closes #402)
allowedSites and deniedSites held the bare hostname, so a grant to
https://dapp.example also authorised http://dapp.example and every port
on that host, and the connection, transaction and signature prompts
named only the hostname. Both lists now store and match the full origin
(scheme://host[:port]), the key the connections approved without
Remember already used. The prompts, the Settings site lists and
AUTISTMASK_REMOVE_SITE use the origin too. Entries saved by hostname
are not migrated (pre-1.0): they match no site.

Model: opus-5-5
2026-10-04 15:43:58 +00:00

535 lines
18 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<"));
}
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("<div>Amount</div><div>Unknown</div>");
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",
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);
});
});