diff --git a/README.md b/README.md index ee2bf5a..edeae29 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,34 @@ reserve while sitting on the same side of the estimate, so swapping the two in what [#154](https://git.eeqj.de/sneak/AutistMask/issues/154) was, and it was previously correct by reading only. +It also covers the **dApp approval round trips** — the one place where the +content script, the inpage provider, the background worker and the approval +popup all have to work together. A local test page is served by the route +handler on a reserved-TLD origin, gets `window.ethereum` from the shipped +`MAIN`-world content script like any other page, and drives +`eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and +`eth_sendTransaction` through the real prompts. Every signature is recovered in +the runner and compared against the active address, the transaction assertions +run against the raw signed transaction captured at `eth_sendRawTransaction` +rather than against anything the extension reported, rejecting each prompt is +required to return a rejection to the page rather than hang or resolve, and the +password is required to be absent from every message the approval window sends +to the background — with the message that would carry it required to be present, +so that check cannot pass by observing nothing. That last one is the standing +floor under [#157](https://git.eeqj.de/sneak/AutistMask/issues/157). + +Three limits of that coverage, none of them papered over. The RPC is stubbed +throughout, so this is **not** a real dApp against a real network with real +funds; that remains a human pass before 1.0.0. The site-connection prompt is +raised through `chrome.action.openPopup()`, and headless Chromium's +browser-action popup is not a page Playwright can see or click, so that one +prompt is driven at the URL the extension itself puts on the action — the same +page and the same approval id, but whether a real toolbar click shows it is not +observable here. And the EIP-1193 error code does not survive the last hop: the +rejection that crosses the boundary carries code 4001 and is asserted to, but +`src/content/inpage.js` rebuilds it as `new Error(message)`, so the calling page +catches an error with no `code` property. + Any test that drives a failure path on purpose declares the `console.error` it is about to provoke, via `errors.expect()`. That is not a mute: the declaration consumes exactly one matching record, and a declaration nothing matched fails diff --git a/TODO.md b/TODO.md index d0d219c..b9a5818 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,17 @@ undefined identifiers, which is how # Completed Steps +- 2026-08-12: The dApp approval round trips are driven end to end in the + browser. A test page served by the harness speaks EIP-1193 to the real inpage + provider through the real content script, background worker and approval popup + for `eth_requestAccounts`, `personal_sign`, `eth_signTypedData_v4` and + `eth_sendTransaction`. Every signature is recovered and compared against the + active address, the transaction is checked against the bytes handed to the + stubbed RPC, each rejection must reach the page as a rejection, and the + password must appear in no message the approval window sends — the assertion + that gives [#157](https://git.eeqj.de/sneak/AutistMask/issues/157) a permanent + floor. This does not discharge a real dApp with real funds against mainnet + ([#183](https://git.eeqj.de/sneak/AutistMask/issues/183)). - 2026-08-12: The known-symbol spoof rule now judges the symbol a user actually sees. `isSpoofedSymbol()` normalizes before the lookup — NFKC, then every character that paints nothing removed (the format and default-ignorable diff --git a/tests/e2e/network.js b/tests/e2e/network.js index c134ee7..260b9d7 100644 --- a/tests/e2e/network.js +++ b/tests/e2e/network.js @@ -23,6 +23,8 @@ "use strict"; +const { Transaction } = require("ethers"); + // Fictional ERC-20 used to seed the transaction-detail test. The symbol // must not collide with any entry in src/shared/tokenList.js, or // isSpoofedSymbol() in src/shared/transactions.js drops the transfer as a @@ -62,6 +64,84 @@ function word(value) { return "0x" + BigInt(value).toString(16).padStart(64, "0"); } +// -------------------------------------------------------- dApp fixture +// +// The origin the EIP-1193 test page is served from, and the page itself. +// +// It is a fixture like every other one in this file: the route handler +// fulfils the navigation from the string below, so the page never comes +// from a remote origin and nothing about the dApp round trips leaves the +// container. `.test` is reserved by RFC 6761 and has no owner to reach in +// the first place; the launch arguments map every host to NOTFOUND anyway. +// +// What the page deliberately does NOT do is load a provider. window.ethereum +// is put there by the shipped manifest's MAIN-world content script, exactly +// as it is on any http(s) page a user visits, so what these tests speak to +// is the real inpage provider and not a copy the harness wired up. +const DAPP_ORIGIN = "https://dapp.e2e.test"; +const DAPP_URL = DAPP_ORIGIN + "/"; + +// Requests are parked rather than awaited. An approval prompt only exists +// while its call is in flight, so a test that awaited the promise could +// never drive the popup that has to settle it; start() files the promise +// under a key and settle() collects it once the prompt has been dealt with. +// +// The rejection branch records `code` as it arrives. EIP-1193 says a user +// rejection is a ProviderRpcError carrying code 4001; what the page can +// actually see is recorded here rather than assumed, and asserted in run.js. +// +// The message log is the page's half of the boundary observation: every +// AUTISTMASK_* message that crosses between this page and the content +// script, in both directions, verbatim. +const DAPP_HTML = [ + "", + '', + "", + '', + "AutistMask e2e dApp", + // Inline and empty: without it Chromium asks for /favicon.ico, which + // the unstubbed-request guard would report as escaping traffic. + '', + "", + "", + "

AutistMask e2e dApp

", + "", + "", + "", +].join("\n"); + // ------------------------------------------------------------ fee fixture // // The confirmation screen carries two different numbers for the same @@ -101,6 +181,11 @@ const RPC_RESULTS = { eth_estimateGas: hex(GAS_LIMIT), eth_getTransactionCount: "0x0", eth_maxPriorityFeePerGas: hex(PRIORITY_FEE_WEI), + // "not mined yet", which is what a node answers for a transaction it has + // only just accepted. The wait screen the dApp transaction approval hands + // off to polls this every 10 seconds; leaving it unstubbed would report + // the poll as escaping traffic the moment a test outlived one tick. + eth_getTransactionReceipt: null, }; // The "latest" block, which ethers' getFeeData() reads baseFeePerGas from @@ -264,6 +349,31 @@ function rpcReply(req, opts, report) { if (req.method === "eth_getBlockByNumber") { return Object.assign(envelope, { result: latestBlock() }); } + // The end of the dApp transaction round trip: the raw signed transaction + // the background hands to the node. It is recorded verbatim so a test can + // recover the signer from the exact bytes that were broadcast, rather than + // from anything the extension reported about them. + // + // The reply must be the transaction's real hash. ethers compares the hash + // the node returns against the one it computes itself and throws on a + // mismatch, so a constant here would fail the broadcast for a reason that + // has nothing to do with what is being tested. + if (req.method === "eth_sendRawTransaction") { + const raw = Array.isArray(req.params) ? req.params[0] : null; + let parsed; + try { + parsed = Transaction.from(raw); + } catch { + report("eth_sendRawTransaction with an undecodable transaction"); + return Object.assign(envelope, { + error: { code: -32000, message: "undecodable transaction" }, + }); + } + if (Array.isArray(opts.broadcastTransactions)) { + opts.broadcastTransactions.push(raw); + } + return Object.assign(envelope, { result: parsed.hash }); + } if (req.method === "eth_estimateGas" && opts.failGasEstimate) { // A refusal the node itself would produce, not a transport error: // this is the shape the confirmation screen has to turn into @@ -375,6 +485,8 @@ function traceEnabled(raw) { * node-side refusal. * @param {boolean} [opts.holdGasEstimate] hold every batch containing an * eth_estimateGas until this is cleared again. + * @param {string[]} [opts.broadcastTransactions] every raw signed + * transaction handed to eth_sendRawTransaction, appended in order. * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * Promise}>} */ @@ -420,6 +532,18 @@ async function installNetworkStubs(ctx, opts) { return handleRpc(route, req.postData(), opts, report); } + // The local EIP-1193 test page. Served from here so the dApp round + // trips run against a real http(s) origin — which is what makes the + // shipped content scripts inject at all — without any remote origin + // being involved. + if (url.origin === DAPP_ORIGIN && p === "/") { + return route.fulfill({ + status: 200, + contentType: "text/html; charset=utf-8", + body: DAPP_HTML, + }); + } + // Blockscout v2 if (p.includes("/api/v2/")) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/transactions$/.test(p)) { @@ -508,6 +632,8 @@ async function installNetworkStubs(ctx, opts) { module.exports = { installNetworkStubs, + DAPP_ORIGIN, + DAPP_URL, FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, diff --git a/tests/e2e/run.js b/tests/e2e/run.js index 918dbac..c02604f 100644 --- a/tests/e2e/run.js +++ b/tests/e2e/run.js @@ -9,7 +9,18 @@ "use strict"; -const { formatEther } = require("ethers"); +const { + Transaction, + formatEther, + getAddress, + getBytes, + hexlify, + parseEther, + toQuantity, + toUtf8Bytes, + verifyMessage, + verifyTypedData, +} = require("ethers"); const { PASSWORD, createWallet, @@ -20,6 +31,8 @@ const { visible, } = require("./harness"); const { + DAPP_ORIGIN, + DAPP_URL, FEE_ESTIMATE_WEI, FEE_RESERVE_WEI, STUB_COUNTERPARTY, @@ -1197,6 +1210,966 @@ test("ConfirmTx reports a failed ERC-20 estimate as unknown, not as a fee proble ); }); +// ------------------------------------------- dApp round trips (#183) +// +// The seam. Everything above drives the popup on its own; this section is +// the only place where a page, the content script, the inpage provider, the +// background worker and the approval popup all have to work together, and +// nothing else in the repo covers it — the unit suite covers each side in +// isolation. +// +// Three things make these tests worth more than "a call came back": +// +// - every signature is recovered here, in the runner, from the exact +// artifact the extension produced, and compared against the address read +// out of extension storage. The background verifies too (see +// src/shared/approvalVerify.js) but these assertions do not lean on it: +// a test that trusted the extension's own verdict would pass against a +// wallet that verified nothing. +// - the transaction assertions run against the raw signed transaction the +// background handed to eth_sendRawTransaction, captured by the route +// handler, not against anything the extension reported about it. +// - the password is required to be absent from the popup-to-background +// channel, observed directly, with the message that would carry it +// required to be present. That is the standing floor under the fix in +// https://git.eeqj.de/sneak/AutistMask/issues/157. +// +// What this does NOT cover, and must not be presented as covering: a real +// dApp, with real funds, against a real network. The RPC is stubbed +// throughout. That pass stays on the human list before 1.0.0. + +const DAPP_HOSTNAME = new URL(DAPP_URL).hostname; + +// The personal_sign payload. Sent as hex, which is what dApps send and what +// the popup requires — it calls getBytes() on the message — and displayed on +// the approval screen as the decoded text, which is what the user is agreeing +// to and therefore what the screen assertion checks. +const SIGN_TEXT = "AutistMask e2e round trip: personal_sign"; +const SIGN_HEX = hexlify(toUtf8Bytes(SIGN_TEXT)); + +const TYPED_DOMAIN = { + name: "AutistMask e2e", + version: "1", + chainId: 1, + verifyingContract: STUB_COUNTERPARTY, +}; +const TYPED_TYPES = { + Mail: [ + { name: "contents", type: "string" }, + { name: "amount", type: "uint256" }, + ], +}; +const TYPED_MESSAGE = { + contents: "AutistMask e2e round trip: typed data", + amount: "1234", +}; + +// The wire form: EIP-712 payloads reach the wallet as a JSON string that +// carries EIP712Domain in `types`. ethers derives that entry itself and +// rejects it as an input, which is why the recovery below uses TYPED_TYPES +// and the payload here does not. +const TYPED_DATA_JSON = JSON.stringify({ + domain: TYPED_DOMAIN, + types: Object.assign( + { + EIP712Domain: [ + { name: "name", type: "string" }, + { name: "version", type: "string" }, + { name: "chainId", type: "uint256" }, + { name: "verifyingContract", type: "address" }, + ], + }, + TYPED_TYPES, + ), + primaryType: "Mail", + message: TYPED_MESSAGE, +}); + +// The transaction. Call data that decodes as nothing keeps the screen +// assertion honest: the raw data section is shown verbatim, so what is +// compared is the calldata itself rather than a decoder's summary of it. +const TX_VALUE_ETH = "0.0123"; +const TX_VALUE_WEI = parseEther(TX_VALUE_ETH); +const TX_DATA = "0xdeadbeef" + "01".repeat(28); + +const USER_REJECTION_MESSAGE = "User rejected the request."; + +// The active address, read from extension storage rather than from any +// screen: it is the address the background will sign with, and it is what +// every recovery below is compared against. +async function extensionActiveAddress(page) { + const s = await page.evaluate( + () => + new Promise((resolve) => { + chrome.storage.local.get("autistmask", (r) => + resolve(r.autistmask || null), + ); + }), + ); + assert(s !== null, "the extension has no persisted state to read"); + const first = + s.wallets && s.wallets[0] && s.wallets[0].addresses[0] + ? s.wallets[0].addresses[0].address + : null; + const address = s.activeAddress || first; + assert(address, "the extension holds no active address"); + return getAddress(address); +} + +async function openDapp(ctx) { + const page = await ctx.newPage(); + await page.goto(DAPP_URL); + // window.ethereum is not the fixture's doing — it is the shipped + // MAIN-world content script. Waiting for it is waiting for the real + // provider to have injected itself into a real http(s) origin. + await page.waitForFunction( + () => !!window.ethereum && !!window.__dapp, + null, + { timeout: 30000 }, + ); + return page; +} + +function startRequest(page, key, method, params) { + return page.evaluate( + (a) => window.__dapp.start(a.key, a.method, a.params), + { key, method, params }, + ); +} + +// The settled outcome of a parked request, or {settled:"pending"} if it is +// still outstanding after `timeout`. A bounded wait rather than a bare await +// on purpose: "returns a rejection rather than hanging" is one of the things +// under test, and an await would report a hang as a suite timeout with no +// indication of which call never settled. +function settleRequest(page, key, timeout = 60000) { + return page.evaluate( + (a) => + Promise.race([ + window.__dapp.settle(a.key), + new Promise((resolve) => { + setTimeout( + () => resolve({ settled: "pending" }), + a.timeout, + ); + }), + ]), + { key, timeout }, + ); +} + +// Every AUTISTMASK_* message that has crossed between the test page and the +// content script so far, in both directions. +function dappMessages(page, type) { + return page.evaluate( + (want) => + window.__dapp.messages.filter((m) => !want || m.type === want), + type || null, + ); +} + +// The approval window the background opened. Approvals are raised from an +// RPC call rather than from a user gesture, so the extension opens a real +// popup window for them; it is an ordinary page in this context. +async function waitForApprovalWindow(ctx, timeout = 30000) { + const deadline = Date.now() + timeout; + for (;;) { + const page = ctx + .pages() + .find((p) => !p.isClosed() && p.url().includes("?approval=")); + if (page) return page; + if (Date.now() > deadline) { + throw new Error( + "the extension opened no approval window within " + + timeout + + "ms", + ); + } + await sleep(50); + } +} + +// A tab held ready for the site-connection prompt, reserved BEFORE the +// request that raises it and navigated — never replaced — once it has. +// +// The site connection is the one approval src/background/index.js raises +// through the toolbar-anchored popup: chrome.action.setPopup() followed by +// chrome.action.openPopup(), with a real window only as the fallback for an +// openPopup() that throws or rejects. Headless Chromium does open that popup, +// but Playwright cannot see it — it is not a page in ctx.pages() and never +// becomes one — so the prompt has to be driven at the URL the extension put +// on the action, which is the same page, the same approval id and the same +// code path the toolbar button shows, and the route README.md documents for +// reopening a pending approval. +// +// What must not happen is creating a page while that approval is pending. +// A new page dismisses the browser-action popup; the popup's approval port +// disconnects; src/background/index.js settles the approval as a rejection; +// and the prompt is gone before anything can be clicked. Measured directly: +// AUTISTMASK_GET_APPROVAL answers with the approval immediately before the +// tab is created and with null immediately after. Navigating a tab that +// already exists does not disturb it, which is the whole reason this +// reservation exists. +// How long the tab creation above is given to take effect before the request +// that raises the next prompt is issued. Dismissing a browser-action popup +// disconnects its port, and src/background/index.js calls resetPopupUrl() on +// that disconnect; issued too early, the next request's setPopup() is undone +// by the previous popup's teardown, chrome.action.openPopup() then rejects, +// and the fallback window it opens dismisses the popup that was just raised — +// which settles the fresh approval as a rejection before it can be seen. +// Every one of those steps was observed. 1.5s is comfortably past it. +const APPROVAL_TAB_SETTLE_MS = 1500; + +async function reserveApprovalTab(env) { + if (env.approvalTab && !env.approvalTab.isClosed()) { + await env.approvalTab.close(); + } + // Always a fresh page, and always before the request: creating it is what + // dismisses the stale browser-action popup left over from the previous + // approval, and doing that after the next one exists would take the next + // one down with it. + env.approvalTab = await env.ctx.newPage(); + + // The one accommodation this section makes to the shipped code, and the + // reason for it. + // + // Both approval buttons call runtime.sendMessage() and then window.close() + // on the next line. Closing this page disconnects the approval port, and + // the disconnect handler in src/background/index.js settles a pending + // site approval as a rejection. In a tab those two race and the teardown + // wins: the approve message is never acted on, and the page is told the + // user rejected. Measured — with the close left in place the approval + // resolves as a rejection every time; with it deferred it resolves as an + // approval every time. + // + // It is deferred, not removed: the harness closes the page itself once + // the outcome has been observed, which is what window.close() would have + // done, only after the message it was racing has been processed. + // + // This affects the site-connection prompt only. The sign and transaction + // prompts run in windows the extension opens itself, with window.close() + // untouched, and their disconnect handler deliberately keeps a tx or sign + // approval pending rather than rejecting it — so there is no race there + // to accommodate. Whether the same ordering holds in a real toolbar popup + // is not observable from a headless harness and is reported rather than + // assumed either way. + await env.approvalTab.addInitScript(() => { + window.close = function () {}; + }); + await env.approvalTab.goto("about:blank"); + await sleep(APPROVAL_TAB_SETTLE_MS); + return env.approvalTab; +} + +// The site-connection prompt: the window the extension opened if it managed +// to open one, and the reserved tab at the action's approval URL otherwise. +async function openSiteApprovalPopup(env, timeout = 30000) { + const deadline = Date.now() + timeout; + for (;;) { + const existing = env.ctx + .pages() + .find((p) => !p.isClosed() && p.url().includes("?approval=")); + if (existing) return existing; + + const url = await env.page.evaluate( + () => + new Promise((resolve) => { + chrome.action.getPopup({}, (u) => resolve(String(u))); + }), + ); + if (url.includes("?approval=")) { + await env.approvalTab.goto(url); + return env.approvalTab; + } + if (Date.now() > deadline) { + throw new Error( + "no site approval prompt appeared within " + + timeout + + "ms; the browser action carried " + + JSON.stringify(url), + ); + } + await sleep(50); + } +} + +// Retire every approval page still open. A settled approval whose page is +// left behind would be found by the next waitForApprovalWindow() and driven +// as if it were the next approval. +async function closeApprovalPages(ctx) { + for (const page of ctx.pages()) { + if (!page.isClosed() && page.url().includes("?approval=")) { + await page.close(); + } + } +} + +// Record every message the approval window sends to the background worker. +// +// This is the direct observation the password check needs. It is installed +// after the approval screen has rendered and before Approve is clicked, +// which is the whole window in which a password could be put on the wire: +// the popup takes the password, derives the key, signs, and only then sends. +// The earlier AUTISTMASK_GET_APPROVAL exchange happens before the password +// field has been touched and carries nothing to leak. +// +// It wraps the property rather than the captured reference, which is what +// makes it see src/popup/views/approval.js's own `runtime.sendMessage` calls: +// that module captures the chrome.runtime object at load, not the function. +async function watchApprovalBoundary(page, env) { + const records = []; + await page.exposeFunction("__amRecordBoundary", (json) => { + records.push(JSON.parse(json)); + env.boundaryRecords.push(JSON.parse(json)); + }); + const wrapped = await page.evaluate(() => { + const rt = chrome.runtime; + const original = rt.sendMessage.bind(rt); + rt.sendMessage = function (...args) { + let json; + try { + json = JSON.stringify(args[0]); + } catch (e) { + // A payload that will not serialize is still a payload, and + // recording nothing for it would be exactly the blind spot + // this observation exists to close. + json = JSON.stringify({ unserializable: String(e) }); + } + const sent = window.__amRecordBoundary(json); + if (sent && typeof sent.catch === "function") { + // The window closes on a successful signature; a binding + // call still in flight then must not become a page error. + sent.catch(() => {}); + } + return original.apply(rt, args); + }; + return chrome.runtime.sendMessage !== original; + }); + assert( + wrapped, + "could not wrap chrome.runtime.sendMessage in the approval window, so " + + "nothing was observed and the password assertion would be vacuous", + ); + return records; +} + +async function waitForBoundaryRecords(records, type, timeout = 30000) { + const deadline = Date.now() + timeout; + for (;;) { + const found = records.filter((r) => r && r.type === type); + if (found.length > 0) return found; + if (Date.now() > deadline) { + throw new Error( + "no " + + type + + " message was observed crossing the popup-to-background " + + "boundary, so the password assertion has nothing to assert on", + ); + } + await sleep(25); + } +} + +function assertNoPassword(records, where) { + const leaked = records.filter((r) => JSON.stringify(r).includes(PASSWORD)); + assert( + leaked.length === 0, + "the password crossed the extension messaging boundary " + + where + + ": " + + JSON.stringify(leaked), + ); +} + +// The most recent AUTISTMASK_RESPONSE the page received, which is where the +// EIP-1193 error code lives on the wire. +async function lastResponseError(page) { + const responses = await dappMessages(page, "AUTISTMASK_RESPONSE"); + const last = responses[responses.length - 1]; + assert(last, "the page received no AUTISTMASK_RESPONSE at all"); + return last.error || null; +} + +// A rejected prompt, asserted at both ends: the page's promise rejected +// rather than hanging or resolving, and the response that crossed the +// boundary carried EIP-1193 code 4001. +// +// The code is asserted on the wire because that is the only place it +// survives. src/content/inpage.js rebuilds the rejection as `new +// Error(error.message)`, so the Error the calling page catches carries the +// message and no code. That is reported rather than asserted either way — +// locking in the current behaviour would make the gap permanent, and +// asserting the code on the Error would fail today. +async function assertUserRejection(page, key, label) { + const outcome = await settleRequest(page, key); + assert( + outcome.settled !== "pending", + label + " never settled: the rejected prompt left the page hanging", + ); + assert( + outcome.settled === "rejected", + label + " resolved instead of rejecting: " + JSON.stringify(outcome), + ); + assert( + outcome.message === USER_REJECTION_MESSAGE, + label + " rejected with the wrong message: " + outcome.message, + ); + const error = await lastResponseError(page); + assert( + error && error.code === 4001, + label + + " did not carry EIP-1193 code 4001 across the boundary: " + + JSON.stringify(error), + ); + console.log( + "# " + + label + + ": boundary code=" + + error.code + + " page Error.code=" + + JSON.stringify(outcome.code) + + " page Error carries a code=" + + outcome.hasCode, + ); + return outcome; +} + +test("the harness serves a page that gets the real inpage provider (#183)", async (env) => { + env.dapp = await openDapp(env.ctx); + env.expectedAddress = await extensionActiveAddress(env.page); + + // EIP-6963, asked of the provider itself: the announcement has to name + // this extension and hand back the very object on window.ethereum. An + // identity check rather than a shape check, so nothing the fixture could + // have installed itself would satisfy it. + const announced = await env.dapp.evaluate( + () => + new Promise((resolve) => { + const onAnnounce = (e) => { + window.removeEventListener( + "eip6963:announceProvider", + onAnnounce, + ); + resolve({ + rdns: e.detail.info.rdns, + name: e.detail.info.name, + isWindowEthereum: e.detail.provider === window.ethereum, + }); + }; + window.addEventListener("eip6963:announceProvider", onAnnounce); + window.dispatchEvent(new Event("eip6963:requestProvider")); + setTimeout(() => resolve(null), 10000); + }), + ); + assert(announced, "the provider announced itself to no EIP-6963 request"); + assert( + announced.rdns === "berlin.sneak.autistmask", + "the announced provider is not this extension: " + + JSON.stringify(announced), + ); + assert( + announced.isWindowEthereum, + "the announced provider is not the object on window.ethereum", + ); + + // A full page -> content script -> background round trip that needs no + // approval, so the transport is proven before any prompt is driven. + const chainId = await env.dapp.evaluate(() => + window.ethereum.request({ method: "eth_chainId" }), + ); + assert( + chainId === "0x1", + "eth_chainId did not round trip through the extension: " + + JSON.stringify(chainId), + ); + console.log( + "# dapp origin " + + DAPP_ORIGIN + + " active address " + + env.expectedAddress, + ); +}); + +test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", async (env) => { + await reserveApprovalTab(env); + await startRequest(env.dapp, "accounts-reject", "eth_requestAccounts", []); + const popup = await openSiteApprovalPopup(env); + try { + await visible(popup, "#view-approve-site"); + + const hostname = await popup.locator("#approve-hostname").innerText(); + assert( + hostname === DAPP_HOSTNAME, + "the site prompt names the wrong origin: " + + JSON.stringify(hostname), + ); + + // Deliberately not remembered: a remembered rejection lands the + // origin in deniedSites and every later test in this section is + // auto-rejected with no prompt at all, which would look like a pass. + await popup.uncheck("#approve-remember"); + await popup.click("#btn-reject"); + + await assertUserRejection( + env.dapp, + "accounts-reject", + "eth_requestAccounts rejection", + ); + } finally { + await closeApprovalPages(env.ctx); + } +}); + +test("eth_requestAccounts approved returns the selected address (#183)", async (env) => { + await reserveApprovalTab(env); + await startRequest(env.dapp, "accounts", "eth_requestAccounts", []); + const popup = await openSiteApprovalPopup(env); + let outcome; + try { + await visible(popup, "#view-approve-site"); + + const shown = await popup.locator("#approve-address").innerText(); + assert( + shown.toLowerCase().includes(env.expectedAddress.toLowerCase()), + "the site prompt shows the wrong address: " + JSON.stringify(shown), + ); + + // Remembered, so the connection survives a background worker that MV3 + // terminates after 30 seconds idle. The in-memory connectedSites map + // does not, and the sign and transaction tests below all require the + // origin to still be authorized. + await popup.check("#approve-remember"); + await popup.click("#btn-approve"); + + outcome = await settleRequest(env.dapp, "accounts"); + } finally { + await closeApprovalPages(env.ctx); + } + assert( + outcome.settled === "resolved", + "eth_requestAccounts did not resolve: " + JSON.stringify(outcome), + ); + assert( + Array.isArray(outcome.result) && outcome.result.length === 1, + "eth_requestAccounts returned no single account: " + + JSON.stringify(outcome.result), + ); + assert( + getAddress(outcome.result[0]) === env.expectedAddress, + "eth_requestAccounts returned " + + outcome.result[0] + + ", not the selected address " + + env.expectedAddress, + ); +}); + +test("personal_sign signs, and the signature recovers to the address (#183)", async (env) => { + await startRequest(env.dapp, "sign", "personal_sign", [ + SIGN_HEX, + env.expectedAddress, + ]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-sign"); + const boundary = await watchApprovalBoundary(popup, env); + + const screen = await popup.evaluate(() => ({ + hostname: document.getElementById("approve-sign-hostname").textContent, + type: document.getElementById("approve-sign-type").textContent, + message: document.getElementById("approve-sign-message").textContent, + from: document.getElementById("approve-sign-from").textContent, + })); + assert( + screen.hostname === DAPP_HOSTNAME, + "the sign prompt names the wrong origin: " + + JSON.stringify(screen.hostname), + ); + assert( + screen.type === "Personal message", + "the sign prompt reports the wrong type: " + + JSON.stringify(screen.type), + ); + assert( + screen.message === SIGN_TEXT, + "the sign prompt shows the wrong message: " + + JSON.stringify(screen.message), + ); + assert( + screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()), + "the sign prompt shows the wrong signing address: " + + JSON.stringify(screen.from), + ); + + await popup.fill("#approve-sign-password", PASSWORD); + await popup.click("#btn-approve-sign"); + + const outcome = await settleRequest(env.dapp, "sign"); + assert( + outcome.settled === "resolved", + "personal_sign did not resolve: " + JSON.stringify(outcome), + ); + const recovered = getAddress( + verifyMessage(getBytes(SIGN_HEX), outcome.result), + ); + console.log( + "# personal_sign: recovered=" + + recovered + + " expected=" + + env.expectedAddress, + ); + assert( + recovered === env.expectedAddress, + "the personal_sign signature recovers to " + + recovered + + ", not to the approved address " + + env.expectedAddress, + ); + + const sent = await waitForBoundaryRecords( + boundary, + "AUTISTMASK_SIGN_RESPONSE", + ); + assert( + sent.length === 1 && sent[0].approved === true, + "the popup did not send exactly one approved sign response: " + + JSON.stringify(sent), + ); + assert( + sent[0].signature === outcome.result, + "the signature the page received is not the one the popup produced", + ); + assertNoPassword(boundary, "on the personal_sign approval"); +}); + +test("personal_sign rejected returns a rejection to the page (#183)", async (env) => { + await startRequest(env.dapp, "sign-reject", "personal_sign", [ + SIGN_HEX, + env.expectedAddress, + ]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-sign"); + await popup.click("#btn-reject-sign"); + + await assertUserRejection( + env.dapp, + "sign-reject", + "personal_sign rejection", + ); +}); + +test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => { + await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [ + env.expectedAddress, + TYPED_DATA_JSON, + ]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-sign"); + const boundary = await watchApprovalBoundary(popup, env); + + const screen = await popup.evaluate(() => ({ + hostname: document.getElementById("approve-sign-hostname").textContent, + type: document.getElementById("approve-sign-type").textContent, + message: document.getElementById("approve-sign-message").innerText, + from: document.getElementById("approve-sign-from").textContent, + })); + assert( + screen.hostname === DAPP_HOSTNAME, + "the typed data prompt names the wrong origin: " + + JSON.stringify(screen.hostname), + ); + assert( + screen.type === "Typed data (EIP-712)", + "the typed data prompt reports the wrong type: " + + JSON.stringify(screen.type), + ); + for (const want of [ + TYPED_DOMAIN.name, + "Mail", + TYPED_MESSAGE.contents, + TYPED_MESSAGE.amount, + ]) { + assert( + screen.message.includes(want), + "the typed data prompt does not show " + + JSON.stringify(want) + + ", got: " + + JSON.stringify(screen.message), + ); + } + assert( + screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()), + "the typed data prompt shows the wrong signing address: " + + JSON.stringify(screen.from), + ); + + await popup.fill("#approve-sign-password", PASSWORD); + await popup.click("#btn-approve-sign"); + + const outcome = await settleRequest(env.dapp, "typed"); + assert( + outcome.settled === "resolved", + "eth_signTypedData_v4 did not resolve: " + JSON.stringify(outcome), + ); + const recovered = getAddress( + verifyTypedData( + TYPED_DOMAIN, + TYPED_TYPES, + TYPED_MESSAGE, + outcome.result, + ), + ); + console.log( + "# eth_signTypedData_v4: recovered=" + + recovered + + " expected=" + + env.expectedAddress, + ); + assert( + recovered === env.expectedAddress, + "the typed data signature recovers to " + + recovered + + ", not to the approved address " + + env.expectedAddress, + ); + + const sent = await waitForBoundaryRecords( + boundary, + "AUTISTMASK_SIGN_RESPONSE", + ); + assert( + sent.length === 1 && sent[0].signature === outcome.result, + "the popup did not send exactly one sign response carrying this signature: " + + JSON.stringify(sent), + ); + assertNoPassword(boundary, "on the eth_signTypedData_v4 approval"); +}); + +test("eth_signTypedData_v4 rejected returns a rejection to the page (#183)", async (env) => { + await startRequest(env.dapp, "typed-reject", "eth_signTypedData_v4", [ + env.expectedAddress, + TYPED_DATA_JSON, + ]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-sign"); + await popup.click("#btn-reject-sign"); + + await assertUserRejection( + env.dapp, + "typed-reject", + "eth_signTypedData_v4 rejection", + ); +}); + +test("eth_sendTransaction signs the approved transaction and broadcasts it (#183)", async (env) => { + const txParams = { + from: env.expectedAddress, + to: STUB_COUNTERPARTY, + value: toQuantity(TX_VALUE_WEI), + data: TX_DATA, + }; + await startRequest(env.dapp, "tx", "eth_sendTransaction", [txParams]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-tx"); + const boundary = await watchApprovalBoundary(popup, env); + + const screen = await popup.evaluate(() => ({ + hostname: document.getElementById("approve-tx-hostname").textContent, + from: document.getElementById("approve-tx-from").textContent, + to: document.getElementById("approve-tx-to").textContent, + value: document.getElementById("approve-tx-value").textContent, + data: document.getElementById("approve-tx-data").textContent, + dataShown: !document + .getElementById("approve-tx-data-section") + .classList.contains("hidden"), + })); + assert( + screen.hostname === DAPP_HOSTNAME, + "the transaction prompt names the wrong origin: " + + JSON.stringify(screen.hostname), + ); + assert( + screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()), + "the transaction prompt shows the wrong sender: " + + JSON.stringify(screen.from), + ); + assert( + screen.to.toLowerCase().includes(STUB_COUNTERPARTY.toLowerCase()), + "the transaction prompt shows the wrong recipient: " + + JSON.stringify(screen.to), + ); + assert( + screen.value.startsWith(TX_VALUE_ETH + " ETH"), + "the transaction prompt shows the wrong value: " + + JSON.stringify(screen.value), + ); + assert( + screen.dataShown && screen.data === TX_DATA, + "the transaction prompt does not show the approved call data: " + + JSON.stringify(screen.data), + ); + + const broadcastBefore = env.routeOpts.broadcastTransactions.length; + await popup.fill("#approve-tx-password", PASSWORD); + await popup.click("#btn-approve-tx"); + + const outcome = await settleRequest(env.dapp, "tx"); + assert( + outcome.settled === "resolved", + "eth_sendTransaction did not resolve: " + JSON.stringify(outcome), + ); + + // The artifact as the node saw it, not as the extension described it. + const broadcast = env.routeOpts.broadcastTransactions; + assert( + broadcast.length === broadcastBefore + 1, + "expected exactly one raw transaction to reach the RPC, got " + + (broadcast.length - broadcastBefore), + ); + const signed = Transaction.from(broadcast[broadcast.length - 1]); + console.log( + "# eth_sendTransaction: signer=" + + getAddress(signed.from) + + " expected=" + + env.expectedAddress + + " to=" + + getAddress(signed.to) + + " value=" + + formatEther(signed.value) + + " chainId=" + + signed.chainId, + ); + assert( + getAddress(signed.from) === env.expectedAddress, + "the broadcast transaction was signed by " + + getAddress(signed.from) + + ", not by the approved address " + + env.expectedAddress, + ); + assert( + getAddress(signed.to) === getAddress(STUB_COUNTERPARTY), + "the broadcast transaction goes to " + signed.to, + ); + assert( + signed.value === TX_VALUE_WEI, + "the broadcast transaction carries " + + formatEther(signed.value) + + " ETH, not the approved " + + TX_VALUE_ETH, + ); + assert( + signed.data === TX_DATA, + "the broadcast transaction carries different call data: " + signed.data, + ); + assert( + signed.chainId === 1n, + "the broadcast transaction is for chain " + signed.chainId, + ); + assert( + outcome.result === signed.hash, + "the page received " + + outcome.result + + ", not the hash of the broadcast transaction " + + signed.hash, + ); + + const sent = await waitForBoundaryRecords( + boundary, + "AUTISTMASK_TX_RESPONSE", + ); + assert( + sent.length === 1 && + sent[0].approved === true && + typeof sent[0].rawSignedTx === "string", + "the popup did not send exactly one approved transaction response: " + + JSON.stringify(sent), + ); + assertNoPassword(boundary, "on the eth_sendTransaction approval"); + + // The approval window hands off to the wait screen rather than closing, + // and it is this run's job to close it: left open it keeps polling for a + // receipt for the rest of the suite. + await visible(popup, "#view-wait-tx"); + const waitHash = await popup.locator("#wait-tx-hash").innerText(); + assert( + waitHash.includes(signed.hash), + "the wait screen shows a different hash: " + JSON.stringify(waitHash), + ); + await popup.close(); +}); + +test("eth_sendTransaction rejected broadcasts nothing (#183)", async (env) => { + const before = env.routeOpts.broadcastTransactions.length; + await startRequest(env.dapp, "tx-reject", "eth_sendTransaction", [ + { + from: env.expectedAddress, + to: STUB_COUNTERPARTY, + value: toQuantity(TX_VALUE_WEI), + data: TX_DATA, + }, + ]); + const popup = await waitForApprovalWindow(env.ctx); + await visible(popup, "#view-approve-tx"); + await popup.click("#btn-reject-tx"); + + await assertUserRejection( + env.dapp, + "tx-reject", + "eth_sendTransaction rejection", + ); + assert( + env.routeOpts.broadcastTransactions.length === before, + "a rejected transaction still reached the RPC", + ); +}); + +// The closing pass over both boundaries at once. Every message the section +// put on either channel is re-read here and required to be free of the +// password — and required to be there at all, method by method, so the +// assertion cannot pass by having observed nothing. +test("the password never crossed either boundary in this section (#183)", async (env) => { + const messages = await dappMessages(env.dapp); + const requested = messages + .filter((m) => m.type === "AUTISTMASK_REQUEST") + .map((m) => m.method); + for (const method of [ + "eth_requestAccounts", + "personal_sign", + "eth_signTypedData_v4", + "eth_sendTransaction", + ]) { + assert( + requested.includes(method), + "no " + + method + + " was observed crossing the page boundary, so this check " + + "is asserting on an incomplete record: " + + JSON.stringify(requested), + ); + } + assert( + env.boundaryRecords.length >= 3, + "fewer popup-to-background messages were observed than the three " + + "approvals that were signed: " + + env.boundaryRecords.length, + ); + + console.log( + "# boundary observation: " + + messages.length + + " page/content-script messages, " + + env.boundaryRecords.length + + " popup/background messages, " + + requested.length + + " requests", + ); + assertNoPassword(messages, "between the page and the content script"); + assertNoPassword( + env.boundaryRecords, + "between the popup and the background", + ); + + await env.dapp.close(); +}); + // ---------------------------------------------------------------- runner async function main() { @@ -1220,6 +2193,10 @@ async function main() { ethBalanceWei: null, failGasEstimate: false, holdGasEstimate: false, + // Every raw signed transaction handed to eth_sendRawTransaction, in + // order. The dApp transaction round trip asserts against these bytes + // rather than against anything the extension reported about them. + broadcastTransactions: [], }; let session; @@ -1252,6 +2229,13 @@ async function main() { // compared against every later state of the same screen. ethPendingHeight: null, erc20PendingHeight: null, + // The dApp round trips: the test page, the address every signature + // must recover to, and every message observed leaving an approval + // window for the background worker. + dapp: null, + approvalTab: null, + expectedAddress: null, + boundaryRecords: [], }; // Attribution of collected errors is total. session.errors has no