Compare commits

...
2 Commits
Author SHA1 Message Date
sneak e791e06fb1 fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 3s
e2e / e2e-firefox (push) Failing after 2s
A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already runs the check loadState()
runs at open; a save refused by it now raises the recovery screen, stops the
ten-second refresh, and passes the screen to showView(), which runs the leave
cleanup of the screen it replaces and records it as the current view. From
then on showView() shows nothing else, so a transaction wait or a later save
cannot take the user off it or clear an export or a typed confirmation. Any
other failed save keeps the "NOT SAVED" banner. The popup test harness now
honours clearInterval().

Model: opus-5-5
2026-10-04 20:21:04 +00:00
clawbot 375998beaf harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
The signature screen showed only the text a personal message decodes
to, with bidirectional, right-to-left and zero-width characters acting
on it, so a site could make the message read differently from the
bytes that are signed, and a message that was not hex was decoded into
NUL characters. The screen now shows the hex as "Raw data" alongside
the text, lays the text out left to right in byte order, and shows each
control character, line and paragraph separator, and character that
paints nothing (the set src/shared/symbolSpoof.js already strips) as a
U+XXXX mark. A message is hex when getBytes, which signing uses, reads
it; one that is not cannot be signed, so it is shown as plain text with
"Sign" disabled.

