Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaaf1593c0 | ||
|
|
add11e57de |
@@ -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**:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user