All checks were successful
check / check (push) Successful in 31s
An xprv wallet imported before non-master keys were refused holds a key whose depth is greater than zero. Its addresses were derived by applying the Ethereum path beneath that key, so they are not the addresses the key produces under the standard path, and signing for them now throws — on the send screen, with no explanation. Detect it at wallet-list render time instead. An xprv wallet stores the neutered node four levels below the imported key, so a master import stores a depth-4 xpub and a depth-d import stores depth d + 4; the stored xpub is therefore an exact read on the imported key's depth and needs no password. The wallet list renders a named explanation under the wallet's name, the "+" button is withheld, and send, dapp transaction approval, dapp message signing and private-key export all refuse before asking for a password. getSignerForAddress remains the backstop and now says why in a sentence. The copy states what is true and nothing more: the addresses do descend from the key that was imported, so it neither promises the funds are safe nor implies anything was lost. The wallet is not deleted or rewritten.
291 lines
9.8 KiB
JavaScript
291 lines
9.8 KiB
JavaScript
// 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);
|
|
});
|
|
});
|