411 lines
15 KiB
JavaScript
411 lines
15 KiB
JavaScript
// 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.
|
|
// Tracked as https://git.eeqj.de/sneak/AutistMask/issues/210; 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]);
|
|
});
|
|
});
|