fix: enforce the base58 checksum and reject non-master extended keys (closes #210)
Some checks failed
check / check (push) Has been cancelled

This commit was merged in pull request #232.
This commit is contained in:
2026-08-11 15:31:50 +02:00
parent fb9e8f5542
commit b155c0fcd6
5 changed files with 238 additions and 30 deletions

View File

@@ -136,7 +136,9 @@
<div id="add-wallet-section-xprv" class="hidden">
<p class="mb-2">
Paste your extended private key (xprv) below. This will
import the HD wallet and scan for used addresses.
import the HD wallet and scan for used addresses. It
must be the master key for the wallet; an account-level
or child key is not supported.
</p>
<div class="mb-2">
<input

View File

@@ -6,6 +6,7 @@ const {
addressFromPrivateKey,
hdWalletFromXprv,
isValidXprv,
isMasterExtendedKey,
} = require("../../shared/wallet");
const { encryptWithPassword } = require("../../shared/vault");
const { state, saveState } = require("../../shared/state");
@@ -213,14 +214,25 @@ async function importXprvKey(ctx) {
return;
}
if (!isValidXprv(xprv)) {
showFlash("Invalid extended private key.");
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
if (!isMasterExtendedKey(xprv)) {
showFlash(
"That is an account-level or child key, which cannot be imported. " +
"Please paste the master extended private key for the wallet.",
);
return;
}
let result;
try {
result = hdWalletFromXprv(xprv);
} catch (e) {
showFlash("Invalid extended private key.");
showFlash(
"That extended private key is not valid. Please check it and try again.",
);
return;
}
const { xpub, firstAddress } = result;

View File

@@ -16,8 +16,60 @@ function generateMnemonic() {
return m.phrase;
}
// Every extended key (xprv or xpub) entering the app goes through this.
//
// ethers' HDNodeWallet.fromExtendedKey does NOT verify the base58 checksum
// when the decoded payload is the usual 82 bytes, which is exactly the case
// the checksum exists to catch: a key with a one-character typo parses into a
// *different* wallet instead of being rejected. Re-encoding the parsed node
// reproduces a well-formed key byte for byte, checksum included, so comparing
// the round trip against the input rejects any altered character. Measured by
// the sweep in tests/wallet.test.js over every single-character substitution
// of the BIP-32 vector 1 master key: 199 parse without the round-trip
// comparison, 0 with it.
//
// Returns the parsed node, or null if the key is not a well-formed extended
// key. Callers turn null into a user-facing error; none of them may fall back
// to fromExtendedKey directly.
function parseExtendedKey(key) {
if (typeof key !== "string") return null;
try {
const node = HDNodeWallet.fromExtendedKey(key);
return node.extendedKey === key ? node : null;
} catch {
return null;
}
}
// A master key is at depth 0. Only from there is BIP44_ETH_PATH the absolute
// path it names; deriving it under an account-level or child key yields
// addresses that correspond to nothing the user holds.
const MASTER_DEPTH = 0;
// Parse an extended private key that the BIP-44 Ethereum account path can be
// derived from, or throw. Both callers derive BIP44_ETH_PATH from the result.
function masterXprvOrThrow(key) {
const node = parseExtendedKey(key);
if (!node) {
throw new Error("Not a valid extended private key (xprv).");
}
if (!node.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
if (node.depth !== MASTER_DEPTH) {
throw new Error(
"Not a master extended private key (xprv): an account-level or " +
"child key cannot be imported.",
);
}
return node;
}
function deriveAddressFromXpub(xpub, index) {
const node = HDNodeWallet.fromExtendedKey(xpub);
const node = parseExtendedKey(xpub);
if (!node) {
throw new Error("Not a valid extended key.");
}
return node.deriveChild(index).address;
}
@@ -29,23 +81,28 @@ function hdWalletFromMnemonic(mnemonic) {
}
function hdWalletFromXprv(xprv) {
const root = HDNodeWallet.fromExtendedKey(xprv);
if (!root.privateKey) {
throw new Error("Not an extended private key (xprv).");
}
const node = root.derivePath("44'/60'/0'/0");
// BIP44_ETH_PATH is absolute ("m/..."), which ethers will only derive from
// a depth-0 node. The relative form this used to derive would have been
// applied *beneath* an account-level key instead of being refused.
const node = masterXprvOrThrow(xprv).derivePath(BIP44_ETH_PATH);
const xpub = node.neuter().extendedKey;
const firstAddress = node.deriveChild(0).address;
return { xpub, firstAddress };
}
// Well-formed extended private key. Says nothing about depth: the import view
// reports a non-master key separately, since "check it for a typo" is the
// wrong advice for a key the user copied correctly.
function isValidXprv(key) {
try {
const node = HDNodeWallet.fromExtendedKey(key);
return !!node.privateKey;
} catch {
return false;
}
const node = parseExtendedKey(key);
return !!(node && node.privateKey);
}
// Whether an extended key is a master key, i.e. the one BIP44_ETH_PATH can be
// derived from. False for anything parseExtendedKey rejects.
function isMasterExtendedKey(key) {
const node = parseExtendedKey(key);
return !!node && node.depth === MASTER_DEPTH;
}
function addressFromPrivateKey(key) {
@@ -63,8 +120,8 @@ function getSignerForAddress(walletData, addrIndex, decryptedSecret) {
return node.deriveChild(addrIndex);
}
if (walletData.type === "xprv") {
const root = HDNodeWallet.fromExtendedKey(decryptedSecret);
const node = root.derivePath("44'/60'/0'/0");
const node =
masterXprvOrThrow(decryptedSecret).derivePath(BIP44_ETH_PATH);
return node.deriveChild(addrIndex);
}
return new Wallet(decryptedSecret);
@@ -89,6 +146,7 @@ module.exports = {
hdWalletFromMnemonic,
hdWalletFromXprv,
isValidXprv,
isMasterExtendedKey,
addressFromPrivateKey,
getSignerForAddress,
isValidMnemonic,