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
535 lines
18 KiB
JavaScript
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);
|
|
});
|
|
});
|