// 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 nothing on the way there throws, and that a wallet // imported from a real master key is untouched by any of it. 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"); }); }); 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); }); });