// Sending, a site's signature request and private key export each use the key // of the address they name: the key at the index that address was derived at, // not the one at its position in the wallet's list. The two differ after an // import whose scan passed over an unused index, and after an address that is // not the last is removed (https://git.eeqj.de/sneak/AutistMask/issues/510). // // Driven through the real import, "+" button, address removal, confirmation // screen, signature approval screen and export screen, with the node, the vault // and the DOM stubbed. "use strict"; // The addresses the stub node reports a balance for, and the signed // transactions it was handed. const mockNode = { funded: new Set(), broadcast: [] }; jest.mock("ethers", () => { const actual = jest.requireActual("ethers"); class StubProvider { async getBalance(address) { return mockNode.funded.has(address) ? 1n : 0n; } async getTransactionCount() { return 0; } async estimateGas() { return 21000n; } async getFeeData() { return { maxFeePerGas: 20000000000n, maxPriorityFeePerGas: 1000000000n, gasPrice: 10000000000n, }; } async getNetwork() { return { chainId: 1n }; } async getCode() { return "0x"; } async lookupAddress() { return null; } async broadcastTransaction(signed) { mockNode.broadcast.push(signed); return { hash: "0x" + "ab".repeat(32) }; } } return { ...actual, JsonRpcProvider: StubProvider }; }); // No password is hashed: what the wallet stores is the phrase itself. jest.mock("../src/shared/vault", () => ({ encryptWithPassword: async (secret) => secret, decryptWithPassword: async (secret) => secret, })); // The wait screen polls for a receipt; these tests stop at the broadcast. jest.mock("../src/popup/views/txStatus", () => ({ showWait: jest.fn(), showError: jest.fn(), })); jest.mock("ethereum-blockies-base64", () => () => "data:image/png;base64,x"); global.fetch = jest.fn(() => { throw new Error("tests must not perform network requests"); }); const { HDNodeWallet, Transaction, getBytes, verifyMessage, } = require("ethers"); // Publicly known development phrase; never fund it. const PHRASE = "test test test test test test test test test test test junk"; const PASSWORD = "correct horse battery staple"; const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe"; // "Hello", as the hex a page sends. const MESSAGE = "0x48656c6c6f"; // The address and key at derivation index `index` of PHRASE, worked out with // ethers directly rather than through the wallet's own code. function derived(index) { return HDNodeWallet.fromPhrase(PHRASE, "", "m/44'/60'/0'/0/" + index); } // ------------------------------------------------------------ DOM stub function makeElement(id) { const classes = new Set(); const el = { id, textContent: "", title: "", value: "", innerHTML: "", disabled: false, style: {}, dataset: {}, listeners: {}, classList: { add: (...names) => names.forEach((n) => classes.add(n)), remove: (...names) => names.forEach((n) => classes.delete(n)), contains: (n) => classes.has(n), toggle: (n, force) => { const on = force === undefined ? !classes.has(n) : force; if (on) classes.add(n); else classes.delete(n); return on; }, }, addEventListener: (name, fn) => { el.listeners[name] = el.listeners[name] || []; el.listeners[name].push(fn); }, querySelectorAll: () => [], appendChild: () => {}, }; // Views reach for .parentElement to hide whole sections. Object.defineProperty(el, "parentElement", { get: () => node(id + "-parent"), }); return el; } function makeDocument() { const els = new Map(); return { getElementById(id) { // The debug banner is created on demand by helpers.js; absent // is the state a non-debug, non-testnet popup is in. if (id === "debug-banner") return null; if (!els.has(id)) els.set(id, makeElement(id)); return els.get(id); }, createElement: () => makeElement("created"), addEventListener: () => {}, body: { prepend: () => {} }, }; } function node(id) { return globalThis.document.getElementById(id); } function click(element) { const event = { stopPropagation: () => {} }; return Promise.all((element.listeners.click || []).map((fn) => fn(event))); } // show() starts the fee estimate without awaiting it; this lets it settle. async function settle() { for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); } // ------------------------------------------------------------ harness globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, }; const { state } = require("../src/shared/state"); const { removeAddressFromState } = require("../src/shared/walletDelete"); const addWallet = require("../src/popup/views/addWallet"); const home = require("../src/popup/views/home"); const confirmTx = require("../src/popup/views/confirmTx"); const approval = require("../src/popup/views/approval"); const exportPrivkey = require("../src/popup/views/exportPrivkey"); const txStatus = require("../src/popup/views/txStatus"); // What the background answers when the approval window asks which approval it // was opened for, and every message the popup sent it. let approvalDetails; let sent; // The "+" button of the wallet list's last render. Each render attaches the // handler to the buttons it drew, as the browser's fresh list would have. let plusButton; beforeEach(() => { approvalDetails = null; sent = []; mockNode.broadcast = []; globalThis.document = makeDocument(); globalThis.window = { close: () => {} }; globalThis.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, runtime: { connect: () => ({ postMessage: () => {} }), sendMessage: (msg, reply) => { sent.push(msg); if (!reply) return; reply( msg.type === "AUTISTMASK_GET_APPROVAL" ? approvalDetails : null, ); }, }, }; node("wallet-list").querySelectorAll = (selector) => { if (selector !== ".btn-add-address") return []; plusButton = makeElement("btn-add-address"); plusButton.dataset.wallet = "0"; return [plusButton]; }; // The explorer and price lookups these screens start fail offline, and // say so; that is not under test. for (const method of ["log", "warn", "error"]) { jest.spyOn(console, method).mockImplementation(() => {}); } txStatus.showError.mockClear(); state.wallets = []; state.rpcUrl = "https://rpc.example.invalid"; state.networkId = "mainnet"; state.viewStack = []; state.currentView = "main"; addWallet.init({ renderWalletList: () => {}, doRefreshAndRender: () => {}, pageClosed: new AbortController().signal, }); confirmTx.init({}); approval.init({}); exportPrivkey.init(); }); afterEach(() => { jest.restoreAllMocks(); }); // Import PHRASE on the add-wallet screen, with the stub node reporting a // balance at each of `fundedIndices`, and wait for the address scan. async function importPhrase(fundedIndices) { mockNode.funded = new Set(fundedIndices.map((i) => derived(i).address)); await click(node("tab-mnemonic")); node("wallet-mnemonic").value = PHRASE; node("add-wallet-password").value = PASSWORD; node("add-wallet-password-confirm").value = PASSWORD; await click(node("btn-add-wallet-confirm")); } // Press the wallet's "+" button on Home. async function addAddress() { home.render({ doRefreshAndRender: () => {}, pageClosed: new AbortController().signal, }); await click(plusButton); } // The key the export screen reveals for the address at `position`. async function exportedKey(position) { exportPrivkey.show(0, position); node("export-privkey-password").value = PASSWORD; await click(node("btn-export-privkey-confirm")); return node("export-privkey-value").textContent; } // Send from the address at `position`; resolves to the signed transaction the // node was handed, or undefined when none was. async function sendFrom(position) { state.selectedWallet = 0; state.selectedAddress = position; confirmTx.show({ from: state.wallets[0].addresses[position].address, to: RECIPIENT, amount: "0.5", token: "ETH", balance: "1.0", }); await settle(); node("confirm-tx-password").value = PASSWORD; await click(node("btn-confirm-send")); return mockNode.broadcast.pop(); } // A site asks the address at `position` to sign MESSAGE; resolves to the // response the approval window sent the background. async function signFor(position) { const address = state.wallets[0].addresses[position].address; approvalDetails = { type: "sign", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: address, signParams: { method: "personal_sign", message: MESSAGE, from: address, }, }; await approval.show(1); node("approve-sign-password").value = PASSWORD; await click(node("btn-approve-sign")); return sent.find((msg) => msg.type === "AUTISTMASK_SIGN_RESPONSE"); } describe.each([ [ "an import whose scan passed over index 3", async () => { await importPhrase([0, 1, 2, 4]); }, // Address 4 on Home, derived at index 4. { position: 3, index: 4 }, ], [ "removing the middle one of three addresses", async () => { await importPhrase([0]); await addAddress(); await addAddress(); removeAddressFromState(state, 0, 1); }, // Address 2 on Home after the removal, derived at index 2. { position: 1, index: 2 }, ], ])("after %s", (_name, setUp, { position, index }) => { beforeEach(setUp); test("the wallet lists the address derived at that index there", () => { expect(state.wallets[0].addresses[position].address).toBe( derived(index).address, ); }); test("export reveals that address's key", async () => { expect(await exportedKey(position)).toBe(derived(index).privateKey); }); test("a send is signed by that address", async () => { const signed = await sendFrom(position); expect(Transaction.from(signed).from).toBe(derived(index).address); }); test("a site's signature is made by that address", async () => { const response = await signFor(position); expect(verifyMessage(getBytes(MESSAGE), response.signature)).toBe( derived(index).address, ); }); }); describe("an address whose stored index derives a different address", () => { function refusal() { return ( "The key this wallet holds for " + derived(0).address + " belongs to a different address, so it was not used." ); } beforeEach(async () => { await importPhrase([0]); state.wallets[0].addresses[0].index = 1; }); test("export reveals nothing and says why", async () => { expect(await exportedKey(0)).toBe(""); expect(node("export-privkey-password-error").textContent).toBe( refusal(), ); }); test("nothing is sent", async () => { expect(await sendFrom(0)).toBeUndefined(); expect(txStatus.showError).toHaveBeenCalledWith( expect.anything(), null, refusal(), ); }); test("nothing is signed", async () => { const response = await signFor(0); expect(response.signature).toBeUndefined(); expect(response.error).toBe(refusal()); }); });