// Tests for src/shared/wallet.js: the DEBUG build flag as it gates mnemonic // generation (first two describes), and HD key derivation against published // known-answer vectors (rest of the file). // // The modules read the __BUILD_DEBUG__ global that esbuild replaces at bundle // time. Under jest the global is absent, which is exactly the release-build // case; the debug-build case is exercised by defining the global and // re-requiring the modules with a fresh registry. const WORDS_IN_12_WORD_PHRASE = 12; function loadWallet() { const constants = require("../src/shared/constants"); const wallet = require("../src/shared/wallet"); const log = require("../src/shared/log"); return { constants, wallet, log }; } describe("generateMnemonic in a release build", () => { beforeEach(() => { jest.resetModules(); delete globalThis.__BUILD_DEBUG__; }); test("DEBUG defaults to false when the build define is absent", () => { const { constants } = loadWallet(); expect(constants.DEBUG).toBe(false); }); test("returns fresh, valid 12-word phrases that are not the test phrase", () => { const { constants, wallet } = loadWallet(); const first = wallet.generateMnemonic(); const second = wallet.generateMnemonic(); expect(first).not.toBe(second); for (const phrase of [first, second]) { expect(wallet.isValidMnemonic(phrase)).toBe(true); expect(phrase.split(" ")).toHaveLength(WORDS_IN_12_WORD_PHRASE); expect(phrase).not.toBe(constants.DEBUG_MNEMONIC); } }); test("derives a usable HD wallet from the generated phrase", () => { const { wallet } = loadWallet(); const { xpub, firstAddress } = wallet.hdWalletFromMnemonic( wallet.generateMnemonic(), ); expect(xpub.startsWith("xpub")).toBe(true); expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); }); test("the runtime debug toggle cannot re-enable the test phrase", () => { const { constants, wallet, log } = loadWallet(); // What the settings easter-egg toggle does at runtime. log.setRuntimeDebug(true); expect(log.isDebug()).toBe(true); const phrase = wallet.generateMnemonic(); expect(phrase).not.toBe(constants.DEBUG_MNEMONIC); expect(wallet.isValidMnemonic(phrase)).toBe(true); expect(phrase).not.toBe(wallet.generateMnemonic()); log.setRuntimeDebug(false); }); }); describe("generateMnemonic in a debug build", () => { beforeEach(() => { jest.resetModules(); globalThis.__BUILD_DEBUG__ = true; }); afterEach(() => { delete globalThis.__BUILD_DEBUG__; }); test("DEBUG is true and the test phrase is returned", () => { const { constants, wallet } = loadWallet(); expect(constants.DEBUG).toBe(true); expect(wallet.generateMnemonic()).toBe(constants.DEBUG_MNEMONIC); }); test("the test phrase is itself a valid 12-word BIP-39 phrase", () => { const { constants, wallet } = loadWallet(); expect(wallet.isValidMnemonic(constants.DEBUG_MNEMONIC)).toBe(true); expect(constants.DEBUG_MNEMONIC.split(" ")).toHaveLength( WORDS_IN_12_WORD_PHRASE, ); }); }); // --------------------------------------------------------------------------- // Key derivation. // // Every address below is a published constant, not something this codebase // produced. Asserting against what the implementation happens to return today // would pass just as happily with the wrong coin type, the wrong path depth or // a non-empty seed passphrase, all of which silently send funds to addresses // no other wallet can recover. // // Vector sources: // // VECTOR_PHRASE / VECTOR_ADDRESSES / VECTOR_PRIVATE_KEYS — the standard // development recovery phrase and the first three accounts it yields at // m/44'/60'/0'/0/n with an empty seed passphrase, as published in the // Hardhat and Ganache documentation. Publicly known; never fund it. // // ZERO_ENTROPY_PHRASE / ZERO_ENTROPY_ADDRESS — the BIP-39 all-zero-entropy // phrase (Trezor's official BIP-39 vector set, first entry) and its // m/44'/60'/0'/0/0 Ethereum address with an empty seed passphrase. A second, // independently published phrase so the pin is not one vector deep. // // BIP32_VECTOR_1_XPRV — the master key of BIP-32 test vector 1 // (seed 000102030405060708090a0b0c0d0e0f). // // The two Hardhat facts cross-check each other: VECTOR_PRIVATE_KEYS[n] is the // published key for VECTOR_ADDRESSES[n], so addressFromPrivateKey and the HD // path must meet at the same address from two different directions. const { HDNodeWallet, Mnemonic, verifyMessage } = require("ethers"); const wallet = require("../src/shared/wallet"); const { BIP44_ETH_PATH } = require("../src/shared/constants"); const VECTOR_PHRASE = "test test test test test test test test test test test junk"; const VECTOR_ADDRESSES = [ "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", ]; const VECTOR_PRIVATE_KEYS = [ "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a", ]; const ZERO_ENTROPY_PHRASE = "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon about"; const ZERO_ENTROPY_ADDRESS = "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"; const BIP32_VECTOR_1_XPRV = "xprv9s21ZrQH143K3QTDL4LXw2F7HEK3wJUD2nW2nRk4stbPy6cq3jPPqji" + "ChkVvvNKmPGJxWUtg6LnF5kejMRNNU3TGtRBeJgk33yuGBxrMPHi"; // The master (depth-0) extended private key for a phrase, which is what the // import-an-xprv flow is handed. Built with ethers rather than with the module // under test, so hdWalletFromXprv is not being checked against itself. function masterXprv(phrase, passphrase = "") { return HDNodeWallet.fromSeed( Mnemonic.fromPhrase(phrase, passphrase).computeSeed(), ).extendedKey; } describe("hdWalletFromMnemonic", () => { test("first address matches the published vector for m/44'/60'/0'/0/0", () => { expect(wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress).toBe( VECTOR_ADDRESSES[0], ); }); test("second published phrase derives its published address", () => { expect( wallet.hdWalletFromMnemonic(ZERO_ENTROPY_PHRASE).firstAddress, ).toBe(ZERO_ENTROPY_ADDRESS); }); test("returns the account-level xpub, which is watch-only", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(xpub.startsWith("xpub")).toBe(true); // A neutered ethers node exposes no private key at all, so accept // either absent or null rather than pinning which. expect( HDNodeWallet.fromExtendedKey(xpub).privateKey ?? null, ).toBeNull(); expect(wallet.isValidXprv(xpub)).toBe(false); }); test("the account path is the documented BIP-44 Ethereum path", () => { expect(BIP44_ETH_PATH).toBe("m/44'/60'/0'/0"); }); test("rejects an invalid recovery phrase rather than deriving from it", () => { expect(() => wallet.hdWalletFromMnemonic("not a phrase")).toThrow(); }); }); describe("deriveAddressFromXpub", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); test.each([0, 1, 2])( "child %i matches the published vector address", (index) => { expect(wallet.deriveAddressFromXpub(xpub, index)).toBe( VECTOR_ADDRESSES[index], ); }, ); test("agrees with hdWalletFromMnemonic at index 0", () => { expect(wallet.deriveAddressFromXpub(xpub, 0)).toBe( wallet.hdWalletFromMnemonic(VECTOR_PHRASE).firstAddress, ); }); test("rejects garbage instead of returning an address", () => { expect(() => wallet.deriveAddressFromXpub("xpub-nonsense", 0), ).toThrow(); }); }); describe("hdWalletFromMnemonic seed passphrase handling", () => { // The vectors above are only reproducible with an empty BIP-39 seed // passphrase. This pins that the empty string reaching // HDNodeWallet.fromPhrase is load-bearing: with any passphrase applied the // published address is unreachable, and a wallet derived that way could // not be restored anywhere else from the phrase alone. test("a non-empty seed passphrase would yield a different address", () => { const withPassphrase = HDNodeWallet.fromPhrase( VECTOR_PHRASE, "TREZOR", BIP44_ETH_PATH, ).deriveChild(0).address; expect(withPassphrase).not.toBe(VECTOR_ADDRESSES[0]); }); }); describe("hdWalletFromXprv", () => { // hdWalletFromMnemonic derives the absolute path "m/44'/60'/0'/0" while // hdWalletFromXprv derives the relative path "44'/60'/0'/0". For a // depth-0 master key the two are the same derivation; these tests pin that // equivalence to a published address rather than assuming it. test("master xprv for the vector phrase yields the vector address", () => { expect( wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)).firstAddress, ).toBe(VECTOR_ADDRESSES[0]); }); test("agrees with hdWalletFromMnemonic on xpub and address", () => { const fromPhrase = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); const fromXprv = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); expect(fromXprv).toEqual(fromPhrase); }); test("derived xpub generates the same child addresses", () => { const { xpub } = wallet.hdWalletFromXprv(masterXprv(VECTOR_PHRASE)); expect( [0, 1, 2].map((i) => wallet.deriveAddressFromXpub(xpub, i)), ).toEqual(VECTOR_ADDRESSES); }); test("accepts the BIP-32 test vector 1 master key", () => { const { xpub, firstAddress } = wallet.hdWalletFromXprv(BIP32_VECTOR_1_XPRV); expect(xpub.startsWith("xpub")).toBe(true); expect(firstAddress).toMatch(/^0x[0-9a-fA-F]{40}$/); }); test("rejects a watch-only xpub", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(() => wallet.hdWalletFromXprv(xpub)).toThrow(); }); test("rejects garbage", () => { expect(() => wallet.hdWalletFromXprv("nonsense")).toThrow(); }); }); describe("isValidXprv", () => { test.each([ ["BIP-32 test vector 1 master key", BIP32_VECTOR_1_XPRV, true], ["the empty string", "", false], ["garbage", "not-a-key", false], ["a bare private key", VECTOR_PRIVATE_KEYS[0], false], ["a truncated xprv", BIP32_VECTOR_1_XPRV.slice(0, -6), false], ["an xprv with an extra character", BIP32_VECTOR_1_XPRV + "a", false], ])("%s -> %s", (_name, key, expected) => { expect(wallet.isValidXprv(key)).toBe(expected); }); test("a watch-only xpub is not an xprv", () => { const { xpub } = wallet.hdWalletFromMnemonic(VECTOR_PHRASE); expect(wallet.isValidXprv(xpub)).toBe(false); }); // Skipped: this asserts the correct behaviour, which the code does not // currently have. isValidXprv gates the paste-your-extended-private-key // import in src/popup/views/addWallet.js:215, and it accepts a key with a // one-character typo: ethers' HDNodeWallet.fromExtendedKey skips base58 // checksum verification whenever the decoded payload is the usual 82 // bytes, which is the whole point of that checksum. Measured on this // vector: changing any one of the last 14 characters passes validation, // and for 9 of those 14 positions the import silently yields a *different* // wallet (e.g. 0x3F334f0a356d6B46B1d70B590E7437D77100d28D instead of // 0x022b971dFF0C43305e691DEd7a14367AF19D6407) with no error shown. // Reported on the pull request for // https://git.eeqj.de/sneak/AutistMask/issues/159 to be filed as its own // issue; out of scope here, which is tests only. Unskip when it is fixed. test.skip("rejects an extended key with a one-character typo", () => { const index = BIP32_VECTOR_1_XPRV.length - 8; const typo = BIP32_VECTOR_1_XPRV.slice(0, index) + (BIP32_VECTOR_1_XPRV[index] === "a" ? "b" : "a") + BIP32_VECTOR_1_XPRV.slice(index + 1); expect(wallet.isValidXprv(typo)).toBe(false); }); }); describe("isValidMnemonic", () => { test.each([ ["the vector phrase", VECTOR_PHRASE, true], ["the BIP-39 zero-entropy phrase", ZERO_ENTROPY_PHRASE, true], [ "a 12-word phrase with a bad checksum", "abandon abandon abandon abandon abandon abandon " + "abandon abandon abandon abandon abandon abandon", false, ], ["an 11-word phrase", "abandon ".repeat(10) + "about", false], ["a word outside the wordlist", VECTOR_PHRASE + " zzzzzz", false], ["the empty string", "", false], ["garbage", "correct horse battery staple", false], ])("%s -> %s", (_name, phrase, expected) => { expect(wallet.isValidMnemonic(phrase)).toBe(expected); }); }); describe("addressFromPrivateKey", () => { test.each([0, 1, 2])( "published key %i yields its published address", (index) => { expect( wallet.addressFromPrivateKey(VECTOR_PRIVATE_KEYS[index]), ).toBe(VECTOR_ADDRESSES[index]); }, ); test("rejects a key of the wrong length", () => { expect(() => wallet.addressFromPrivateKey("0xdeadbeef")).toThrow(); }); test("rejects the empty string", () => { expect(() => wallet.addressFromPrivateKey("")).toThrow(); }); }); describe("getSignerForAddress", () => { test.each([0, 1, 2])("hd wallet, address index %i", (index) => { const signer = wallet.getSignerForAddress( { type: "hd" }, index, VECTOR_PHRASE, ); expect(signer.address).toBe(VECTOR_ADDRESSES[index]); expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); }); test.each([0, 1, 2])("xprv wallet, address index %i", (index) => { const signer = wallet.getSignerForAddress( { type: "xprv" }, index, masterXprv(VECTOR_PHRASE), ); expect(signer.address).toBe(VECTOR_ADDRESSES[index]); expect(signer.privateKey).toBe(VECTOR_PRIVATE_KEYS[index]); }); test("single private key ignores the address index", () => { for (const index of [0, 1, 2]) { const signer = wallet.getSignerForAddress( { type: "privkey" }, index, VECTOR_PRIVATE_KEYS[1], ); expect(signer.address).toBe(VECTOR_ADDRESSES[1]); } }); test("the returned signer signs recoverably as the expected address", async () => { const signer = wallet.getSignerForAddress( { type: "hd" }, 1, VECTOR_PHRASE, ); const message = "AutistMask derivation test"; const signature = await signer.signMessage(message); expect(verifyMessage(message, signature)).toBe(VECTOR_ADDRESSES[1]); }); });