Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaaf1593c0 | ||
|
|
add11e57de |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -143,6 +161,17 @@ but the review is broader than any of them.
|
||||
both routes take, so they behave the same and the button is live for the next
|
||||
delete.
|
||||
|
||||
- 2026-09-21: The EIP-6963 provider UUID is generated fresh on each page load
|
||||
and never persisted ([#398](https://git.eeqj.de/sneak/AutistMask/issues/398)).
|
||||
It was created once and stored, then announced verbatim to every page on every
|
||||
load and across restarts, so any site — connected or not — could read it as a
|
||||
stable cross-site, cross-session identifier for the install, contradicting the
|
||||
"no tracking" promise. inpage.js now announces a per-load
|
||||
`crypto.randomUUID()` and the `eip6963Uuid` storage key and the
|
||||
`AUTISTMASK_PROVIDER_UUID` content-script message are gone. That key was a
|
||||
standalone storage entry, never part of the versioned `autistmask` profile, so
|
||||
the state schema is untouched and no existing profile is affected.
|
||||
|
||||
- 2026-08-30: An address no longer wraps, or is shortened to fit, in any of the
|
||||
common views ([#380](https://git.eeqj.de/sneak/AutistMask/issues/380)). The
|
||||
wallet list was the reported case: the address shared one row with the
|
||||
|
||||
@@ -5,8 +5,6 @@ const {
|
||||
hasBrowserNamespace,
|
||||
runtimeApi,
|
||||
sendMessage,
|
||||
storageGet,
|
||||
storageSet,
|
||||
} = require("../shared/browserApi");
|
||||
|
||||
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
|
||||
@@ -21,30 +19,6 @@ if (hasBrowserNamespace()) {
|
||||
(document.head || document.documentElement).appendChild(script);
|
||||
}
|
||||
|
||||
// Send the persisted EIP-6963 provider UUID to the inpage script.
|
||||
// Generated once at install time and stored in extension storage.
|
||||
(async function sendProviderUuid() {
|
||||
let uuid = null;
|
||||
try {
|
||||
const items = await storageGet("eip6963Uuid");
|
||||
uuid = items?.eip6963Uuid;
|
||||
if (!uuid) {
|
||||
uuid = crypto.randomUUID();
|
||||
await storageSet({ eip6963Uuid: uuid });
|
||||
}
|
||||
} catch {
|
||||
// Storage was unavailable or refused the write. The announcement
|
||||
// still has to go out — a provider that never announces is invisible
|
||||
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
|
||||
// page load will not outlive.
|
||||
if (!uuid) uuid = crypto.randomUUID();
|
||||
}
|
||||
window.postMessage(
|
||||
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
|
||||
location.origin,
|
||||
);
|
||||
})();
|
||||
|
||||
// Relay requests from the page to the background script
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
|
||||
+9
-13
@@ -45,7 +45,7 @@
|
||||
}
|
||||
|
||||
// Listen for responses from the content script
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
|
||||
const { id, result, error } = event.data;
|
||||
@@ -60,7 +60,7 @@
|
||||
});
|
||||
|
||||
// Listen for events pushed from the extension
|
||||
window.addEventListener("message", function onUuid(event) {
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_EVENT") return;
|
||||
const { eventName, data } = event.data;
|
||||
@@ -204,7 +204,13 @@
|
||||
"</svg>",
|
||||
);
|
||||
|
||||
let providerUuid = crypto.randomUUID(); // fallback until real UUID arrives
|
||||
// EIP-6963 asks for one UUIDv4 per provider for the life of the page: one
|
||||
// per page load, shared by every announcement in that load. It is
|
||||
// generated here and never stored: announcing one persisted value to every
|
||||
// site, on every load and across restarts, turned it into a stable
|
||||
// cross-site, cross-session tracking identifier any page could read
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/398).
|
||||
const providerUuid = crypto.randomUUID();
|
||||
|
||||
function buildProviderInfo() {
|
||||
return {
|
||||
@@ -226,16 +232,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
// Listen for the persisted UUID from the content script
|
||||
function onProviderUuid(event) {
|
||||
if (event.source !== window) return;
|
||||
if (event.data?.type !== "AUTISTMASK_PROVIDER_UUID") return;
|
||||
window.removeEventListener("message", onProviderUuid);
|
||||
providerUuid = event.data.uuid;
|
||||
announceProvider();
|
||||
}
|
||||
window.addEventListener("message", onProviderUuid);
|
||||
|
||||
window.addEventListener("eip6963:requestProvider", announceProvider);
|
||||
announceProvider();
|
||||
|
||||
|
||||
+256
-23
@@ -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,
|
||||
};
|
||||
|
||||
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
STEP_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
// EIP-6963, asked of the provider itself. The announcement carries the
|
||||
// uuid src/content/index.js reads out of extension storage — call site 1
|
||||
// in the issue — and it has to name this extension and hand back the very
|
||||
// object on window.ethereum.
|
||||
// EIP-6963, asked of the provider itself. The announcement carries a
|
||||
// UUIDv4 inpage.js generates fresh for this page load (nothing persists
|
||||
// it — see issue #398) and has to name this extension and hand back the
|
||||
// very object on window.ethereum.
|
||||
const announced = await d.executeAsync(
|
||||
`const done = arguments[arguments.length - 1];
|
||||
const onAnnounce = (e) => {
|
||||
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
|
||||
);
|
||||
assert(
|
||||
typeof announced.uuid === "string" && announced.uuid.length === 36,
|
||||
"the announcement carries no stored provider uuid: " +
|
||||
"the announcement carries no provider uuid: " +
|
||||
JSON.stringify(announced.uuid),
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// The EIP-6963 provider UUID inpage.js announces (src/content/inpage.js).
|
||||
//
|
||||
// The bug this pins down (issue #398): the UUID used to be generated once,
|
||||
// persisted in extension storage, and announced verbatim to every page on
|
||||
// every load and across browser restarts, so any site — connected or not —
|
||||
// could read a stable cross-site, cross-session identifier for the install.
|
||||
// EIP-6963 asks for one UUIDv4 per page load, shared by every announcement in
|
||||
// that load. The fix generates it per page load and stores nothing.
|
||||
//
|
||||
// The stored UUID reached inpage.js as an AUTISTMASK_PROVIDER_UUID page
|
||||
// message from the content script, and inpage.js then announced it. Each load
|
||||
// below is posted the same stored UUID that way, so on the old code both loads
|
||||
// announce it and the last two tests fail.
|
||||
//
|
||||
// inpage.js is a bare IIFE injected into the page's JS context, not a module;
|
||||
// see tests/inpageErrors.test.js for why it is evaluated against a stub window
|
||||
// rather than imported. Here the stub captures the CustomEvent that carries
|
||||
// the announcement, so the UUID this file reads is the one a real dApp's
|
||||
// eip6963:announceProvider listener would see.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { webcrypto } = require("crypto");
|
||||
|
||||
const SOURCE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "src", "content", "inpage.js"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const loadInto = new Function(
|
||||
"window",
|
||||
"self",
|
||||
"crypto",
|
||||
"Event",
|
||||
"CustomEvent",
|
||||
SOURCE,
|
||||
);
|
||||
|
||||
class StubEvent {
|
||||
constructor(type) {
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
class StubCustomEvent extends StubEvent {
|
||||
constructor(type, init) {
|
||||
super(type);
|
||||
this.detail = init && init.detail;
|
||||
}
|
||||
}
|
||||
|
||||
// What the old content script read out of extension storage and posted to
|
||||
// every page load.
|
||||
const STORED_UUID = "11111111-2222-4333-8444-555555555555";
|
||||
|
||||
// Evaluate inpage.js once against a fresh stub window, post it STORED_UUID the
|
||||
// way the old content script did, then dispatch a `requestProvider` event so a
|
||||
// re-announcement is observed as well as the announcement at load. Returns
|
||||
// every UUID the load announced, in order.
|
||||
function announcedUuids() {
|
||||
const listeners = {};
|
||||
const uuids = [];
|
||||
|
||||
const win = {
|
||||
addEventListener(type, fn) {
|
||||
(listeners[type] || (listeners[type] = [])).push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
const fns = listeners[type];
|
||||
if (!fns) return;
|
||||
const i = fns.indexOf(fn);
|
||||
if (i !== -1) fns.splice(i, 1);
|
||||
},
|
||||
postMessage() {},
|
||||
dispatchEvent(event) {
|
||||
if (event.type === "eip6963:announceProvider") {
|
||||
uuids.push(event.detail.info.uuid);
|
||||
}
|
||||
for (const fn of (listeners[event.type] || []).slice()) fn(event);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
win.window = win;
|
||||
|
||||
loadInto(win, win, webcrypto, StubEvent, StubCustomEvent);
|
||||
win.dispatchEvent({
|
||||
type: "message",
|
||||
source: win,
|
||||
data: { type: "AUTISTMASK_PROVIDER_UUID", uuid: STORED_UUID },
|
||||
});
|
||||
win.dispatchEvent(new StubEvent("eip6963:requestProvider"));
|
||||
return uuids;
|
||||
}
|
||||
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
describe("the EIP-6963 provider UUID is fresh per page load", () => {
|
||||
test("a load announces a UUIDv4", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(0);
|
||||
expect(uuids[0]).toMatch(UUID_V4);
|
||||
});
|
||||
|
||||
test("every announcement within one load carries the same UUID", () => {
|
||||
const uuids = announcedUuids();
|
||||
expect(uuids.length).toBeGreaterThan(1);
|
||||
expect(new Set(uuids).size).toBe(1);
|
||||
});
|
||||
|
||||
test("two page loads announce different UUIDs, neither the stored one", () => {
|
||||
const first = announcedUuids();
|
||||
const second = announcedUuids();
|
||||
expect(first).not.toContain(STORED_UUID);
|
||||
expect(second).not.toContain(STORED_UUID);
|
||||
expect(second[0]).not.toBe(first[0]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user