Author SHA1 Message Date
sneak e9f5286ca7 fix: a slow answer to an approval still reaches the site (closes #540)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Chrome stopped the background worker about six minutes after a site's
request even with the approval window open, and the approval and the
site's waiting request went with it. While any approval is pending, the
worker now calls runtime.getPlatformInfo() every 20 seconds; Chrome
counts each extension call as activity. The timer is the worker's own,
so a minimized window does not slow it, and it stops itself once nothing
is pending.

tests/e2e/slowApproval.js, run by make test-e2e, leaves a transaction
and a signature approval open for seven minutes in minimized windows and
requires the site to receive both results. It drives Chromium over the
DevTools pipe without Playwright, because Playwright keeps DevTools
attached to the worker and Chrome then never stops it.

Model: opus-5-5
2026-10-14 19:53:55 +00:00
10 changed files with 687 additions and 243 deletions
+2 -2
View File
@@ -35,10 +35,10 @@ on: [push]
jobs:
e2e-chrome:
runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and both Chrome
# Bounds the image build, a cold cache included, and the three Chrome
# programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times.
timeout-minutes: 20
timeout-minutes: 40
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+41 -9
View File
@@ -542,6 +542,29 @@ one profile yields a single extension id, not two: Chrome does not load a second
copy of an id it already has. The assertions are annotated as observations, so a
Chrome that ever changes this fails the run instead of passing it.
Last, `make test-e2e` runs `tests/e2e/slowApproval.js`, which leaves a
transaction approval and a signature approval open for seven minutes with both
windows minimized, then restores and approves both and requires the site to
receive the hash of the transaction the stub node received and a signature that
recovers to the address. Before the background worker kept itself running while
an approval is pending, Chrome stopped it about six minutes in and neither
request was ever answered
([#540](https://git.eeqj.de/sneak/AutistMask/issues/540)). The windows are
minimized through the DevTools protocol (`Browser.setWindowBounds`), and the run
fails unless both pages then report themselves hidden: Chrome slows a hidden
page's timers to once a minute, so anything the window did to keep the worker
running would fail here. It is its own program and does not use Playwright:
Playwright keeps DevTools attached to the background worker, and Chrome never
stops a worker DevTools is attached to, so no wait in the suite above can show
the worker stopping. It starts Chromium itself, speaks the DevTools protocol
over `--remote-debugging-pipe`, and attaches to pages only. Before raising the
approvals it requires the worker to stop on its own with nothing pending, so a
run in which something else keeps the worker running fails. The site is
connected and the RPC endpoint set through the extension's own screens, against
the Firefox suite's loopback dApp page and stub node
(`tests/e2e/firefox/dapp.js`). Unlike the suite above, it does not fail on page
errors.
### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
@@ -722,11 +745,13 @@ installing Firefox, geckodriver and their system libraries. Both scripts now
build their image with `--no-cache`, so every run pays the cold figure. Those
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end
in error. `make test-e2e` took 3m51s locally with its image cached, so with the
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes. The
approvals left open for seven minutes (`tests/e2e/slowApproval.js`) add about
eight more, so about fifteen.
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
job instead of holding the shared runner: `e2e-firefox` 15 minutes and
`e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
`e2e-chrome` 40, each over two and a half times the job's slowest cold run. A
job that reaches its cap has hung; read it as a hang, not as a slow run to
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
copy from `sneak/prompts`, kept byte-identical.
@@ -821,6 +846,20 @@ on the next event. Two consequences shape every recurring job in the background:
service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such.
Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the
worker that received the request can answer it. Left alone, Chrome stopped the
worker about six minutes after a site's request even with the approval window
still open, and the user's answer then reached nobody. So the worker keeps
itself running while any approval is pending: raising an approval starts a
`setInterval` that calls `runtime.getPlatformInfo()` every 20 seconds, and
Chrome counts each extension call as activity. The timer stops itself once
nothing is pending, and the worker then stops as it otherwise would. The timer
is the worker's own, so it does not depend on the approval window being visible;
a timer in the window would, because Chrome slows a hidden page's timers to once
a minute. A worker that stops for another reason, a crash say, still loses the
approvals open in it.
The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard
@@ -2221,13 +2260,6 @@ view would leave a wallet one click from deletion.
- 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
- Typed data whose message leaves out a field its type declares, at any
level of nesting, or gives a `bool` field a value other than `true` or
`false` → shown with the error line naming the field and "Sign" disabled;
only "Reject" remains. ethers signs a `bool` by whether its value is
truthy, so the string `"false"` would be signed as true and a field left
out as false. The background refuses such a signature too and answers the
site with the same sentence
- Typed data whose domain `chainId` names a chain other than the active
network's → shown with the error line naming both chain ids and "Sign"
disabled; only "Reject" remains. EIP-712 asks for this, because the
+12
View File
@@ -97,6 +97,18 @@ main() {
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/storagePartition.js
# Approvals left open for seven minutes in minimized windows, past the
# point where Chrome stopped the background worker when nothing kept it
# running. Its own program, without Playwright: Playwright keeps DevTools
# attached to the worker, and Chrome never stops a worker DevTools is
# attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..."
docker run --rm \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/slowApproval.js
}
main "$@"
+35 -18
View File
@@ -22,8 +22,6 @@ const { debugFetch, log } = require("../shared/log");
const {
verifySignedTx,
verifySignature,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
failureIsRetryable,
describeTxFailure,
@@ -46,6 +44,7 @@ const {
const {
actionApi,
getPlatformInfo,
runtimeApi,
tabsQuery,
tabsSendMessage,
@@ -69,6 +68,29 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, resolve } }
const pendingApprovals = {};
// An approval, and the site's request waiting on it, exist only in this
// worker's memory. Left alone, Chrome stopped the worker about six minutes
// after the site's request while the user was still deciding, and the approval
// was lost with it. Chrome counts an extension call as activity, so while any
// approval is pending this worker makes one every 20 seconds, inside Chrome's
// 30-second idle limit. Each function below that raises an approval starts the
// timer, and it stops itself once nothing is pending. It is the worker's own
// timer, so it runs whether or not the approval window is visible.
let keepRunningTimer = null;
function keepRunningWhileApprovalsPending() {
if (keepRunningTimer) return;
keepRunningTimer = setInterval(() => {
if (Object.keys(pendingApprovals).length === 0) {
clearInterval(keepRunningTimer);
keepRunningTimer = null;
return;
}
// Only the call matters; its answer is not used.
getPlatformInfo().catch(() => {});
}, 20000);
}
// One transaction approval at a time, wallet-wide.
//
// The transaction a site asks for is populated before its approval window
@@ -517,6 +539,7 @@ function requestApproval(origin) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, resolve, type: "site" };
keepRunningWhileApprovalsPending();
if (actionNs && typeof actionNs.openPopup === "function") {
showInToolbarPopup(id);
@@ -568,6 +591,7 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
type: "tx",
};
if (slot) slot.approvalId = id;
keepRunningWhileApprovalsPending();
openApprovalWindow(id);
});
@@ -588,6 +612,7 @@ function requestSignApproval(origin, signParams, approvedFrom) {
resolve,
type: "sign",
};
keepRunningWhileApprovalsPending();
openApprovalWindow(id);
});
@@ -608,6 +633,7 @@ function requestNetworkApproval(origin, currentNetworkId, requestedNetworkId) {
resolve,
type: "network",
};
keepRunningWhileApprovalsPending();
openApprovalWindow(id);
});
@@ -1789,27 +1815,18 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
signature,
approval.approvedFrom,
);
// The sign screen refuses typed data whose message ethers
// would sign differently from how it is shown, or whose
// domain names another chain. A signature for it that arrives
// anyway is refused here, against the network active now.
// The sign screen refuses typed data whose domain names
// another chain. A signature for it that arrives anyway is
// refused here, against the network active now.
const sp = approval.signParams;
if (
sp.method === "eth_signTypedData_v4" ||
sp.method === "eth_signTypedData"
) {
const data = JSON.parse(sp.typedData);
const refusal =
typedDataMessageRefusal(
data.types,
signedPrimaryType(data.types),
data.message,
"",
) ||
typedDataChainRefusal(
data.domain,
networkById(s.networkId),
);
const refusal = typedDataChainRefusal(
JSON.parse(sp.typedData).domain,
networkById(s.networkId),
);
if (refusal) throw new ApprovalMismatchError(refusal);
}
settleApproval(msg.id, { signature }, { holdsClaim: true });
+15 -9
View File
@@ -27,6 +27,7 @@ const {
Interface,
MaxUint256,
toUtf8String,
TypedDataEncoder,
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
@@ -47,8 +48,6 @@ const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const {
describeSigningFailure,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
} = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
@@ -433,12 +432,22 @@ function markInvisibleCharacters(text) {
);
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a signature request cannot be signed, as a sentence for the error line,
// or null when it can. Only typed data is refused: the `primaryType` the page
// states has to be the type ethers will sign, or this screen would name one
// message while another is signed. Its message has to hold every declared
// field, with each `bool` true or false, and its domain may name no chain but
// the active network's.
// message while another is signed. Its domain may name no chain but the active
// network's.
function typedDataRefusal(sp) {
if (sp.method === "personal_sign" || sp.method === "eth_sign") return null;
let data;
@@ -461,10 +470,7 @@ function typedDataRefusal(sp) {
", so it cannot be signed."
);
}
return (
typedDataMessageRefusal(data.types, signed, data.message, "") ||
typedDataChainRefusal(data.domain, currentNetwork())
);
return typedDataChainRefusal(data.domain, currentNetwork());
}
// The largest amount a Permit2 allowance can hold, a uint160. Permit2 treats
-60
View File
@@ -56,7 +56,6 @@ const {
getBigInt,
getBytes,
toQuantity,
TypedDataEncoder,
verifyMessage,
verifyTypedData,
} = require("ethers");
@@ -637,63 +636,6 @@ function verifySignature(signParams, signature, expectedFrom) {
return recovered;
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
// would refuse to sign as well.
function signedPrimaryType(types) {
const structs = { ...types };
// ethers derives EIP712Domain itself and rejects it as an input.
delete structs.EIP712Domain;
return TypedDataEncoder.getPrimaryType(structs);
}
// Why a typed data message cannot be signed as the screen shows it, as a
// sentence, or null when it can. ethers signs a `bool` field by whether its
// value is truthy, so the string "false" is signed as true and a field left
// out as false. Each field that `type` declares, at every level of nesting,
// must be one of the value's own keys, and each `bool` must be true or false.
// Called with the type ethers signs, the message, and a `path` of "".
function typedDataMessageRefusal(types, type, value, path) {
const itemType = type.replace(/\[\d*\]$/, "");
if (itemType !== type) {
// ethers refuses to sign a list field that is not a list.
if (!Array.isArray(value)) return null;
for (let i = 0; i < value.length; i++) {
const refusal = typedDataMessageRefusal(
types,
itemType,
value[i],
`${path}[${i}]`,
);
if (refusal) return refusal;
}
return null;
}
if (type === "bool" && value !== true && value !== false) {
return `The field "${path}" is not true or false, so this typed data cannot be signed.`;
}
for (const field of types[type] || []) {
const at = path ? `${path}.${field.name}` : field.name;
// An own key only: a plain lookup of `constructor` finds a function.
const present =
value !== null &&
typeof value === "object" &&
Object.prototype.hasOwnProperty.call(value, field.name);
if (!present) {
return `The field "${at}" is missing from the message, so this typed data cannot be signed.`;
}
const refusal = typedDataMessageRefusal(
types,
field.type,
value[field.name],
at,
);
if (refusal) return refusal;
}
return null;
}
// Why typed data cannot be signed while `network` is the active network, as a
// sentence, or null when it can. EIP-712 has a wallet refuse typed data whose
// domain names another chain, because the signature is valid on that chain.
@@ -882,8 +824,6 @@ function describeSigningFailure(response, fallbackMessage) {
module.exports = {
verifySignedTx,
verifySignature,
signedPrimaryType,
typedDataMessageRefusal,
typedDataChainRefusal,
assertNoForbiddenFields,
assertNothingUnchecked,
+9
View File
@@ -145,6 +145,14 @@ function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* @returns {Promise<Object>} the operating system and processor the browser
* runs on.
*/
function getPlatformInfo() {
return invoke(runtimeApi(), "getPlatformInfo");
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
@@ -262,6 +270,7 @@ module.exports = {
actionApi,
alarmsApi,
extensionApi,
getPlatformInfo,
hasBrowserNamespace,
notify,
runtimeApi,
+42 -40
View File
@@ -264,6 +264,7 @@ function loadBackground(options) {
connectListener = fn;
},
},
getPlatformInfo: jest.fn((cb) => cb({ os: "linux" })),
lastError: null,
},
windows: {
@@ -384,13 +385,13 @@ function loadBackground(options) {
// The same through eth_signTypedData_v4, whose params name the address
// first and the typed data second.
function requestTypedData(typedData = TYPED_DATA) {
function requestTypedData() {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_signTypedData_v4",
params: [signer.address, typedData],
params: [signer.address, TYPED_DATA],
},
{ origin: ORIGIN },
(r) => {
@@ -878,6 +879,43 @@ describe("one transaction approval at a time", () => {
});
});
// Chrome stops an idle worker, and the approvals in it go with it
// (https://git.eeqj.de/sneak/AutistMask/issues/540). tests/e2e/slowApproval.js
// shows the worker kept running in Chrome; this shows the calls stop once the
// approval is answered, so the worker can stop again.
describe("the worker keeps running while an approval is pending", () => {
test("an extension call every 20 seconds until the approval is answered", async () => {
const startTimer = jest
.spyOn(global, "setInterval")
.mockImplementation(() => 1);
const stopTimer = jest
.spyOn(global, "clearInterval")
.mockImplementation(() => {});
try {
const bg = loadBackground();
const calls = global.chrome.runtime.getPlatformInfo;
bg.requestTx();
await settle();
expect(startTimer).toHaveBeenCalledTimes(1);
expect(startTimer.mock.calls[0][1]).toBe(20000);
const tick = startTimer.mock.calls[0][0];
tick();
expect(calls).toHaveBeenCalledTimes(1);
// The user closes the approval window, which rejects it.
bg.closeWindow(1);
await settle();
tick();
expect(calls).toHaveBeenCalledTimes(1);
expect(stopTimer).toHaveBeenCalledWith(1);
} finally {
startTimer.mockRestore();
stopTimer.mockRestore();
}
});
});
// A page that asks again before the user has answered its last connection or
// signature request is refused, instead of opening one more window per call.
describe("one connection and one signature approval per site at a time", () => {
@@ -1494,8 +1532,8 @@ describe("what the approval is verified against", () => {
// screen; a signature for it that reaches the background anyway is not
// handed to the page (https://git.eeqj.de/sneak/AutistMask/issues/519).
// TYPED_DATA names mainnet.
async function answerTypedData(bg, pending, typedData = TYPED_DATA) {
const { domain, types, message } = JSON.parse(typedData);
async function answerTypedData(bg, pending) {
const { domain, types, message } = JSON.parse(TYPED_DATA);
delete types.EIP712Domain;
const signature = await signer.signTypedData(domain, types, message);
const answer = bg.send(
@@ -1539,42 +1577,6 @@ describe("what the approval is verified against", () => {
expect(pending.result()).toEqual({ error: { message: refusal } });
});
// ethers signs a `bool` field sent as the string "false" as true, and one
// left out as false. The sign screen refuses both; a signature for either
// that reaches the background anyway is not handed to the page
// (https://git.eeqj.de/sneak/AutistMask/issues/552).
test.each([
[
'"false"',
"false",
'The field "approved" is not true or false, so this typed data cannot be signed.',
],
[
"left out",
undefined,
'The field "approved" is missing from the message, so this typed data cannot be signed.',
],
])(
"typed data with a bool %s is refused, not handed back",
async (_, approved, refusal) => {
const data = JSON.parse(TYPED_DATA);
data.types.Note.push({ name: "approved", type: "bool" });
data.message.approved = approved;
const typedData = JSON.stringify(data);
const bg = loadBackground();
const pending = bg.requestTypedData(typedData);
await settle();
const { answer } = await answerTypedData(bg, pending, typedData);
expect(answer.sendResponse).toHaveBeenCalledWith({
error: refusal,
retryable: false,
});
expect(pending.result()).toEqual({ error: { message: refusal } });
},
);
test("a sign request from another address raises no approval at all", async () => {
const bg = loadBackground();
const pending = bg.requestSign(other.address);
+530
View File
@@ -0,0 +1,530 @@
// A transaction approval and a signature approval the user leaves open for
// seven minutes, their windows minimized, are still answered, and the site
// receives both results (https://git.eeqj.de/sneak/AutistMask/issues/540).
//
// Both approvals, and the site's requests waiting on them, live only in the
// background worker's memory. With nothing else going on, Chrome stopped that
// worker about six minutes after the site's request, while the window was
// still open, and the user's answer then reached nobody. While an approval is
// pending, the worker now makes an extension call every 20 seconds, which
// keeps it running (keepRunningWhileApprovalsPending() in
// src/background/index.js). The windows are minimized for the wait because
// Chrome slows a hidden page's timers to once a minute after a few minutes,
// so anything the window itself did to keep the worker running would fail
// here.
//
// A separate program from run.js, and not driven by Playwright: Playwright
// keeps DevTools attached to the background worker, and Chrome never stops a
// worker DevTools is attached to, so in run.js the worker outlives any wait
// and a wait there proves nothing. This program starts Chromium itself and
// speaks the DevTools protocol over the pipe Chromium opens for it
// (--remote-debugging-pipe), attaching to pages only, never to the worker.
// Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass.
//
// The dApp page and the stub node are the Firefox suite's loopback ones
// (tests/e2e/firefox/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage
// from outside.
//
// Run through script/test-e2e, which builds the extension and the pinned
// container.
//
// node tests/e2e/slowApproval.js
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const { chromium } = require("playwright-core");
const {
Transaction,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { PASSWORD } = require("./harness");
const { STUB_COUNTERPARTY } = require("./network");
const { startDappServer } = require("./firefox/dapp");
const DIST_CHROME = path.resolve(__dirname, "..", "..", "dist", "chrome");
// Chrome stopped the worker six minutes after the site's request when nothing
// kept it running, so the approvals are left open for longer than that.
const APPROVAL_WAIT_MS = 7 * 60 * 1000;
// With nothing pending, Chrome stops the worker 30 seconds after its last
// event; this is how long the program waits to see that.
const IDLE_STOP_TIMEOUT_MS = 90000;
const SIGN_HEX = hexlify(toUtf8Bytes("AutistMask e2e: a slow signature"));
const checks = [];
let failed = 0;
function check(name, cond, detail) {
checks.push(name);
if (cond) {
console.log("ok " + checks.length + " - " + name);
} else {
failed += 1;
console.log("not ok " + checks.length + " - " + name);
if (detail) console.log(" " + detail);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Poll `probe` until it returns something truthy, and return that.
async function until(what, probe, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const value = await probe().catch(() => null);
if (value) return value;
if (Date.now() > deadline) {
throw new Error(
"gave up after " + timeout + "ms waiting for " + what,
);
}
await sleep(250);
}
}
// The DevTools protocol over Chromium's pipe: commands go to its fd 3, and
// replies and events come back on its fd 4, each one JSON message ended by a
// NUL byte. Events are not used and are dropped. Returns send(method, params,
// sessionId), which resolves with the command's result.
function devtools(browser) {
const waiting = new Map();
let nextId = 1;
let pending = "";
const replies = browser.stdio[4];
replies.setEncoding("utf8");
replies.on("data", (chunk) => {
pending += chunk;
let end;
while ((end = pending.indexOf("\0")) !== -1) {
const message = JSON.parse(pending.slice(0, end));
pending = pending.slice(end + 1);
const caller = waiting.get(message.id);
if (!caller) continue;
waiting.delete(message.id);
if (message.error) {
caller.reject(
new Error(caller.method + ": " + message.error.message),
);
} else {
caller.resolve(message.result);
}
}
});
replies.on("close", () => {
for (const caller of waiting.values()) {
caller.reject(new Error("the browser exited"));
}
waiting.clear();
});
return (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = nextId++;
waiting.set(id, { method, resolve, reject });
browser.stdio[3].write(
JSON.stringify({ id, method, params, sessionId }) + "\0",
);
});
}
// Attach to a page and return a function that evaluates an expression in it,
// awaiting a promise it returns, and resolves with the value.
async function attach(send, targetId) {
const { sessionId } = await send("Target.attachToTarget", {
targetId,
flatten: true,
});
return async (expression) => {
const r = await send(
"Runtime.evaluate",
{ expression, awaitPromise: true, returnByValue: true },
sessionId,
);
if (r.exceptionDetails) {
throw new Error(
"evaluating " +
expression.slice(0, 80) +
" threw: " +
JSON.stringify(r.exceptionDetails.exception || {}),
);
}
return r.result.value;
};
}
// Whether the page has loaded and shows the element with this id: the
// `hidden` class showView() keeps on every other view is on neither it nor
// anything around it.
function shows(id) {
return (
"document.readyState === 'complete' &&" +
" !!document.getElementById(" +
JSON.stringify(id) +
") && !document.getElementById(" +
JSON.stringify(id) +
").closest('.hidden')"
);
}
// Set an input's value and fire the input event typing would fire.
function typeInto(id, value) {
return (
"(() => { const el = document.getElementById(" +
JSON.stringify(id) +
"); el.value = " +
JSON.stringify(value) +
"; el.dispatchEvent(new Event('input', { bubbles: true })); })()"
);
}
function click(id) {
return "document.getElementById(" + JSON.stringify(id) + ").click()";
}
// The outcome of a request the dApp page started with __dapp.start(), or
// {settled: "pending"} if it has not settled within `timeout`.
function settled(key, timeout = 30000) {
return (
"Promise.race([window.__dapp.settle(" +
JSON.stringify(key) +
"), new Promise((r) => setTimeout(() => r({ settled: 'pending' }), " +
timeout +
"))])"
);
}
async function main() {
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
console.error(
"slowApproval: no unpacked build at " +
DIST_CHROME +
" — run make build first",
);
process.exitCode = 1;
return;
}
const node = await startDappServer();
const profile = fs.mkdtempSync(
path.join(os.tmpdir(), "autistmask-slow-approval-"),
);
const browser = spawn(
chromium.executablePath(),
[
"--headless",
// The container runs unprivileged, as in harness.js.
"--no-sandbox",
"--remote-debugging-pipe",
"--user-data-dir=" + profile,
"--disable-extensions-except=" + DIST_CHROME,
"--load-extension=" + DIST_CHROME,
// Nothing but the loopback dApp page and stub node can be reached.
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
"about:blank",
],
{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
);
try {
const send = devtools(browser);
const targets = async () =>
(await send("Target.getTargets")).targetInfos;
const worker = await until("the background worker", async () =>
(await targets()).find(
(t) =>
t.type === "service_worker" &&
t.url.startsWith("chrome-extension://"),
),
);
// The extension id is the host of the worker's URL, as in harness.js.
const popupUrl =
"chrome-extension://" +
new URL(worker.url).host +
"/src/popup/index.html";
const workerRunning = async () =>
(await targets()).some((t) => t.url === worker.url);
const openPage = async (url) => {
const { targetId } = await send("Target.createTarget", { url });
return { targetId, evaluate: await attach(send, targetId) };
};
const approvalPages = async () =>
(await targets()).filter(
(t) => t.type === "page" && t.url.includes("?approval="),
);
// A wallet, through the popup's own screens, and the stub node as its
// RPC endpoint, through Settings.
const popup = await openPage(popupUrl);
await until("the welcome screen", () =>
popup.evaluate(shows("view-welcome")),
);
await popup.evaluate(click("btn-welcome-add"));
await until("the add wallet screen", () =>
popup.evaluate(shows("view-add-wallet")),
);
await popup.evaluate(click("btn-generate-phrase"));
await until("a generated recovery phrase", () =>
popup.evaluate(
"document.getElementById('wallet-mnemonic').value.trim()" +
".split(/\\s+/).length >= 12",
),
);
await popup.evaluate(typeInto("add-wallet-password", PASSWORD));
await popup.evaluate(typeInto("add-wallet-password-confirm", PASSWORD));
await popup.evaluate(click("btn-add-wallet-confirm"));
await until(
"the main screen",
() => popup.evaluate(shows("view-main")),
60000,
);
await popup.evaluate(click("btn-settings"));
await until("the settings screen", () =>
popup.evaluate(shows("view-settings")),
);
await popup.evaluate(typeInto("settings-rpc", node.rpcUrl));
await popup.evaluate(click("btn-save-rpc"));
await until("the stub node saved as the RPC endpoint", () =>
popup.evaluate(
"new Promise((resolve) => chrome.storage.local.get(" +
"'autistmask', (r) => resolve(r.autistmask.rpcUrl === " +
JSON.stringify(node.rpcUrl) +
")))",
),
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so
// the connection is stored rather than held by the worker.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
);
await dapp.evaluate(
"window.__dapp.start('accounts', 'eth_requestAccounts', [])",
);
const prompt = await until(
"the connection prompt",
async () => (await approvalPages())[0],
);
const inPrompt = await attach(send, prompt.targetId);
await until("the connection prompt's screen", () =>
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = true",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
const accounts = await dapp.evaluate(settled("accounts"));
if (accounts.settled !== "resolved") {
throw new Error(
"the site could not be connected: " + JSON.stringify(accounts),
);
}
const address = getAddress(accounts.result[0]);
console.log("# address " + address + ", site " + node.origin);
// The control: with nothing pending, the worker stops on its own.
// Were something else keeping it running, the wait below would
// prove nothing.
const idleStart = Date.now();
const stoppedWhenIdle = await until(
"the idle worker to stop",
async () => !(await workerRunning()),
IDLE_STOP_TIMEOUT_MS,
).catch(() => false);
check(
"with nothing pending, Chrome stops the background worker on its own",
stoppedWhenIdle,
"the worker was still running " +
IDLE_STOP_TIMEOUT_MS / 1000 +
"s after its last event, so something other than the " +
"approval windows is keeping it running",
);
console.log(
"# idle worker stopped after " +
Math.round((Date.now() - idleStart) / 1000) +
"s",
);
// A transaction approval and a signature approval, both open at once.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
JSON.stringify({
from: address,
to: STUB_COUNTERPARTY,
value: toQuantity(parseEther("0.01")),
}) +
"])",
);
await until("the transaction approval window", async () =>
(await approvalPages()).length === 1 ? true : null,
);
await dapp.evaluate(
"window.__dapp.start('sign', 'personal_sign', [" +
JSON.stringify(SIGN_HEX) +
", " +
JSON.stringify(address) +
"])",
);
const windows = await until("both approval windows", async () => {
const pages = await approvalPages();
return pages.length === 2 ? pages : null;
});
let txWindow = null;
let signWindow = null;
const windowIds = [];
for (const page of windows) {
const evaluate = await attach(send, page.targetId);
const view = await until("an approval screen", () =>
evaluate(
"(" +
shows("view-approve-tx") +
") ? 'tx' : (" +
shows("view-approve-sign") +
") ? 'sign' : null",
),
);
if (view === "tx") txWindow = evaluate;
if (view === "sign") signWindow = evaluate;
const { windowId } = await send("Browser.getWindowForTarget", {
targetId: page.targetId,
});
windowIds.push(windowId);
}
if (!txWindow || !signWindow) {
throw new Error("the two windows are not one of each kind");
}
const setWindowState = (windowState) =>
Promise.all(
windowIds.map((windowId) =>
send("Browser.setWindowBounds", {
windowId,
bounds: { windowState },
}),
),
);
// The user minimizes both windows while deciding.
await setWindowState("minimized");
const bothHidden = await until(
"both approval pages hidden",
async () =>
(await txWindow("document.visibilityState")) === "hidden" &&
(await signWindow("document.visibilityState")) === "hidden",
).catch(() => false);
check(
"both approval windows are minimized, and their pages are hidden",
bothHidden,
"a page still reports itself visible, so the wait below would " +
"not show what happens to a minimized approval",
);
// The user takes seven minutes. The worker is checked every two
// seconds, and the wait ends early if Chrome stops it.
const opened = Date.now();
let stoppedAfter = null;
while (Date.now() - opened < APPROVAL_WAIT_MS) {
if (!(await workerRunning())) {
stoppedAfter = Math.round((Date.now() - opened) / 1000);
break;
}
await sleep(2000);
}
check(
"the background worker keeps running while both approvals wait in minimized windows",
stoppedAfter === null,
"Chrome stopped the worker " +
stoppedAfter +
"s after the approvals opened",
);
// Back on screen to be answered.
await setWindowState("normal");
await txWindow(typeInto("approve-tx-password", PASSWORD));
await txWindow(click("btn-approve-tx"));
await signWindow(typeInto("approve-sign-password", PASSWORD));
// The window closes itself once the signature is handed over.
await signWindow(click("btn-approve-sign")).catch(() => {});
const tx = await dapp.evaluate(settled("tx"));
const broadcast = node.broadcast.map((raw) => Transaction.from(raw));
check(
"the transaction approved after seven minutes reaches the site",
tx.settled === "resolved" &&
broadcast.length === 1 &&
tx.result === broadcast[0].hash &&
getAddress(broadcast[0].from) === address,
"the page got " +
JSON.stringify(tx) +
" and the node received " +
broadcast.length +
" transaction(s)",
);
const sign = await dapp.evaluate(settled("sign"));
check(
"the signature approved after seven minutes reaches the site",
sign.settled === "resolved" &&
getAddress(verifyMessage(getBytes(SIGN_HEX), sign.result)) ===
address,
"the page got " + JSON.stringify(sign),
);
} catch (e) {
failed += 1;
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
} finally {
if (browser.exitCode === null && browser.signalCode === null) {
const exited = new Promise((resolve) =>
browser.once("exit", resolve),
);
browser.kill();
await exited;
}
await node.close();
// Chromium's helper processes can still be writing to the profile
// for a moment after the browser process itself has exited.
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}
console.log("1.." + checks.length);
if (checks.length === 0) {
console.log("# FAILED: this program asserted nothing");
process.exitCode = 1;
return;
}
console.log(
"# " +
(checks.length - failed) +
"/" +
checks.length +
" checks passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("slowApproval: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});
+1 -105
View File
@@ -28,7 +28,7 @@ globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { getAddress, MaxUint256, TypedDataEncoder } = require("ethers");
const { getAddress, MaxUint256 } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const approval = require("../src/popup/views/approval");
@@ -662,107 +662,3 @@ describe("the sign screen refuses typed data for another chain", () => {
expect(node("btn-approve-sign").disabled).toBe(false);
});
});
// ethers signs a `bool` field by whether its value is truthy in JavaScript:
// the string "false" is signed as true, and a field left out as false. The
// screen would show one value while another is signed, so such typed data is
// refused, and so is any declared field left out of the message
// (https://git.eeqj.de/sneak/AutistMask/issues/552). DAI's older permit
// declares the `bool` field `allowed`.
describe("the sign screen refuses a message ethers would sign differently", () => {
const NOT_A_BOOL =
'The field "allowed" is not true or false, so this typed data cannot be signed.';
const MISSING =
'The field "allowed" is missing from the message, so this typed data cannot be signed.';
function daiPermit(allowed) {
return { ...DAI_PERMIT, message: { ...DAI_PERMIT.message, allowed } };
}
const withoutAllowed = {
...DAI_PERMIT,
message: { ...DAI_PERMIT.message },
};
delete withoutAllowed.message.allowed;
beforeEach(() => {
decryptWithPassword.mockClear();
});
test('ethers signs "false" as true, and a bool left out as false', () => {
const hash = (data) =>
TypedDataEncoder.hash(
data.domain,
{ Permit: data.types.Permit },
data.message,
);
expect(hash(daiPermit("false"))).toBe(hash(daiPermit(true)));
expect(hash(withoutAllowed)).toBe(hash(daiPermit(false)));
});
test('a bool sent as the string "false" shows the error, with Sign disabled', async () => {
await openSignScreen(daiPermit("false"));
expect(node("approve-sign-error").textContent).toBe(NOT_A_BOOL);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("a bool left out shows the error, with Sign disabled", async () => {
await openSignScreen(withoutAllowed);
expect(node("approve-sign-error").textContent).toBe(MISSING);
expect(node("btn-approve-sign").disabled).toBe(true);
});
test("Sign clicked anyway decrypts and signs nothing", async () => {
const sent = await openSignScreen(daiPermit("false"));
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(NOT_A_BOOL);
});
test("a well-formed message leaves Sign enabled", async () => {
for (const allowed of [true, false]) {
await openSignScreen(daiPermit(allowed));
expect(node("approve-sign-error").textContent).toBe("");
expect(node("btn-approve-sign").disabled).toBe(false);
}
});
test("a field left out inside a list item is named by its place", () => {
const [first, second] = PERMIT_BATCH.message.details;
const withoutNonce = { ...second };
delete withoutNonce.nonce;
const data = {
...PERMIT_BATCH,
message: {
...PERMIT_BATCH.message,
details: [first, withoutNonce],
},
};
expect(typedDataRefusal(request(data))).toBe(
'The field "details[1].nonce" is missing from the message, so this typed data cannot be signed.',
);
});
// A plain lookup of a field named after a built-in object property reads
// back a function, which ethers would sign as true.
test("a field named after a built-in object property is missing when left out", () => {
const data = {
...LOGIN,
types: {
...LOGIN.types,
Login: [
...LOGIN.types.Login,
{ name: "constructor", type: "bool" },
],
},
};
expect(typedDataRefusal(request(data))).toBe(
'The field "constructor" is missing from the message, so this typed data cannot be signed.',
);
});
});