What changed, and why it matters
This commit adds a new wallet RPC command called addhdkey that lets users import a BIP32 extended private key (xprv) into a Bitcoin Core wallet, or generate a fresh random one. It is a feature addition, not a bug fix. The code checks that the wallet stores private keys, requires the wallet to be unlocked, rejects plain xpubs, and refuses duplicate keys. There is no indication in the commit that this fixes a security vulnerability.
Review as a normal feature addition. If deploying, ensure RPC access controls and wallet encryption practices are adequate because addhdkey accepts highly sensitive extended private keys. No urgent security patch action is indicated by this commit alone.
Security signals we found
New RPC exposes xprv import surface
Requires wallet to be unlocked (EnsureWalletIsUnlocked)
Blocked on wallets with WALLET_FLAG_DISABLE_PRIVATE_KEYS
Validates input and distinguishes xpub from xprv
No bug-fix or vulnerability-disclosure language in commit
Evidence from the diff
The patch introduces addhdkey() in src/wallet/rpc/wallet.cpp and registers it under the wallet RPC category. It accepts an optional xprv string, validates it with DecodeExtKey, gives a clearer error if an xpub is supplied, wraps the key in an unused(…) descriptor, and adds it to the wallet via AddWalletDescriptor. It returns the corresponding xpub. Tests cover happy path, duplicate-key rejection, xpub rejection, and disabled-private-key wallet rejection.
Changed components
src/wallet/rpc/wallet.cpptest/functional/wallet_hd.pyInspect captured patch +131 / −0
diff --git a/src/wallet/rpc/wallet.cpp b/src/wallet/rpc/wallet.cpp
index b6c9179d..8aa15c7e 100644
--- a/src/wallet/rpc/wallet.cpp
+++ b/src/wallet/rpc/wallet.cpp
@@ -840,6 +840,82 @@ static RPCMethod createwalletdescriptor()
};
}
+RPCMethod addhdkey()
+{
+ return RPCMethod{
+ "addhdkey",
+ "Add a BIP 32 HD key to the wallet that can be used with 'createwalletdescriptor'\n",
+ {
+ {"hdkey", RPCArg::Type::STR, RPCArg::DefaultHint{"Automatically generated new key"}, "The BIP 32 extended private key to add. If none is provided, a randomly generated one will be added."},
+ },
+ RPCResult{
+ RPCResult::Type::OBJ, "", "",
+ {
+ {RPCResult::Type::STR, "xpub", "The xpub of the HD key that was added to the wallet"}
+ },
+ },
+ RPCExamples{
+ HelpExampleCli("addhdkey", "xprv") + HelpExampleRpc("addhdkey", "xprv")
+ },
+ [&](const RPCMethod& self, const JSONRPCRequest& request) -> UniValue
+ {
+ std::shared_ptr<CWallet> const wallet = GetWalletForJSONRPCRequest(request);
+ if (!wallet) return UniValue::VNULL;
+
+ if (wallet->IsWalletFlagSet(WALLET_FLAG_DISABLE_PRIVATE_KEYS)) {
+ throw JSONRPCError(RPC_WALLET_ERROR, "addhdkey is not available for wallets without private keys");
+ }
+
+ EnsureWalletIsUnlocked(*wallet);
+
+ CExtKey hdkey;
+ if (request.params[0].isNull()) {
+ CKey seed_key = GenerateRandomKey();
+ hdkey.SetSeed(seed_key);
+ } else {
+ hdkey = DecodeExtKey(request.params[0].get_str());
+ if (!hdkey.key.IsValid()) {
+ // Check if the user gave us an xpub and give a more descriptive error if so
+ CExtPubKey xpub = DecodeExtPubKey(request.params[0].get_str());
+ if (xpub.pubkey.IsValid()) {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Extended public key (xpub) provided, but extended private key (xprv) is required");
+ } else {
+ throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Could not parse HD key");
+ }
+ }
+ }
+
+ LOCK(wallet->cs_wallet);
+ std::string desc_str = "unused(" + EncodeExtKey(hdkey) + ")";
+ FlatSigningProvider keys;
+ std::string error;
+ std::vector<std::unique_ptr<Descriptor>> descs = Parse(desc_str, keys, error, false);
+ CHECK_NONFATAL(!descs.empty());
+ WalletDescriptor w_desc(std::move(descs.at(0)), GetTime(), 0, 0, 0);
+ if (wallet->GetDescriptorScriptPubKeyMan(w_desc) != nullptr) {
+ throw JSONRPCError(RPC_WALLET_ERROR, "HD key already exists");
+ }
+
+ auto spkm = wallet->AddWalletDescriptor(w_desc, keys, /*label=*/"", /*internal=*/false);
+ if (!spkm) {
+ throw JSONRPCError(RPC_WALLET_ERROR, util::ErrorString(spkm).original);
+ }
+
+ UniValue response(UniValue::VOBJ);
+ const DescriptorScriptPubKeyMan& desc_spkm = spkm->get();
+ LOCK(desc_spkm.cs_desc_man);
+ std::set<CPubKey> pubkeys;
+ std::set<CExtPubKey> extpubs;
+ desc_spkm.GetWalletDescriptor().descriptor->GetPubKeys(pubkeys, extpubs);
+ CHECK_NONFATAL(pubkeys.size() == 0);
+ CHECK_NONFATAL(extpubs.size() == 1);
+ response.pushKV("xpub", EncodeExtPubKey(*extpubs.begin()));
+
+ return response;
+ },
+ };
+}
+
// addresses
RPCMethod getaddressinfo();
RPCMethod getnewaddress();
@@ -907,6 +983,7 @@ std::span<const CRPCCommand> GetWalletRPCCommands()
{"rawtransactions", &fundrawtransaction},
{"wallet", &abandontransaction},
{"wallet", &abortrescan},
+ {"wallet", &addhdkey},
{"wallet", &backupwallet},
{"wallet", &bumpfee},
{"wallet", &psbtbumpfee},
diff --git a/test/functional/wallet_hd.py b/test/functional/wallet_hd.py
index c130c731..e5227e6f 100755
--- a/test/functional/wallet_hd.py
+++ b/test/functional/wallet_hd.py
@@ -7,10 +7,12 @@
import shutil
from test_framework.blocktools import COINBASE_MATURITY
+from test_framework.descriptors import descsum_create
from test_framework.test_framework import BitcoinTestFramework
from test_framework.util import (
assert_equal,
wallet_importprivkey,
+ assert_raises_rpc_error,
)
@@ -25,6 +27,56 @@ class WalletHDTest(BitcoinTestFramework):
def skip_test_if_missing_module(self):
self.skip_if_no_wallet()
+ def test_addhdkey(self):
+ self.log.info("Test addhdkey")
+ def_wallet = self.nodes[0].get_wallet_rpc(self.default_wallet_name)
+ self.nodes[0].createwallet("hdkey")
+ wallet = self.nodes[0].get_wallet_rpc("hdkey")
+
+ assert_equal(len(wallet.gethdkeys()), 1)
+
+ wallet.addhdkey()
+ xpub_info = wallet.gethdkeys()
+ assert_equal(len(xpub_info), 2)
+ for x in xpub_info:
+ if len(x["descriptors"]) == 1 and x["descriptors"][0]["desc"].startswith("unused("):
+ break
+ else:
+ assert False, "Did not find HD key with no descriptors"
+
+ imp_xpub_info = def_wallet.gethdkeys(private=True)[0]
+ imp_xpub = imp_xpub_info["xpub"]
+ imp_xprv = imp_xpub_info["xprv"]
+
+ assert_raises_rpc_error(-5, "Extended public key (xpub) provided, but extended private key (xprv) is required", wallet.addhdkey, imp_xpub)
+ add_res = wallet.addhdkey(imp_xprv)
+ expected_unused_desc = descsum_create(f"unused({imp_xpub})")
+ assert_equal(add_res["xpub"], imp_xpub)
+ xpub_info = wallet.gethdkeys()
+ assert_equal(len(xpub_info), 3)
+ for x in xpub_info:
+ if x["xpub"] == imp_xpub:
+ assert_equal(len(x["descriptors"]), 1)
+ assert_equal(x["descriptors"][0]["desc"], expected_unused_desc)
+ break
+ else:
+ assert False, "Added HD key was not found in wallet"
+
+ for d in wallet.listdescriptors()["descriptors"]:
+ if d["desc"] == expected_unused_desc:
+ assert_equal(d["active"], False)
+ break
+ else:
+ assert False, "Added HD key's descriptor was not found in wallet"
+
+ assert_raises_rpc_error(-4, "HD key already exists", wallet.addhdkey, imp_xprv)
+
+ def test_addhdkey_noprivs(self):
+ self.log.info("Test addhdkey is not available for wallets without privkeys")
+ self.nodes[0].createwallet("hdkey_noprivs", disable_private_keys=True)
+ wallet = self.nodes[0].get_wallet_rpc("hdkey_noprivs")
+ assert_raises_rpc_error(-4, "addhdkey is not available for wallets without private keys", wallet.addhdkey)
+
def run_test(self):
# Make sure we use hd, keep masterkeyid
hd_fingerprint = self.nodes[1].getaddressinfo(self.nodes[1].getnewaddress())['hdmasterfingerprint']
@@ -124,6 +176,8 @@ class WalletHDTest(BitcoinTestFramework):
assert_equal(keypath[0:14], "m/84h/1h/0h/1/")
+ self.test_addhdkey()
+ self.test_addhdkey_noprivs()
if __name__ == '__main__':
WalletHDTest(__file__).main()
Why this scored 23/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.