ethers signs a bool field by whether its value is truthy, so the string "false" was shown as false and signed as true, and a bool field left out was shown as nothing and signed as false. The sign screen now refuses typed data whose message leaves out a declared field, at any level of nesting, or holds a bool that is not true or false: the error line names the field and Sign is disabled. The background refuses a signature for it too. Presence is an own-key test, so a field named like a built-in property such as constructor is not found by accident. signedPrimaryType() moves to the shared module so the background reads the message as the same type. Model: opus-5-5
769 lines
28 KiB
JavaScript
769 lines
28 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, TypedDataEncoder } = 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);
|
|
});
|
|
});
|
|
|
|
// ethers signs a `bool` field by whether its value is truthy in JavaScript:
|
|
// the string "false" is signed as true, and a field left out as false. The
|
|
// screen would show one value while another is signed, so such typed data is
|
|
// refused, and so is any declared field left out of the message
|
|
// (https://git.eeqj.de/sneak/AutistMask/issues/552). DAI's older permit
|
|
// declares the `bool` field `allowed`.
|
|
describe("the sign screen refuses a message ethers would sign differently", () => {
|
|
const NOT_A_BOOL =
|
|
'The field "allowed" is not true or false, so this typed data cannot be signed.';
|
|
const MISSING =
|
|
'The field "allowed" is missing from the message, so this typed data cannot be signed.';
|
|
|
|
function daiPermit(allowed) {
|
|
return { ...DAI_PERMIT, message: { ...DAI_PERMIT.message, allowed } };
|
|
}
|
|
|
|
const withoutAllowed = {
|
|
...DAI_PERMIT,
|
|
message: { ...DAI_PERMIT.message },
|
|
};
|
|
delete withoutAllowed.message.allowed;
|
|
|
|
beforeEach(() => {
|
|
decryptWithPassword.mockClear();
|
|
});
|
|
|
|
test('ethers signs "false" as true, and a bool left out as false', () => {
|
|
const hash = (data) =>
|
|
TypedDataEncoder.hash(
|
|
data.domain,
|
|
{ Permit: data.types.Permit },
|
|
data.message,
|
|
);
|
|
expect(hash(daiPermit("false"))).toBe(hash(daiPermit(true)));
|
|
expect(hash(withoutAllowed)).toBe(hash(daiPermit(false)));
|
|
});
|
|
|
|
test('a bool sent as the string "false" shows the error, with Sign disabled', async () => {
|
|
await openSignScreen(daiPermit("false"));
|
|
expect(node("approve-sign-error").textContent).toBe(NOT_A_BOOL);
|
|
expect(node("btn-approve-sign").disabled).toBe(true);
|
|
});
|
|
|
|
test("a bool left out shows the error, with Sign disabled", async () => {
|
|
await openSignScreen(withoutAllowed);
|
|
expect(node("approve-sign-error").textContent).toBe(MISSING);
|
|
expect(node("btn-approve-sign").disabled).toBe(true);
|
|
});
|
|
|
|
test("Sign clicked anyway decrypts and signs nothing", async () => {
|
|
const sent = await openSignScreen(daiPermit("false"));
|
|
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(NOT_A_BOOL);
|
|
});
|
|
|
|
test("a well-formed message leaves Sign enabled", async () => {
|
|
for (const allowed of [true, false]) {
|
|
await openSignScreen(daiPermit(allowed));
|
|
expect(node("approve-sign-error").textContent).toBe("");
|
|
expect(node("btn-approve-sign").disabled).toBe(false);
|
|
}
|
|
});
|
|
|
|
test("a field left out inside a list item is named by its place", () => {
|
|
const [first, second] = PERMIT_BATCH.message.details;
|
|
const withoutNonce = { ...second };
|
|
delete withoutNonce.nonce;
|
|
const data = {
|
|
...PERMIT_BATCH,
|
|
message: {
|
|
...PERMIT_BATCH.message,
|
|
details: [first, withoutNonce],
|
|
},
|
|
};
|
|
expect(typedDataRefusal(request(data))).toBe(
|
|
'The field "details[1].nonce" is missing from the message, so this typed data cannot be signed.',
|
|
);
|
|
});
|
|
|
|
// A plain lookup of a field named after a built-in object property reads
|
|
// back a function, which ethers would sign as true.
|
|
test("a field named after a built-in object property is missing when left out", () => {
|
|
const data = {
|
|
...LOGIN,
|
|
types: {
|
|
...LOGIN.types,
|
|
Login: [
|
|
...LOGIN.types.Login,
|
|
{ name: "constructor", type: "bool" },
|
|
],
|
|
},
|
|
};
|
|
expect(typedDataRefusal(request(data))).toBe(
|
|
'The field "constructor" is missing from the message, so this typed data cannot be signed.',
|
|
);
|
|
});
|
|
});
|