Compare commits

..
2 Commits
Author SHA1 Message Date
sneak eaaf1593c0 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
2026-10-03 14:38:06 +00:00
clawbot add11e57de security: announce a fresh EIP-6963 provider UUID per page load (closes #398)
check / check (push) Successful in 1m4s
e2e / e2e-chrome (push) Successful in 1m47s
e2e / e2e-firefox (push) Successful in 33s
The provider UUID was generated once, kept in extension storage and
announced to every page on every load, so any site could read it as a
stable identifier for the install across sites and browser restarts.

inpage.js now generates one UUID per page load, shared by every
announcement in that load, and stores nothing. The eip6963Uuid storage
key and the AUTISTMASK_PROVIDER_UUID content-script message are removed.
The key was never part of the versioned autistmask profile, so the state
schema is untouched. Two inpage.js message listeners lose the leftover
name onUuid.

The test posts each load the same stored UUID the way the old content
script did, and asserts that no load announces it and that two loads
announce different UUIDs.

Model: opus-5-5
2026-10-03 16:26:39 +02:00
8 changed files with 535 additions and 143 deletions
+8 -4
View File
@@ -1806,11 +1806,15 @@ view would leave a wallet one click from deletion.
ethers signs, derived from the typed data's `types`, not the type the site ethers signs, derived from the typed data's `types`, not the type the site
states. states.
- Token permission warning, at the top of the message (typed data whose - Token permission warning, at the top of the message (typed data whose
primary type is EIP-2612's `Permit` or one of Permit2's signature types): primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
"⚠️ TOKEN PERMISSION: Signing this lets the spender below take the tokens types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
listed here from your address, without asking you again.", then 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 spender's full address and, for each token, its symbol, full address and
amount (`Unlimited` for the largest amount the field holds) 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 - Password input and an error line
- "Sign" / "Reject" buttons - "Sign" / "Reject" buttons
- **Transitions**: - **Transitions**:
+24 -11
View File
@@ -50,17 +50,19 @@ but the review is broader than any of them.
([#400](https://git.eeqj.de/sneak/AutistMask/issues/400)). A Permit or Permit2 ([#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 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 screen listed it as plain key/value lines, exactly like a sign-in message. For
EIP-2612's `Permit` (and DAI's older permit of the same name) and Permit2's typed data signed as `Permit` (EIP-2612's, DAI's older one, or any other of
six signature types, `src/popup/views/approval.js` now shows a red warning at that name) or as one of Permit2's six signature types,
the top of the message naming the spender and each token and amount, with `src/popup/views/approval.js` now shows a red warning at the top of the
`Unlimited` for the largest amount the field holds and the existing message naming the spender and each token and amount, read only from the
unknown-scale wording otherwise. The screen printed the page's `primaryType`, fields the signed type declares, with `Unlimited` for the largest amount the
but ethers signs the type it derives from `types`; the screen now shows the field holds, the existing unknown-scale wording otherwise, and `Unknown` for
derived type, and typed data whose stated type is missing or differs, or that whatever those fields do not give. The screen printed the page's
cannot be read, is shown with an error line and Sign disabled, and is refused `primaryType`, but ethers signs the type it derives from `types`; the screen
again where signing starts. The warning names no deadline or expiry: those now shows the derived type, and typed data whose stated type is missing or
fields mean different things across the shapes, and a date could read as the differs, or that cannot be read, is shown with an error line and Sign
permission ending when it does not. 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
@@ -159,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 both routes take, so they behave the same and the button is live for the next
delete. 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 - 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 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 wallet list was the reported case: the address shared one row with the
-26
View File
@@ -5,8 +5,6 @@ const {
hasBrowserNamespace, hasBrowserNamespace,
runtimeApi, runtimeApi,
sendMessage, sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi"); } = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared // 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); (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 // Relay requests from the page to the background script
window.addEventListener("message", (event) => { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
+9 -13
View File
@@ -45,7 +45,7 @@
} }
// Listen for responses from the content script // Listen for responses from the content script
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_RESPONSE") return; if (event.data?.type !== "AUTISTMASK_RESPONSE") return;
const { id, result, error } = event.data; const { id, result, error } = event.data;
@@ -60,7 +60,7 @@
}); });
// Listen for events pushed from the extension // Listen for events pushed from the extension
window.addEventListener("message", function onUuid(event) { window.addEventListener("message", (event) => {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type !== "AUTISTMASK_EVENT") return; if (event.data?.type !== "AUTISTMASK_EVENT") return;
const { eventName, data } = event.data; const { eventName, data } = event.data;
@@ -204,7 +204,13 @@
"</svg>", "</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() { function buildProviderInfo() {
return { 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); window.addEventListener("eip6963:requestProvider", announceProvider);
announceProvider(); announceProvider();
+147 -78
View File
@@ -439,63 +439,106 @@ function typedDataRefusal(sp) {
// it as an allowance that is never used up. // it as an allowance that is never used up.
const MAX_UINT160 = (1n << 160n) - 1n; 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 // 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 // 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 // signatures are how most wallet drains are done, and as plain key/value
// lines they read exactly like a sign-in message. The shapes are EIP-2612's // lines they read exactly like a sign-in message. They are recognised by the
// `Permit` and Permit2's six signature types, recognised by the type ethers // type ethers signs, `Permit` or one of Permit2's six signature types: a
// signs: a token contract checks the type's exact name, so a renamed copy of // contract checks the type's exact name, so a renamed copy of one of these
// one of these would not be honoured. // would not be honoured. Everything named is read only from the fields that
function permitWarningHtml(primaryType, domain, message) { // 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 // Each entry is a token, the amount, and the largest value its amount
// field holds, which the contract treats as unlimited. // field holds, which the contract treats as unlimited.
let grants; let grants;
switch (primaryType) { switch (primaryType) {
case "Permit": { case "Permit": {
// EIP-2612 states a `value`. DAI's older permit, signed under the // EIP-2612 declares a `value`. DAI's older permit, signed under
// same name, states only `allowed`: unlimited, or nothing. // the same name, declares only `allowed`: unlimited, or nothing.
let amount = message.value; // Others, such as the permit for a Uniswap v3 position, declare
if ("allowed" in message) { // neither, and their amount is unknown.
amount = message.allowed ? MaxUint256 : 0n; 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 = [ grants = [
{ token: domain.verifyingContract, amount, max: MaxUint256 }, { token: domain?.verifyingContract, amount, max: MaxUint256 },
]; ];
break; break;
} }
case "PermitSingle": case "PermitSingle":
grants = [
{
token: message.details.token,
amount: message.details.amount,
max: MAX_UINT160,
},
];
break;
case "PermitBatch": case "PermitBatch":
grants = message.details.map((d) => ({ grants = permit2Grants(
token: d.token, types,
amount: d.amount, primaryType,
max: MAX_UINT160, message,
})); "details",
MAX_UINT160,
);
break; break;
case "PermitTransferFrom": case "PermitTransferFrom":
case "PermitWitnessTransferFrom": case "PermitWitnessTransferFrom":
grants = [
{
token: message.permitted.token,
amount: message.permitted.amount,
max: MaxUint256,
},
];
break;
case "PermitBatchTransferFrom": case "PermitBatchTransferFrom":
case "PermitBatchWitnessTransferFrom": case "PermitBatchWitnessTransferFrom":
grants = message.permitted.map((p) => ({ grants = permit2Grants(
token: p.token, types,
amount: p.amount, primaryType,
max: MaxUint256, message,
})); "permitted",
MaxUint256,
);
break; break;
default: default:
return ""; return "";
@@ -505,25 +548,39 @@ function permitWarningHtml(primaryType, domain, message) {
trackedTokens: state.trackedTokens, trackedTokens: state.trackedTokens,
wallets: state.wallets, 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">`; 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">⚠️ 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>${approvalAddressHtml(getAddress(message.spender))}</div>`; html += `<div class="mb-2"><div>Spender</div>`;
html += spender ? approvalAddressHtml(spender) : `<div>Unknown</div>`;
html += `</div>`;
for (const grant of grants) { for (const grant of grants) {
const token = getAddress(grant.token); const token = addressOrNull(grant.token);
const amount = getBigInt(grant.amount); const amount = amountOrNull(grant.amount);
// `Unlimited` as on the ERC-20 approve line; otherwise the quantity, // `Unlimited` as on the ERC-20 approve line; otherwise the quantity,
// or base units when nothing knows the token's scale. // or base units when nothing knows the token's scale.
const amountText = let amountText = "Unknown";
amount === grant.max if (amount === grant.max) {
? "Unlimited" amountText = "Unlimited";
: tokenAmountText( } else if (amount !== null && token === null) {
amount, amountText = unknownDecimalsAmount(amount);
resolveTokenDecimals(token, sources), } else if (amount !== null) {
tokenLabel(token), amountText = tokenAmountText(
).display; amount,
resolveTokenDecimals(token, sources),
tokenLabel(token),
).display;
}
html += `<div class="mb-2"><div>Token</div>`; html += `<div class="mb-2"><div>Token</div>`;
html += `<div>${escapeHtml(tokenLabel(token) || "Unknown token")}</div>`; if (token) {
html += `${approvalAddressHtml(token)}</div>`; 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 class="mb-2"><div>Amount</div><div>${escapeHtml(amountText)}</div></div>`;
} }
html += `</div>`; html += `</div>`;
@@ -532,38 +589,50 @@ function permitWarningHtml(primaryType, domain, message) {
// The typed data as the screen shows it. The primary type shown is the one // 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 // ethers signs, never the page's word for it; typedDataRefusal() keeps the two
// from differing on anything that can be signed. // 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) { function formatTypedDataHtml(jsonStr) {
let data;
let primaryType;
try { try {
const data = JSON.parse(jsonStr); data = JSON.parse(jsonStr);
const primaryType = signedPrimaryType(data.types); primaryType = signedPrimaryType(data.types);
let html = permitWarningHtml(primaryType, data.domain, data.message);
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>`;
}
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)) {
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>`;
}
return html;
} catch { } catch {
return `<div class="break-all">${escapeHtml(jsonStr)}</div>`; 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) { function showSignApproval(details) {
+5 -5
View File
@@ -372,10 +372,10 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
STEP_TIMEOUT_MS, STEP_TIMEOUT_MS,
); );
// EIP-6963, asked of the provider itself. The announcement carries the // EIP-6963, asked of the provider itself. The announcement carries a
// uuid src/content/index.js reads out of extension storage — call site 1 // UUIDv4 inpage.js generates fresh for this page load (nothing persists
// in the issue — and it has to name this extension and hand back the very // it — see issue #398) and has to name this extension and hand back the
// object on window.ethereum. // very object on window.ethereum.
const announced = await d.executeAsync( const announced = await d.executeAsync(
`const done = arguments[arguments.length - 1]; `const done = arguments[arguments.length - 1];
const onAnnounce = (e) => { const onAnnounce = (e) => {
@@ -402,7 +402,7 @@ step("the loopback dApp page gets the real inpage provider", async (env) => {
); );
assert( assert(
typeof announced.uuid === "string" && announced.uuid.length === 36, 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), JSON.stringify(announced.uuid),
); );
+118
View File
@@ -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]);
});
});
+224 -6
View File
@@ -7,23 +7,34 @@
// to warn for it, naming the spender and the amount, and must not warn for a // to warn for it, naming the spender and the amount, and must not warn for a
// sign-in message. // 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 // ethers signs the type it derives from `types`, not the page's
// `primaryType`, so the screen names the derived type, and a request whose // `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 // stated type is missing or differs is refused rather than shown under a name
// it is not signed as. // 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 = { globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } }, storage: { local: { get: async () => ({}), set: async () => {} } },
}; };
const { getAddress } = require("ethers"); const { getAddress, MaxUint256 } = require("ethers");
const { state } = require("../src/shared/state"); const { state } = require("../src/shared/state");
const { const { decryptWithPassword } = require("../src/shared/vault");
formatTypedDataHtml, const approval = require("../src/popup/views/approval");
typedDataRefusal, const { formatTypedDataHtml, typedDataRefusal } = approval;
} = require("../src/popup/views/approval");
const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0"); const SPENDER = getAddress("0xbad000000000000000000000000000000000bad0");
const DECOY = getAddress("0xdec0000000000000000000000000000000000dec");
const OWNER = getAddress("0x0000000000000000000000000000000000000a11"); const OWNER = getAddress("0x0000000000000000000000000000000000000a11");
// Bundled, so the symbol and the 6-decimal scale come from the list. // Bundled, so the symbol and the 6-decimal scale come from the list.
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"; const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
@@ -191,6 +202,81 @@ describe("a token permission is warned about, naming spender and amount", () =>
}); });
}); });
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", () => { describe("the primary type shown is the one ethers signs", () => {
// A drain stated as a sign-in: the page says Login, the types say // A drain stated as a sign-in: the page says Login, the types say
// PermitSingle, and ethers would sign PermitSingle. // PermitSingle, and ethers would sign PermitSingle.
@@ -221,3 +307,135 @@ describe("the primary type shown is the one ethers signs", () => {
expect(typedDataRefusal(request(LOGIN))).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);
});
});