// Tests for the stored-state half of the non-master extended key problem. // // Refusing a non-master xprv at import time does nothing for a wallet that is // already in storage: the import that created it ran before the refusal // existed. Such a wallet used to sign for the wrong tree and now throws on the // send screen instead. These tests pin down that it is named and explained in // the wallet list, that every control leading to a signature or to the private // key refuses it before asking for a password, that nothing on the way there // throws, and that a wallet imported from a real master key is untouched by // any of it. // Mocked so that no password has to be hashed: the controls below are checked // for whether they decrypt at all. jest.mock("../src/shared/vault", () => ({ decryptWithPassword: jest.fn(), })); const { HDNodeWallet, Mnemonic } = require("ethers"); const wallet = require("../src/shared/wallet"); const { walletDefect, walletDefectHtml, NON_MASTER_XPRV, } = require("../src/shared/walletDefects"); // BIP-39 test vector phrase, published; never used for real funds. const VECTOR_PHRASE = "test test test test test test test test test test test junk"; function seedNode(phrase) { return HDNodeWallet.fromSeed(Mnemonic.fromPhrase(phrase, "").computeSeed()); } // The master (depth-0) key, which is what the import flow accepts today. function masterXprv(phrase) { return seedNode(phrase).extendedKey; } // The account-level (depth-3) key m/44'/60'/0'. A normal thing for a user to // hold, and what the import flow used to accept. function accountXprv(phrase) { return seedNode(phrase).derivePath("m/44'/60'/0'").extendedKey; } // The wallet record the CURRENT import path writes for a master key: the // neutered m/44'/60'/0'/0 node, four levels below a depth-0 key. function healthyXprvWallet(name = "Master") { const { xpub, firstAddress } = wallet.hdWalletFromXprv( masterXprv(VECTOR_PHRASE), ); return { name, type: "xprv", xpub, nextIndex: 1, encryptedSecret: "irrelevant-to-these-tests", addresses: [{ address: firstAddress, balance: "0.0000" }], }; } // The wallet record the PRE-#210 import path wrote for an account-level key: // the same four levels, but derived as a relative path *beneath* the key, so // the stored xpub sits at depth 3 + 4 = 7. Built here the way the old code // built it rather than by calling the module under test, which now refuses. function brokenXprvWallet(name = "Imported xprv") { const node = HDNodeWallet.fromExtendedKey( accountXprv(VECTOR_PHRASE), ).derivePath("44'/60'/0'/0"); return { name, type: "xprv", xpub: node.neuter().extendedKey, nextIndex: 1, encryptedSecret: "irrelevant-to-these-tests", addresses: [ { address: node.deriveChild(0).address, balance: "0.0000" }, ], }; } describe("the fixtures are what the two import paths actually produced", () => { test("a master import stores a depth-4 xpub", () => { expect( HDNodeWallet.fromExtendedKey(healthyXprvWallet().xpub).depth, ).toBe(4); }); test("the pre-fix account-level import stored a depth-7 xpub", () => { expect( HDNodeWallet.fromExtendedKey(brokenXprvWallet().xpub).depth, ).toBe(7); }); }); describe("walletDefect", () => { test("names the defect on a stored non-master xprv wallet", () => { const defect = walletDefect(brokenXprvWallet()); expect(defect).not.toBeNull(); expect(defect.id).toBe(NON_MASTER_XPRV); }); test("a depth-0 xprv wallet has no defect", () => { expect(walletDefect(healthyXprvWallet())).toBeNull(); }); test("hd and key wallets are never assessed", () => { expect( walletDefect({ type: "hd", xpub: brokenXprvWallet().xpub }), ).toBe(null); expect(walletDefect({ type: "key" })).toBeNull(); }); test("an xprv wallet whose xpub cannot be parsed makes no claim", () => { // No basis to say the key was non-master, so nothing is asserted // about it rather than guessing. expect(walletDefect({ type: "xprv", xpub: "not-a-key" })).toBeNull(); expect(walletDefect({ type: "xprv" })).toBeNull(); }); test("nothing about the wallet record is modified by the check", () => { const w = brokenXprvWallet(); const before = JSON.stringify(w); walletDefect(w); expect(JSON.stringify(w)).toBe(before); }); }); describe("the explanatory copy", () => { const defect = walletDefect(brokenXprvWallet()); test("every sentence of it is a full sentence", () => { for (const text of [defect.heading, ...defect.paragraphs]) { expect(text).toMatch(/^[A-Z]/); expect(text.trimEnd()).toMatch(/\.$/); } }); test("it says what was derived wrongly and that these are not the standard addresses", () => { const body = defect.paragraphs.join(" "); expect(body).toContain("not a master key"); expect(body).toMatch(/standard path/); }); test("it does not claim the funds are safe and does not claim a loss", () => { const all = [defect.heading, ...defect.paragraphs].join(" "); expect(all).not.toMatch(/\bsafe\b/i); expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i); }); test("it says the wallet is not deleted and what the user can do", () => { const body = defect.paragraphs.join(" "); expect(body).toMatch(/until you delete it yourself/); expect(body).toMatch(/recovery phrase/); }); test("it uses the project's vocabulary", () => { const all = [ defect.heading, ...defect.paragraphs, defect.shortMessage, ].join(" "); expect(all).not.toMatch(/seed phrase|mnemonic|passphrase/i); expect(all).not.toMatch(/\baccounts?\b/i); }); }); describe("walletDefectHtml", () => { test("renders the heading and every paragraph for a defective wallet", () => { const defect = walletDefect(brokenXprvWallet()); const html = walletDefectHtml(brokenXprvWallet()); expect(html).toContain(defect.heading); for (const p of defect.paragraphs) { expect(html).toContain(p); } }); test("renders nothing at all for a healthy wallet", () => { expect(walletDefectHtml(healthyXprvWallet())).toBe(""); }); }); describe("the wallet list", () => { let home; let state; beforeAll(() => { global.chrome = { storage: { local: { get: async () => ({}), set: async () => {} } }, runtime: { sendMessage: () => {} }, }; home = require("../src/popup/views/home"); state = require("../src/shared/state").state; }); afterEach(() => { state.wallets = []; state.activeAddress = null; }); test("a stored depth-3 xprv wallet renders the explanation", () => { state.wallets = [brokenXprvWallet("Imported xprv")]; const html = home.walletListHtml(); expect(html).toContain(walletDefect(state.wallets[0]).heading); expect(html).toContain("Imported xprv"); }); test("it does not offer to derive further addresses from that wallet", () => { state.wallets = [brokenXprvWallet()]; expect(home.walletListHtml()).not.toContain("btn-add-address"); }); test("a normal depth-0 xprv wallet renders exactly as it did before", () => { state.wallets = [healthyXprvWallet("Master")]; const html = home.walletListHtml(); expect(html).not.toContain(walletDefect(brokenXprvWallet()).heading); expect(html).toContain("btn-add-address"); expect(html).toContain(state.wallets[0].addresses[0].address); }); test("the defective wallet's notice does not bleed onto a healthy one", () => { state.wallets = [brokenXprvWallet("Broken"), healthyXprvWallet("Fine")]; const html = home.walletListHtml(); const healthyPart = html.slice(html.indexOf("Fine")); expect(html).toContain(walletDefect(state.wallets[0]).heading); expect(healthyPart).not.toContain( walletDefect(state.wallets[0]).heading, ); expect(healthyPart).toContain("btn-add-address"); }); }); // A minimal DOM for driving the popup views: any element exists on first // lookup, and click() runs the listeners a view attached to it. 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(id) { return Promise.all((node(id).listeners.click || []).map((fn) => fn())); } // getSignerForAddress refuses this wallet's key, but only once the password // has been typed and spent, and the screens report that refusal as a wrong // password or a failed send. So every control that leads to it refuses first. describe("every way to a signature or the private key refuses a defective wallet first", () => { const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; let state; let decryptWithPassword; let home; let addressDetail; let addressToken; let approval; let confirmTx; let address; let shortMessage; // 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; beforeAll(() => { state = require("../src/shared/state").state; decryptWithPassword = require("../src/shared/vault").decryptWithPassword; home = require("../src/popup/views/home"); addressDetail = require("../src/popup/views/addressDetail"); addressToken = require("../src/popup/views/addressToken"); approval = require("../src/popup/views/approval"); confirmTx = require("../src/popup/views/confirmTx"); }); beforeEach(() => { const broken = brokenXprvWallet(); // A balance, so that no Send button's zero-balance refusal can stand // in for the defect check. broken.addresses[0].balance = "1.0000"; broken.addresses[0].tokenBalances = []; address = broken.addresses[0].address; shortMessage = walletDefect(broken).shortMessage; approvalDetails = null; sent = []; 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, ); }, }, }; // What the wallet's stored secret decrypts to: the account-level key // it was imported from. decryptWithPassword.mockReset(); decryptWithPassword.mockResolvedValue(accountXprv(VECTOR_PHRASE)); state.wallets = [broken]; state.activeAddress = address; state.selectedWallet = 0; state.selectedAddress = 0; state.selectedToken = "ETH"; state.viewStack = []; }); afterEach(() => { state.wallets = []; state.activeAddress = null; state.selectedWallet = null; state.selectedAddress = null; state.selectedToken = null; }); test("Send on the main screen", async () => { state.currentView = "main"; home.init({}); await click("btn-main-send"); expect(node("flash-msg").textContent).toBe(shortMessage); expect(state.currentView).toBe("main"); }); test("Send on the address screen", async () => { state.currentView = "address"; addressDetail.init({}); await click("btn-send"); expect(node("flash-msg").textContent).toBe(shortMessage); expect(state.currentView).toBe("address"); }); test("Export Private Key on the address screen", async () => { state.currentView = "address"; addressDetail.init({}); await click("btn-export-privkey"); expect(node("flash-msg").textContent).toBe(shortMessage); expect(state.currentView).toBe("address"); }); test("Send on a token's screen", async () => { state.currentView = "address-token"; addressToken.init({}); await click("btn-address-token-send"); expect(node("flash-msg").textContent).toBe(shortMessage); expect(state.currentView).toBe("address-token"); }); // The popup reopens onto this screen from a saved view, so the Send // buttons above are not the only way onto it. The screen is not drawn, // because drawing it starts a fee estimate against the network; with a // decrypt that fails, a handler without the check stops at the password // instead of going on to a transaction that was never set up. test("Send on the confirmation screen", async () => { decryptWithPassword.mockRejectedValue(new Error("wrong password")); state.currentView = "confirm-tx"; confirmTx.init({}); node("confirm-tx-password").value = "any password"; await click("btn-confirm-send"); expect(decryptWithPassword).not.toHaveBeenCalled(); expect(node("confirm-tx-password-error").textContent).toBe( shortMessage, ); }); async function openTxApproval() { approvalDetails = { type: "tx", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: address, approvedTx: { from: address, to: RECIPIENT, value: "0x0", data: "0x", chainId: 1, nonce: 0, gasLimit: "21000", maxFeePerGas: "1000000000", }, }; approval.init({}); await approval.show(1); } async function openSignApproval() { approvalDetails = { type: "sign", origin: "https://dapp.example", isPhishingDomain: false, approvedFrom: address, // "Hello", as the hex a page sends. signParams: { method: "personal_sign", message: "0x48656c6c6f", from: address, }, }; approval.init({}); await approval.show(1); } test("the transaction approval screen says so and disables Approve", async () => { await openTxApproval(); expect(node("approve-tx-error").textContent).toBe(shortMessage); expect(node("btn-approve-tx").disabled).toBe(true); }); // The stub runs a disabled button's listener, which a browser would not: // what is asked here is whether the handler refuses on its own. test("Approve on the transaction approval screen does not decrypt", async () => { await openTxApproval(); node("approve-tx-password").value = "any password"; await click("btn-approve-tx"); expect(decryptWithPassword).not.toHaveBeenCalled(); expect(sent.map((msg) => msg.type)).not.toContain( "AUTISTMASK_TX_RESPONSE", ); expect(node("approve-tx-error").textContent).toBe(shortMessage); }); test("the signature approval screen says so and disables Approve", async () => { await openSignApproval(); expect(node("approve-sign-error").textContent).toBe(shortMessage); expect(node("btn-approve-sign").disabled).toBe(true); }); test("Approve on the signature approval screen does not decrypt", async () => { await openSignApproval(); node("approve-sign-password").value = "any password"; await click("btn-approve-sign"); expect(decryptWithPassword).not.toHaveBeenCalled(); expect(sent.map((msg) => msg.type)).not.toContain( "AUTISTMASK_SIGN_RESPONSE", ); expect(node("approve-sign-error").textContent).toBe(shortMessage); }); }); describe("no path throws an unhandled error for a defective wallet", () => { test("address derivation from the stored xpub still works", () => { // The stored xpub is at a non-standard depth but is a valid extended // key; deriving from it is what the list render already does. const w = brokenXprvWallet(); expect(() => wallet.deriveAddressFromXpub(w.xpub, 0)).not.toThrow(); expect(wallet.deriveAddressFromXpub(w.xpub, 0)).toBe( w.addresses[0].address, ); }); test("the wallet list renders without throwing", () => { const { state } = require("../src/shared/state"); const home = require("../src/popup/views/home"); state.wallets = [brokenXprvWallet()]; expect(() => home.walletListHtml()).not.toThrow(); state.wallets = []; }); test("signing refuses with the named defect rather than a bare failure", () => { // getSignerForAddress is the backstop behind the UI gate. It must // still refuse, and it must say why in a sentence the user can read. let thrown = null; try { wallet.getSignerForAddress( { type: "xprv" }, 0, accountXprv(VECTOR_PHRASE), ); } catch (e) { thrown = e; } expect(thrown).not.toBeNull(); expect(thrown.message).toMatch(/master key/); expect(thrown.message.trimEnd()).toMatch(/\.$/); }); test("a healthy xprv wallet signs as it always did", () => { const signer = wallet.getSignerForAddress( { type: "xprv" }, 0, masterXprv(VECTOR_PHRASE), ); expect(signer.address).toBe(healthyXprvWallet().addresses[0].address); }); });