harden: warn for token-permission typed data and show the primary type ethers signs (closes #400) #414
@@ -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
|
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
|
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
|
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
|
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
||||||
unbounded allowance or permit needs no scale to describe and is still shown as
|
token permission warning on the signature screen takes the same rule for its
|
||||||
`Unlimited`.
|
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
|
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
|
sources are read as authoritative, so a value written into one of them cannot be
|
||||||
@@ -1801,10 +1802,26 @@ view would leave a wallet one click from deletion.
|
|||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
- 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, except a `Permit`'s token, which is the
|
||||||
|
domain's `verifyingContract`; 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
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **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
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
signature)
|
signature)
|
||||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
|
|||||||
@@ -45,6 +45,25 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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 (a `Permit`'s token is the domain's
|
||||||
|
`verifyingContract`), 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
|
- 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
|
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
|
wallet's own send is bounded the same way
|
||||||
|
|||||||
+248
-14
@@ -14,9 +14,13 @@ const { networkByChainId } = require("../../shared/networks");
|
|||||||
const {
|
const {
|
||||||
formatEther,
|
formatEther,
|
||||||
formatUnits,
|
formatUnits,
|
||||||
|
getAddress,
|
||||||
|
getBigInt,
|
||||||
getBytes,
|
getBytes,
|
||||||
Interface,
|
Interface,
|
||||||
|
MaxUint256,
|
||||||
toUtf8String,
|
toUtf8String,
|
||||||
|
TypedDataEncoder,
|
||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
@@ -391,38 +395,245 @@ 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 {
|
try {
|
||||||
const data = JSON.parse(jsonStr);
|
data = JSON.parse(sp.typedData);
|
||||||
let html = "";
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, except a `Permit`'s token, which is the domain's
|
||||||
|
// `verifyingContract`; 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 "";
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
if (data.domain) {
|
||||||
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
html += `<div class="mb-2"><div class="text-muted">Domain</div>`;
|
||||||
for (const [key, val] of Object.entries(data.domain)) {
|
for (const [key, val] of Object.entries(data.domain)) {
|
||||||
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(String(val))}</div>`;
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> ${escapeHtml(display(val))}</div>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.primaryType) {
|
|
||||||
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
html += `<div class="mb-2"><div class="text-muted">Primary type</div>`;
|
||||||
html += `<div class="font-bold">${escapeHtml(data.primaryType)}</div></div>`;
|
html += `<div class="font-bold">${escapeHtml(primaryType)}</div></div>`;
|
||||||
}
|
|
||||||
|
|
||||||
if (data.message) {
|
if (data.message) {
|
||||||
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
html += `<div class="mb-2"><div class="text-muted">Message</div>`;
|
||||||
for (const [key, val] of Object.entries(data.message)) {
|
for (const [key, val] of Object.entries(data.message)) {
|
||||||
const display =
|
html += `<div><span class="text-muted">${escapeHtml(key)}:</span> <span class="break-all">${escapeHtml(display(val))}</span></div>`;
|
||||||
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>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
} catch {
|
|
||||||
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSignApproval(details) {
|
function showSignApproval(details) {
|
||||||
@@ -477,6 +688,13 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-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(
|
gateOnWalletDefect(
|
||||||
"approve-sign-error",
|
"approve-sign-error",
|
||||||
"btn-approve-sign",
|
"btn-approve-sign",
|
||||||
@@ -782,6 +1000,16 @@ function init(_ctx) {
|
|||||||
return;
|
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
|
// Decrypt here, in the popup. The password must never cross the
|
||||||
// extension messaging boundary; only the signature does.
|
// extension messaging boundary; only the signature does.
|
||||||
let decryptedSecret;
|
let decryptedSecret;
|
||||||
@@ -873,4 +1101,10 @@ function init(_ctx) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { init, show, decodeCalldata };
|
module.exports = {
|
||||||
|
init,
|
||||||
|
show,
|
||||||
|
decodeCalldata,
|
||||||
|
formatTypedDataHtml,
|
||||||
|
typedDataRefusal,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,534 @@
|
|||||||
|
// 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",
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user