harden: warn for token-permission typed data and show the primary type ethers signs (closes #400)
check / check (push) Successful in 49s
e2e / e2e-chrome (push) Successful in 1m44s
e2e / e2e-firefox (push) Successful in 31s

The typed-data screen listed a Permit or Permit2 signature as plain
key/value lines, like a sign-in message. For typed data signed as Permit
or as one of Permit2's types it now shows a red warning naming the
spender and each token and amount, read only from the fields the signed
type declares; whatever they do not give reads Unknown.

The screen printed the page's primaryType, but ethers signs the type it
derives from types. It now shows the derived type, and typed data whose
stated type is missing or differs is refused: error line, Sign disabled,
and checked again where signing starts.

Deviation: the warning names no deadline or expiry; see the issue.
Judgement call: DAI's older permit and Permit2's batch and witness
transfer types are recognised too.

Model: opus-5-5
This commit is contained in:
2026-10-03 14:38:06 +00:00
parent add11e57de
commit eaaf1593c0
4 changed files with 735 additions and 27 deletions
+20 -4
View File
@@ -911,9 +911,10 @@ approximation but a different number — 1,000 units of a 6-decimal token
formatted at 18 decimals reads `0.000000001` — on the screen whose only job is
to state what is being authorized. Both amount paths of that screen take this
rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). An
unbounded allowance or permit needs no scale to describe and is still shown as
`Unlimited`.
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
token permission warning on the signature screen takes the same rule for its
amounts. An unbounded allowance or permit needs no scale to describe and is
still shown as `Unlimited`.
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
sources are read as authoritative, so a value written into one of them cannot be
@@ -1801,10 +1802,25 @@ view would leave a wallet one click from deletion.
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
message fields (EIP-712 typed data)
message fields (EIP-712 typed data). The primary type shown is the one
ethers signs, derived from the typed data's `types`, not the type the site
states.
- Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
tokens listed here from your address, without asking you again.", then the
spender's full address and, for each token, its symbol, full address and
amount (`Unlimited` for the largest amount the field holds). These are
read only from the fields the signed type declares, never from other keys
the site puts in the message; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text.
- Password input and an error line
- "Sign" / "Reject" buttons
- **Transitions**:
- Typed data that states no primary type, or one other than the type it
would be signed as, or that cannot be read → shown with the error line
saying so and "Sign" disabled; only "Reject" remains
- "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
+18
View File
@@ -45,6 +45,24 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-03: The typed-data signing screen warns for a token permission, and
names the primary type ethers signs
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2
signature lets its spender take tokens from the signer's address, and the
screen listed it as plain key/value lines, exactly like a sign-in message. For
typed data signed as `Permit` (EIP-2612's, DAI's older one, or any other of
that name) or as one of Permit2's six signature types,
`src/popup/views/approval.js` now shows a red warning at the top of the
message naming the spender and each token and amount, read only from the
fields the signed type declares, with `Unlimited` for the largest amount the
field holds, the existing unknown-scale wording otherwise, and `Unknown` for
whatever those fields do not give. The screen printed the page's
`primaryType`, but ethers signs the type it derives from `types`; the screen
now shows the derived type, and typed data whose stated type is missing or
differs, or that cannot be read, is shown with an error line and Sign
disabled, and is refused again where signing starts. The warning names no
deadline or expiry: those fields mean different things across the shapes, and
a date could read as the permission ending when it does not.
- 2026-09-21: The network fee a transaction can commit is bounded by the product
of the gas limit and the fee per gas, not by each field alone, and the
wallet's own send is bounded the same way
+256 -23
View File
@@ -14,9 +14,13 @@ const { networkByChainId } = require("../../shared/networks");
const {
formatEther,
formatUnits,
getAddress,
getBigInt,
getBytes,
Interface,
MaxUint256,
toUtf8String,
TypedDataEncoder,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
@@ -391,38 +395,244 @@ function decodeHexMessage(hex) {
}
}
function formatTypedDataHtml(jsonStr) {
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a signature request cannot be signed, as a sentence for the error line,
// or null when it can. Only typed data is refused: the `primaryType` the page
// states has to be the type ethers will sign, or this screen would name one
// message while another is signed.
function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data;
let signed;
try {
const data = JSON.parse(jsonStr);
let html = "";
data = JSON.parse(sp.typedData);
signed = signedPrimaryType(data.types);
} catch {
return "This typed data cannot be read, so it cannot be signed.";
}
if (!data.primaryType) {
return "This typed data does not name its primary type, so it cannot be signed.";
}
if (data.primaryType !== signed) {
return (
"This typed data names its primary type as " +
data.primaryType +
", but it would be signed as " +
signed +
", so it cannot be signed."
);
}
return null;
}
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
}
html += `</div>`;
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
// it as an allowance that is never used up.
const MAX_UINT160 = (1n << 160n) - 1n;
// One field of a struct as typed data signs it: the field's declared type and
// the struct's value for it, or null when the struct's type declares no field
// of that name. ethers signs only the fields a type declares and drops every
// other key, so a key the page adds beside them is never read here.
function declaredField(types, typeName, struct, name) {
const field = (types[typeName] || []).find((f) => f.name === name);
if (!field || !struct || typeof struct !== "object") return null;
return { type: field.type, value: struct[name] };
}
// The tokens and amounts a Permit2 message grants, from its `details` or
// `permitted` field: one struct of `token` and `amount`, or a list of them,
// as the field's declared type says. When the field cannot be read the one
// grant returned has no token or amount, so the warning still lists it.
function permit2Grants(types, primaryType, message, name, max) {
const field = declaredField(types, primaryType, message, name);
if (!field) return [{ max }];
// `PermitDetails` is one grant, `PermitDetails[]` a list of them.
const itemType = field.type.replace(/\[\d*\]$/, "");
const items = itemType === field.type ? [field.value] : field.value;
if (!Array.isArray(items)) return [{ max }];
return items.map((item) => ({
token: declaredField(types, itemType, item, "token")?.value,
amount: declaredField(types, itemType, item, "amount")?.value,
max,
}));
}
// The address or number a permission field holds, or null when it holds
// none; the warning then shows `Unknown` rather than failing.
function addressOrNull(value) {
try {
return getAddress(value);
} catch {
return null;
}
}
function amountOrNull(value) {
try {
return getBigInt(value);
} catch {
return null;
}
}
// A warning for typed data that lets a spender take your tokens, naming the
// spender and each token and amount, or "" for any other typed data. These
// signatures are how most wallet drains are done, and as plain key/value
// lines they read exactly like a sign-in message. They are recognised by the
// type ethers signs, `Permit` or one of Permit2's six signature types: a
// contract checks the type's exact name, so a renamed copy of one of these
// would not be honoured. Everything named is read only from the fields that
// type declares, and whatever they do not give is shown as `Unknown`.
function permitWarningHtml(types, primaryType, domain, message) {
// Each entry is a token, the amount, and the largest value its amount
// field holds, which the contract treats as unlimited.
let grants;
switch (primaryType) {
case "Permit": {
// EIP-2612 declares a `value`. DAI's older permit, signed under
// the same name, declares only `allowed`: unlimited, or nothing.
// Others, such as the permit for a Uniswap v3 position, declare
// neither, and their amount is unknown.
const value = declaredField(types, primaryType, message, "value");
const allowed = declaredField(
types,
primaryType,
message,
"allowed",
);
let amount;
if (value) amount = value.value;
else if (allowed) amount = allowed.value ? MaxUint256 : 0n;
grants = [
{ token: domain?.verifyingContract, amount, max: MaxUint256 },
];
break;
}
case "PermitSingle":
case "PermitBatch":
grants = permit2Grants(
types,
primaryType,
message,
"details",
MAX_UINT160,
);
break;
case "PermitTransferFrom":
case "PermitWitnessTransferFrom":
case "PermitBatchTransferFrom":
case "PermitBatchWitnessTransferFrom":
grants = permit2Grants(
types,
primaryType,
message,
"permitted",
MaxUint256,
);
break;
default:
return "";
}
if (data.primaryType) {
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
const sources = {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
};
const spender = addressOrNull(
declaredField(types, primaryType, message, "spender")?.value,
);
let html = `<div class="mb-2 p-2 font-bold bg-red-100 text-red-800 border-2 border-red-600 rounded-md">`;
html += `<div class="mb-2">⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens listed here from your address, without asking you again.</div>`;
html += `<div class="mb-2"><div>Spender</div>`;
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
html += `</div>`;
for (const grant of grants) {
const token = addressOrNull(grant.token);
const amount = amountOrNull(grant.amount);
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
// or base units when nothing knows the token's scale.
let amountText = "Unknown";
if (amount === grant.max) {
amountText = "Unlimited";
} else if (amount !== null && token === null) {
amountText = unknownDecimalsAmount(amount);
} else if (amount !== null) {
amountText = tokenAmountText(
amount,
resolveTokenDecimals(token, sources),
tokenLabel(token),
).display;
}
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
const display =
typeof val === "object" ? JSON.stringify(val) : String(val);
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display)}</span></div>`;
}
html += `</div>`;
html += `<div class="mb-2"><div>Token</div>`;
if (token) {
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`;
html += approvalAddressHtml(token);
} else {
html += `<div>Unknown</div>`;
}
html += `</div>`;
html += `<div class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
}
html += `</div>`;
return html;
}
return html;
// The typed data as the screen shows it. The primary type shown is the one
// ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. Only typed data that cannot
// be read at all is shown as raw text, and typedDataRefusal() refuses it.
function formatTypedDataHtml(jsonStr) {
let data;
let primaryType;
try {
data = JSON.parse(jsonStr);
primaryType = signedPrimaryType(data.types);
} catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
}
let html = permitWarningHtml(
data.types,
primaryType,
data.domain,
data.message,
);
// A value that is an object is shown as JSON: String() of it says
// nothing, and throws for some objects a page can send.
const display = (val) =>
typeof val === "object" ? JSON.stringify(val) : String(val);
if (data.domain) {
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
for (const [key, val] of Object.entries(data.domain)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
}
html += `</div>`;
}
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
if (data.message) {
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
for (const [key, val] of Object.entries(data.message)) {
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
}
html += `</div>`;
}
return html;
}
function showSignApproval(details) {
@@ -477,6 +687,13 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) {
showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true;
$("btn-approve-sign").classList.add("text-muted");
return;
}
gateOnWalletDefect(
"approve-sign-error",
"btn-approve-sign",
@@ -782,6 +999,16 @@ function init(_ctx) {
return;
}
// Checked again where the signing starts, not only when the screen
// was drawn, and the button stays disabled: this request can never be
// signed.
const refusal = typedDataRefusal(pendingSignParams);
if (refusal) {
password = null;
showError("approve-sign-error", refusal);
return;
}
// Decrypt here, in the popup. The password must never cross the
// extension messaging boundary; only the signature does.
let decryptedSecret;
@@ -873,4 +1100,10 @@ function init(_ctx) {
});
}
module.exports = { init, show, decodeCalldata };
module.exports = {
init,
show,
decodeCalldata,
formatTypedDataHtml,
typedDataRefusal,
};
+441
View File
@@ -0,0 +1,441 @@
// 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("<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",
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);
});
});