Model: opus-5-5
2026-10-04 22:09:07 +02:00
13 changed files with 666 additions and 47 deletions
+58 -23
View File
@@ -1114,16 +1114,18 @@ because bumping for one would send every older install to StateRecovery for
nothing.
Every read of the record goes through `assertStateUsable()` first, on the raw
bytes, before normalization: `loadState()` for the popup and `getState()` for
the background. It refuses a record that is not an object, a `schemaVersion`
this build does not understand (a newer one included), a `wallets` that is not a
list of wallet records with address records in them, and a `networkId` that is
not a network in `src/shared/networks.js`. Refusing is the whole point — a
record the wallet cannot vouch for is never normalized, never written back, and
never half-loaded. The popup shows StateRecovery; a dApp gets a specific error
(`-32007`, an EIP-1474 server-error code the spec leaves unassigned) saying the
saved data cannot be read and that nothing was signed or sent, rather than the
generic `-32603` every request used to answer.
bytes, before normalization: `loadState()` and every `saveState()` for the
popup, and `getState()` for the background. It refuses a record that is not an
object, a `schemaVersion` this build does not understand (a newer one included),
a `wallets` that is not a list of wallet records with address records in them,
and a `networkId` that is not a network in `src/shared/networks.js`. Refusing is
the whole point — a record the wallet cannot vouch for is never normalized,
never written back, and never half-loaded. The popup shows StateRecovery,
whether it finds the record unreadable when it opens or at a save while it is
open; a dApp gets a specific error (`-32007`, an EIP-1474 server-error code the
spec leaves unassigned) saying the saved data cannot be read and that nothing
was signed or sent, rather than the generic `-32603` every request used to
answer.
Every other field of the record is floored in `normalizePersisted()` rather than
gated, and the floor is not the same for every field. Some are type-checked as a
@@ -1167,8 +1169,9 @@ now also reported rather than swallowed: `onSaveFailure()` in
`src/shared/state.js` is called for every failed save, awaited or not, and the
popup puts up a persistent "NOT SAVED" banner (`showSaveFailureBanner()` in
`src/popup/views/helpers.js`). Storage can still fail for reasons no floor
covers — a quota, a revoked permission, a record a newer build wrote — and the
wallet must never look healthy while that is true.
covers — a quota, a revoked permission — and the wallet must never look healthy
while that is true. A save that fails because the stored record fails the gate,
such as one a newer build wrote, gets StateRecovery instead of the banner.
The `networkId` check is not cosmetic: that value is an object KEY into
`state.networkEndpoints`, so an unvalidated `"__proto__"` would set the map's
@@ -1930,10 +1933,19 @@ view would leave a wallet one click from deletion.
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- 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). The primary type shown is the one
ethers signs, derived from the typed data's `types`, not the type the site
states.
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
decode to as UTF-8, laid out left to right in the order of the bytes that
are signed, right-to-left characters included. Each control character,
each line or paragraph separator (U+2028, U+2029; left in the text, a
paragraph separator would end that layout for the text after it), and each
character that paints nothing (format characters such as zero-width and
bidirectional ones, default-ignorable characters such as variation
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
mark instead of acting on the text; a line feed is shown as a line break.
Bytes that are not UTF-8 are shown as "This message is not text." For
typed data, formatted domain/type/message fields (EIP-712). 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
@@ -1945,12 +1957,20 @@ view would leave a wallet one click from deletion.
domain's `verifyingContract`; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text.
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
the site sent it. The bytes it encodes are what is signed, as an EIP-191
personal message.
- 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
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
an even number of hex digits, the form ethers' `getBytes` reads when
signing) → shown as plain text, with the error line "This message is plain
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
the bytes from the hex, so such a message has none to sign
- "Sign" (correct password) → signs locally → closes popup (returns
signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen
@@ -1961,9 +1981,20 @@ view would leave a wallet one click from deletion.
#### StateRecovery (`state-recovery`)
- **When**: `loadState()` refused the stored profile, so the popup has no
profile at all. It is the only screen reached without one, and the only one
that never appears during ordinary use.
- **When**: the stored profile fails `assertStateUsable()`. At open, that is
`loadState()` refusing it, so the popup has no profile at all. While the popup
is open, on any screen, it is a save refusing it: every `saveState()` reads
the stored record and runs the same check before writing, so the popup finds
it at the next navigation, or at the next ten-second balance refresh that
reaches the network ([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)).
A save that fails for any other reason, such as a storage read or write that
errors, gets the "NOT SAVED" banner instead and leaves the screen as it is.
The screen it replaces is left as any navigation leaves it, so a revealed
phrase or key, or a typed password, is wiped. Once up, the screen stays until
the popup closes or the record is erased: work still running in the popup,
such as a transaction wait, cannot replace it, and nothing in AutistMask
writes over a record that fails the check, so it cannot become readable again
underneath. It is the only screen that never appears during ordinary use.
- **Why it exists**: a record the wallet cannot read used to render nothing — no
view, no message, no control — while every dApp call answered a generic
internal error, and no reset or wipe control existed anywhere in the product.
@@ -1990,16 +2021,20 @@ view would leave a wallet one click from deletion.
Nothing was erased." on the error line
- **No other control is reachable.** The Settings gear is hidden while this
screen is up, because every screen behind it renders from the profile that
could not be read, and `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton.
could not be read. At open `showView()` is not used to raise it for the same
reason — it reads and writes the state singleton. Under an open popup it is
also passed to `showView()`, which runs the replaced screen's cleanup and
records it as the current view, and `showView()` shows nothing else after
that.
- **Both controls are required.** An export with no reset leaves the user
looking at a broken profile with no way to use the wallet again; a reset with
no export destroys the only copy of a record that may hold recoverable key
material. The typed phrase is the same barrier DeleteWalletLostPassword uses,
and for the same reason: there is no password to gate this with, since there
is no profile to check one against.
- Not in `RESTORABLE_VIEWS`: it is never persisted as the current view, because
nothing on this path writes state at all.
- Not in `RESTORABLE_VIEWS`, and not persisted as the current view: at open
nothing on this path writes state at all, and under an open popup the check
that raised the screen refuses the save that would record it.
### External Services
+22
View File
@@ -45,6 +45,28 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A popup that is already open when the stored profile becomes
unreadable moves to the recovery screen
([#373](https://git.eeqj.de/sneak/AutistMask/issues/373)). It used to stay on
the last good profile, with the "NOT SAVED" banner at most, until reopened.
Every save already ran the check the popup runs at open; a save that fails
that check now raises the recovery screen and stops the ten-second refresh, so
the popup finds the record at the next navigation or refresh. The screen it
replaces is left as any navigation leaves it, so a revealed phrase or key or a
typed password is wiped. Once up, nothing else can replace it, and a later
save or a transaction wait that ends does not clear an export or a typed
confirmation. Any other failed save still gets the banner and leaves the
screen alone.
- 2026-10-04: The signature screen shows a personal message as the bytes that
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
showed only the decoded text, with bidirectional and zero-width characters
acting on it, so a site could make the message read differently from what is
signed, and a message that was not hex was shown as NUL characters. The hex is
now shown as "Raw data" alongside the decoded text, the text is laid out left
to right in byte order, control characters, line and paragraph separators and
characters that paint nothing are shown as `U+XXXX` marks, and a message that
is not hex by the rule signing reads it with is shown as plain text with
"Sign" disabled, since such a message has no bytes to sign.
- 2026-10-04: A token that reports more than 80 decimal places has no known
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
+9
View File
@@ -1698,6 +1698,15 @@
></div>
</div>
<div id="approve-sign-hex-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="approve-sign-hex"
class="text-xs break-all"
style="max-height: 6rem; overflow-y: auto"
></div>
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
+27 -2
View File
@@ -50,6 +50,10 @@ function renderWalletList() {
let refreshInFlight = false;
// The ten-second refresh init() starts, stopped when the popup moves to the
// recovery screen: there is no profile left to refresh.
let refreshTimer = null;
async function doRefreshAndRender() {
if (refreshInFlight) return;
refreshInFlight = true;
@@ -155,7 +159,28 @@ async function init() {
// reported rather than being swallowed by the save queue
// (https://git.eeqj.de/sneak/AutistMask/issues/362). Registered ahead of
// the approval-window branch below too, since that window saves as well.
onSaveFailure(showSaveFailureBanner);
//
// Every save first reads the stored record and refuses it with the same
// check loadState() runs below. So a record that becomes unreadable while
// the popup is open is found by the next save, a navigation or the
// ten-second refresh, and gets the screen it would get at open
// (https://git.eeqj.de/sneak/AutistMask/issues/373). The recovery screen
// is then also passed to showView(), which runs the leave cleanup of the
// screen it replaces, so a phrase, key or password on it is wiped, and
// records it as the current view, after which showView() shows nothing
// else. The save showView() fires fails too and comes back here, where
// both calls see the screen already up and do nothing. Any other failed
// save is a read or write that failed, and gets the banner without
// changing the screen.
onSaveFailure((e) => {
if (e instanceof StateUnusableError) {
clearInterval(refreshTimer);
stateRecovery.show(e);
showView("state-recovery");
} else {
showSaveFailureBanner(e);
}
});
try {
await loadState();
} catch (e) {
@@ -244,7 +269,7 @@ async function init() {
renderWalletList();
restoreView();
doRefreshAndRender();
setInterval(doRefreshAndRender, 10000);
refreshTimer = setInterval(doRefreshAndRender, 10000);
}
}
+10
View File
@@ -64,3 +64,13 @@ body {
white-space: nowrap;
overflow-x: auto;
}
/* A personal message on the signature screen is laid out left to right in
* the order of its bytes. Without this, right-to-left characters in it move
* the characters around them: `5`, U+05C3, `00` would read as `500`
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
* the text after it, so src/popup/views/approval.js shows one as a mark. */
.am-byte-order {
direction: ltr;
unicode-bidi: bidi-override;
}
+57 -11
View File
@@ -26,6 +26,7 @@ const {
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const {
resolveTokenDecimals,
resolveTokenSymbol,
@@ -380,20 +381,48 @@ function showTxApproval(details) {
);
}
// Whether a personal message is hex by the rule signing reads it with:
// signing takes getBytes(message), which throws on anything else.
function isHexMessage(message) {
try {
getBytes(message);
return true;
} catch {
return false;
}
}
// The text the hex message's bytes decode to as UTF-8, or null when they are
// not UTF-8. The caller has checked that the message is hex.
function decodeHexMessage(hex) {
try {
const bytes = Uint8Array.from(
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
return toUtf8String(getBytes(hex));
} catch {
return null;
}
}
// A character shown as a bordered U+XXXX mark.
function codePointMark(c) {
const code = c.codePointAt(0).toString(16).toUpperCase();
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
}
// The text as HTML, with each character that paints nothing (zero-width and
// bidirectional characters, variation selectors and Hangul fillers among
// them), each control character and each line or paragraph separator
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
// Left in the text, a paragraph separator would end the byte-order layout
// for everything after it. The marks are plain ASCII, so the second pass
// leaves them be.
function markInvisibleCharacters(text) {
return escapeHtml(text)
.replace(INVISIBLE_CHARACTERS, codePointMark)
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
c === "\n" ? "<br>" : codePointMark(c),
);
}
// 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
@@ -657,15 +686,33 @@ function showSignApproval(details) {
? "Typed data (EIP-712)"
: "Personal message";
// A personal message is signed as the bytes its hex encodes, so the hex
// is shown as well as any text it decodes to, and that text is laid out
// left to right in the order of its bytes. Signing reads the bytes from
// the hex, so a message that is not hex cannot be signed: it is shown as
// the text it is, and refused.
let refusal = null;
$("approve-sign-hex-section").classList.add("hidden");
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else {
refusal = typedDataRefusal(sp);
} else if (isHexMessage(sp.message)) {
const decoded = decodeHexMessage(sp.message);
if (decoded !== null) {
$("approve-sign-message").textContent = decoded;
$("approve-sign-message").innerHTML =
markInvisibleCharacters(decoded);
} else {
$("approve-sign-message").textContent = sp.message;
$("approve-sign-message").textContent = "This message is not text.";
}
$("approve-sign-hex").textContent = sp.message;
$("approve-sign-hex-section").classList.remove("hidden");
} else {
$("approve-sign-message").innerHTML = markInvisibleCharacters(
sp.message,
);
refusal =
"This message is plain text, not hex, so it cannot be signed.";
}
// Display danger warning for eth_sign (raw hash signing)
@@ -687,7 +734,6 @@ 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;
+9 -3
View File
@@ -52,9 +52,10 @@ const VIEWS = [
"export-privkey",
"show-phrase",
// Shown by src/popup/views/stateRecovery.js when the stored profile
// cannot be read. It is never reached through showView() — by then the
// state singleton this file writes on every navigation refuses to be read
// — but it is listed so that every view-hiding loop covers it.
// cannot be read. At open it is not reached through showView(), since the
// state singleton refuses to be read; under an open popup,
// src/popup/index.js also passes it to showView() so the screen it
// replaces is left like any other.
"state-recovery",
];
@@ -86,6 +87,11 @@ function hideError(id) {
}
function showView(name) {
// The recovery screen, once up, is never replaced: work still running
// when it went up, such as a transaction wait, must not take the user off
// it or clear what they exported or typed there
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
if (state.currentView === "state-recovery") return;
const leaving = state.currentView;
if (leaving && leaving !== name) {
const onLeave = viewLeaveHandlers.get(leaving);
+10 -2
View File
@@ -3,8 +3,11 @@
// Everything else in the popup assumes a loaded profile: showView() reads and
// writes the state singleton, every view renders from it, and the Settings
// gear leads to a screen that does both. None of that is available here — by
// the time this runs, loadState() has REFUSED, deliberately, and reading the
// singleton throws (https://git.eeqj.de/sneak/AutistMask/issues/311).
// the time this runs, the stored record has been REFUSED, deliberately: at
// open loadState() refused it and reading the singleton throws
// (https://git.eeqj.de/sneak/AutistMask/issues/311), and under an open popup
// a save refused it, so every further save fails too
// (https://git.eeqj.de/sneak/AutistMask/issues/373).
//
// So this module talks to the DOM directly and touches no state at all. It is
// the one screen that must work when nothing else can, which is also why it
@@ -170,6 +173,11 @@ function wire() {
* refused, or its sentence.
*/
function show(problem) {
// Already up: a later save that trips over the same record, such as a
// refresh that was in flight when the screen went up, must not clear what
// the user has exported or typed here.
if (!$("view-state-recovery").classList.contains("hidden")) return;
const sentence =
(problem && (problem.problem || problem.message)) || String(problem);
+7 -1
View File
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase();
}
// The characters that paint nothing; normalizeSymbol below says which they
// are. The signature screen marks them in a personal message
// (src/popup/views/approval.js).
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
// Fold a symbol onto what a user actually sees, and no further:
//
// NFKC collapses compatibility variants that render as the ASCII
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
function normalizeSymbol(symbol) {
return String(symbol || "")
.normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
.replace(INVISIBLE_CHARACTERS, "")
.trim()
.toUpperCase();
}
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
}
module.exports = {
INVISIBLE_CHARACTERS,
isSpoofedSymbol,
};
+58
View File
@@ -3232,6 +3232,64 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
);
});
// A right-to-left character must not move the characters around it: U+05C3
// between "5" and "00" would otherwise put "500" on screen before it. A
// paragraph separator (U+2029) before it, left in the text, would end the
// byte-order layout and bring that back
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
const rightToLeft = String.fromCodePoint(0x05c3);
const text =
"Sign in" +
String.fromCodePoint(0x2029) +
"Pay 5" +
rightToLeft +
"00 ETH";
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
hexlify(toUtf8Bytes(text)),
env.expectedAddress,
]);
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
// The text on screen, marks included, and the left edge of each of its
// characters, in byte order. A character the browser's fonts draw with
// no width shares its neighbour's edge.
const shown = await popup.evaluate(() => {
const message = document.getElementById("approve-sign-message");
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
const range = document.createRange();
let text = "";
const lefts = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (let i = 0; i < node.length; i++) {
range.setStart(node, i);
range.setEnd(node, i + 1);
lefts.push(range.getBoundingClientRect().left);
}
text += node.data;
}
return { text, lefts };
});
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
"sign-bidi",
"the byte-order personal_sign rejection",
);
assert(
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
"the paragraph separator is not shown as a mark: " +
JSON.stringify(shown.text),
);
assert(
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
"the personal message is not laid out in byte order: " +
JSON.stringify(shown.lefts),
);
});
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
env.expectedAddress,
+229
View File
@@ -0,0 +1,229 @@
// The signature prompt shows a personal message as the bytes that are signed
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
// text it decodes to with control characters, line and paragraph separators
// and characters that paint nothing marked rather than obeyed, markup shown as
// text, laid out in byte order, and a message that is not hex as plain text
// that cannot be signed.
//
// Driven against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { hexlify, toUtf8Bytes } = require("ethers");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const FROM = "0x0000000000000000000000000000000000000a11";
// Built from their code points so that this file holds none of them.
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
const HANGUL_FILLER = String.fromCodePoint(0x3164);
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
function makeElement(id) {
const classes = new Set();
return {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
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: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
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);
}
// Open the signature prompt for a personal_sign of `message`, the way the
// popup does: it asks the background for the approval and show() draws it.
async function openPersonalSign(message) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
signParams: { method: "personal_sign", message, from: FROM },
});
},
};
approval.init({});
await approval.show(1);
}
// The message box's markup as the text a reader sees: tags dropped.
function shownMessage() {
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
}
beforeEach(() => {
state.wallets = [];
state.activeAddress = FROM;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a right-to-left override is marked, so the text reads in byte order", async () => {
// Obeyed, the override shows "0001" as "1000".
const text =
"Pay " +
RIGHT_TO_LEFT_OVERRIDE +
"0001" +
POP_DIRECTIONAL_FORMATTING +
" ETH";
await openPersonalSign(hexlify(toUtf8Bytes(text)));
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
});
test("a zero-width character is marked", async () => {
await openPersonalSign(
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
);
expect(node("approve-sign-message").innerHTML).not.toContain(
ZERO_WIDTH_SPACE,
);
expect(shownMessage()).toBe("payU+200Bpal.com");
});
test("variation selectors and a Hangul filler are marked", async () => {
// Each paints nothing, so a page could hide bytes after "Sign in".
await openPersonalSign(
hexlify(
toUtf8Bytes(
"Sign in" +
VARIATION_SELECTOR_1 +
VARIATION_SELECTOR_17 +
HANGUL_FILLER,
),
),
);
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
});
test("the message is laid out in byte order", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
expect(
node("approve-sign-message").classList.contains("am-byte-order"),
).toBe(true);
});
test("a control character other than a line feed is marked", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
expect(shownMessage()).toBe("aU+0000bU+0009c");
});
test("line and paragraph separators are marked", async () => {
// Left in the text, a paragraph separator would end the byte-order
// layout for everything after it.
await openPersonalSign(
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
);
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(LINE_SEPARATOR);
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
expect(shownMessage()).toBe("aU+2028bU+2029");
});
test("a line feed is shown as a line break", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
});
// The message box is written as HTML, so a site's markup has to arrive there
// escaped, as the text it is.
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
test.each([
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
["a message that is not hex", MARKUP],
])("markup in %s is shown as text, not as markup", async (_, message) => {
await openPersonalSign(message);
expect(node("approve-sign-message").innerHTML).toBe(
"&lt;b&gt;x&lt;/b&gt;&lt;img src=x onerror=alert(1)&gt;",
);
});
test("the raw hex is shown alongside the text", async () => {
await openPersonalSign("0x48656c6c6f");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
false,
);
});
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
await openPersonalSign("0X48656C6C6F");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("bytes that are not text are shown only as hex", async () => {
await openPersonalSign("0xff00");
expect(node("approve-sign-message").textContent).toBe(
"This message is not text.",
);
expect(node("approve-sign-hex").textContent).toBe("0xff00");
});
test("a message that is not hex is shown as text and cannot be signed", async () => {
await openPersonalSign("Hello world");
expect(shownMessage()).toBe("Hello world");
expect(node("approve-sign-error").textContent).toBe(
"This message is plain text, not hex, so it cannot be signed.",
);
expect(node("btn-approve-sign").disabled).toBe(true);
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
true,
);
});
+144
View File
@@ -148,6 +148,150 @@ describe("the destructive reset on the recovery screen", () => {
});
});
describe("a popup already open when the stored profile becomes unreadable", () => {
// https://git.eeqj.de/sneak/AutistMask/issues/373. The popup used to stay
// on the wallet list with the last good balances, and only a reopen
// reached the recovery screen.
test("moves to the recovery screen at its next refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
expect(env.visibleViews()).toEqual(["main"]);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("state-recovery-problem").length).toBeGreaterThan(10);
expect(env.hidden("btn-settings")).toBe(true);
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
});
test("stops the ten-second refresh", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
const { refreshBalances } = require("../src/shared/balances");
const calls = refreshBalances.mock.calls.length;
await env.tick();
expect(refreshBalances).toHaveBeenCalledTimes(calls);
});
test("a later save does not clear what the user exported or typed", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// Such as the save of a refresh already in flight when the screen
// went up.
const { saveState } = require("../src/shared/state");
await expect(saveState()).rejects.toThrow();
await env.settle();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
test("a transaction wait that ends under it does not replace it", async () => {
const env = await bootPopup(
unversionedValidProfile({
currentView: "wait-tx",
viewData: {
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
broadcastTime: Date.now(),
},
},
}),
);
expect(env.visibleViews()).toEqual(["wait-tx"]);
env.storage.write("autistmask", CORRUPT_BLOBS[2].blob);
await env.tick();
await env.click("btn-state-recovery-export");
env.node("state-recovery-reset-input").value = "erase my";
// The test provider answers no receipt lookup, and six that fail in
// a row end the wait with an error.
for (let i = 0; i < 6; i++) await env.tick();
expect(env.text("error-tx-message")).toMatch(/could not be reached/);
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.hidden("state-recovery-blob")).toBe(false);
expect(env.value("state-recovery-reset-input")).toBe("erase my");
});
test("a storage read that fails once leaves the wallet list up", async () => {
const env = await bootPopup(unversionedValidProfile());
env.storage.local.get.mockRejectedValueOnce(
new Error("IO error: storage busy"),
);
await env.tick();
// Reported as a failed save, not mistaken for an unreadable profile.
expect(env.visibleViews()).toEqual(["main"]);
expect(env.node("save-failure-banner")).not.toBeNull();
await env.tick();
expect(env.visibleViews()).toEqual(["main"]);
});
// The screen it replaces is left as any navigation leaves it: the rules
// at the top of src/popup/views/showPhrase.js and exportPrivkey.js hold
// for this way off them too.
describe("from a screen holding a secret", () => {
const PHRASE =
"abandon abandon abandon abandon abandon abandon abandon" +
" abandon abandon abandon abandon about";
afterEach(() => jest.dontMock("../src/shared/vault"));
test("a recovery phrase on screen is wiped", async () => {
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: async () => PHRASE,
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/showPhrase").show(0);
env.node("show-phrase-password").value = "password";
await env.click("btn-show-phrase-reveal");
expect(env.text("show-phrase-value")).toBe(PHRASE);
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.text("show-phrase-value")).toBe("");
});
test("a private key still being decrypted is never written", async () => {
let answer;
jest.doMock("../src/shared/vault", () => ({
decryptWithPassword: () =>
new Promise((resolve) => {
answer = resolve;
}),
}));
const env = await bootPopup(unversionedValidProfile());
require("../src/popup/views/exportPrivkey").show(0, 0);
env.node("export-privkey-password").value = "password";
const revealing = env.click("btn-export-privkey-confirm");
env.storage.write("autistmask", CORRUPT_BLOBS[0].blob);
await env.tick();
expect(env.visibleViews()).toEqual(["state-recovery"]);
expect(env.value("export-privkey-password")).toBe("");
answer(PHRASE);
await revealing;
expect(env.text("export-privkey-value")).toBe("");
});
});
});
describe("an unversioned profile that is perfectly valid", () => {
// The upgrade case. Every install in the field is in this state, and the
// popup must load it, not offer to wipe it.
+26 -5
View File
@@ -40,6 +40,10 @@ jest.doMock("libsodium-wrappers-sumo", () => sodium);
jest.doMock("qrcode", () => QRCode);
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
// Taken before any boot replaces them; see bootPopup().
const realSetInterval = globalThis.setInterval;
const realClearInterval = globalThis.clearInterval;
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
"utf8",
@@ -292,9 +296,20 @@ async function bootPopup(stored, options) {
}),
addEventListener: () => {},
};
// The 10s refresh loop init() starts would outlive the test.
const realSetInterval = globalThis.setInterval;
globalThis.setInterval = () => 0;
// The ten-second refresh init() starts, and a transaction wait's timers,
// would outlive the test. So every interval is recorded rather than
// started, clearInterval() removes it as a browser would, and tick() below
// runs the ones still set. Put back by cleanupPopup().
const intervals = new Map();
let lastId = 0;
globalThis.setInterval = (fn) => {
lastId += 1;
intervals.set(lastId, fn);
return lastId;
};
globalThis.clearInterval = (id) => {
intervals.delete(id);
};
require("../../src/popup/index");
@@ -316,8 +331,6 @@ async function bootPopup(stored, options) {
}
await settle();
globalThis.setInterval = realSetInterval;
return {
storage,
document,
@@ -337,6 +350,12 @@ async function bootPopup(stored, options) {
for (const fn of fns) await fn();
await settle();
},
// Every interval still set runs once: the ten-second refresh, and a
// transaction wait's receipt poll and elapsed counter while one runs.
tick: async () => {
for (const fn of intervals.values()) await fn();
await settle();
},
settle,
// The view ids whose section is not hidden, as the audit measured them.
visibleViews: () => {
@@ -354,6 +373,8 @@ function cleanupPopup() {
delete globalThis.chrome;
delete globalThis.document;
delete globalThis.window;
globalThis.setInterval = realSetInterval;
globalThis.clearInterval = realClearInterval;
}
module.exports = {