From d5595c01515ebbc7567d16da61576e9d13a74e11 Mon Sep 17 00:00:00 2001 From: clawbot Date: Wed, 12 Aug 2026 13:23:57 +0200 Subject: [PATCH] test: drive the EIP-1193 dApp approval round trips in the browser (closes #183) The dApp signing path was the largest unverified surface in the milestone: the only place where the content script, the inpage provider, the background worker and the popup all have to work together, with unit tests covering each side in isolation and none covering the seam. A page served by the harness speaks EIP-1193 to the real provider -- asserted by EIP-6963 object identity, not by shape -- and eth_requestAccounts, personal_sign, eth_signTypedData_v4 and eth_sendTransaction are each driven through to approval and to rejection. Every signature is recovered and compared to the approved address; the broadcast transaction is parsed from the bytes captured at eth_sendRawTransaction and checked for signer, recipient, value, calldata and chain. A signature that merely came back would pass against a wrong key, a wrong message or a wrong chain, so each assertion was demonstrated failing against a variant that is wrong in exactly one of those ways. The password is asserted absent from every message crossing the extension boundary, which gives #157's fix a permanent floor rather than a one-time review. Two defects this surfaced are tracked separately: EIP-1193 error codes never reach the page (#274), and approving a site connection races the popup teardown (#275). Neither is asserted as correct here. A real dApp with real funds against mainnet remains an uncovered human pass and is documented as such. --- README.md | 28 ++ TODO.md | 11 + tests/e2e/network.js | 126 ++++++ tests/e2e/run.js | 986 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1150 insertions(+), 1 deletion(-) 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