lightning-hsmtool: fix dumponchaindescriptors for mnemonic hsm_secrets.
What changed, and why it matters
This commit fixes a bug in the lightning-hsmtool utility's 'dumponchaindescriptors' command. For newer Core Lightning nodes created with a mnemonic seed (version 25.12 and later), the tool was deriving the wrong Bitcoin wallet descriptors, which would not match the actual on-chain addresses the node uses. The fix makes the tool follow the correct key derivation path for mnemonic-based secrets. It is a correctness/reliability bug rather than a remote-exploitable vulnerability, but it could cause users to generate incorrect backup descriptors and fail to recover or monitor funds.
Users who created nodes with mnemonic hsm_secret files (Core Lightning >= v25.12) and used 'lightning-hsmtool dumponchaindescriptors' for backup or recovery should re-run the command after upgrading and verify the generated descriptors match on-chain addresses. Node operators relying on descriptor-based monitoring or recovery should confirm the corrected descriptors against the node's actual UTXOs.
Security signals we found
Incorrect key derivation in a backup/recovery tool
Wallet descriptor mismatch could prevent fund recovery
Fix gated on seed format detection (use_bip86_derivation)
Test vector updated to reflect corrected output
Evidence from the diff
The patch updates dumponchaindescriptors() in tools/lightning-hsmtool.c to detect BIP86/mnemonic-derived hsm_secret files via use_bip86_derivation(). For those seeds it builds the master key directly from the secret data and derives m/86’/0’/0’ as the base, then appends /0/ for the descriptor templates. For legacy 32-byte hsm_secret files it keeps the old HKDF-based ‘bip32 seed’ derivation and /0/0/ path. The test expectation in tests/test_wallet.py is updated to the corrected xpub and paths for the mnemonic case. No remote attack surface is introduced or removed; the issue is that the previous output was simply wrong for mnemonic-created nodes.
Changed components
tools/lightning-hsmtool.cdumponchaindescriptors commandtests/test_wallet.pyInspect captured patch +51 / −18
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index 40282125..79c0c478 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -1651,7 +1651,10 @@ def test_hsmtool_all_commands_work_with_mnemonic_formats(node_factory):
(["getnodeid", hsm_path], "03653e90c1ce4660fd8505dd6d643356e93cfe202af109d382787639dd5890e87d"),
(["getsecret", hsm_path], "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"),
(["makerune", hsm_path], "6VkrWMI2hm2a2UTkg-EyUrrBJN0RcuPB80I1pCVkTD89MA=="),
- (["dumponchaindescriptors", hsm_path], "wpkh(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*)#2jtshmk0\nsh(wpkh(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*))#u6am4was\ntr(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*)#v9hf4756"),
+ (["dumponchaindescriptors", hsm_path],
+ "wpkh(xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ/0/*)#hjszq0wk\n"
+ "sh(wpkh(xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ/0/*))#u0t3u3xz\n"
+ "tr(xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ/0/*)#8e7pq23w"),
]
for cmd_args, expected_output in test_commands:
diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c
index 6526f112..0de1920e 100644
--- a/tools/lightning-hsmtool.c
+++ b/tools/lightning-hsmtool.c
@@ -477,29 +477,60 @@ static void derive_to_remote(const struct unilateral_close_info *info, const cha
fmt_pubkey(tmpctx, info->commitment_point));
printf("privkey : %s\n", fmt_secret(tmpctx, &privkey.secret));
}
+
static void dumponchaindescriptors(const char *hsm_secret_path,
const u32 version, bool show_secrets)
{
- struct secret hsm_secret;
u8 bip32_seed[BIP32_ENTROPY_LEN_256];
u32 salt = 0;
struct ext_key master_extkey;
char *enc_xkey, *descriptor;
struct descriptor_checksum checksum;
struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path);
- /* Extract first 32 bytes for legacy compatibility */
- memcpy(hsm_secret.data, hsms->secret_data, 32);
+ const char *path;
+
+ /* For BIP 86, we derive from subkey: m/86'/0'/0' */
+ if (use_bip86_derivation(tal_bytelen(hsms->secret_data))) {
+ /* First create the master key from the seed */
+ struct ext_key master_key, bip86_base;
+ u32 base_path[3];
+ base_path[0] = 86 | 0x80000000; /* 86' */
+ base_path[1] = 0x80000000; /* 0' */
+ base_path[2] = 0x80000000; /* 0' */
+
+ if (bip32_key_from_seed(hsms->secret_data, tal_bytelen(hsms->secret_data),
+ version, 0, &master_key) != WALLY_OK) {
+ errx(ERROR_LIBWALLY,
+ "Failed to create master key from BIP32 seed");
+ }
- /* The root seed is derived from hsm_secret using hkdf.. */
- do {
- hkdf_sha256(bip32_seed, sizeof(bip32_seed),
- &salt, sizeof(salt),
- &hsm_secret, sizeof(hsm_secret),
- "bip32 seed", strlen("bip32 seed"));
- salt++;
- /* ..Which is used to derive m/ */
- } while (bip32_key_from_seed(bip32_seed, sizeof(bip32_seed),
- version, 0, &master_extkey) != WALLY_OK);
+ /* Derive the BIP86 base key */
+ if (bip32_key_from_parent_path(&master_key, base_path, 3, BIP32_FLAG_KEY_PRIVATE, &bip86_base) != WALLY_OK) {
+ errx(ERROR_LIBWALLY,
+ "Failed to derive BIP86 base key");
+ }
+ master_extkey = bip86_base;
+ /* Remaining path is 0/ */
+ path = "0";
+ } else {
+ struct secret hsm_secret;
+
+ /* Extract first 32 bytes for legacy compatibility */
+ memcpy(hsm_secret.data, hsms->secret_data, 32);
+
+ /* The root seed is derived from hsm_secret using hkdf.. */
+ do {
+ hkdf_sha256(bip32_seed, sizeof(bip32_seed),
+ &salt, sizeof(salt),
+ &hsm_secret, sizeof(hsm_secret),
+ "bip32 seed", strlen("bip32 seed"));
+ salt++;
+ /* ..Which is used to derive m/ */
+ } while (bip32_key_from_seed(bip32_seed, sizeof(bip32_seed),
+ version, 0, &master_extkey) != WALLY_OK);
+ /* Remaining path is 0/0/ */
+ path = "0/0";
+ }
if (show_secrets) {
if (bip32_key_to_base58(&master_extkey, BIP32_FLAG_KEY_PRIVATE,
@@ -513,20 +544,19 @@ static void dumponchaindescriptors(const char *hsm_secret_path,
/* Now we format the descriptor strings (we only ever create P2TR, P2WPKH, and
* P2SH-P2WPKH outputs). */
-
- descriptor = tal_fmt(NULL, "wpkh(%s/0/0/*)", enc_xkey);
+ descriptor = tal_fmt(NULL, "wpkh(%s/%s/*)", enc_xkey, path);
if (!descriptor_checksum(descriptor, strlen(descriptor), &checksum))
errx(ERROR_LIBWALLY, "Can't derive descriptor checksum for wpkh");
printf("%s#%s\n", descriptor, checksum.csum);
tal_free(descriptor);
- descriptor = tal_fmt(NULL, "sh(wpkh(%s/0/0/*))", enc_xkey);
+ descriptor = tal_fmt(NULL, "sh(wpkh(%s/%s/*))", enc_xkey, path);
if (!descriptor_checksum(descriptor, strlen(descriptor), &checksum))
errx(ERROR_LIBWALLY, "Can't derive descriptor checksum for sh(wpkh)");
printf("%s#%s\n", descriptor, checksum.csum);
tal_free(descriptor);
- descriptor = tal_fmt(NULL, "tr(%s/0/0/*)", enc_xkey);
+ descriptor = tal_fmt(NULL, "tr(%s/%s/*)", enc_xkey, path);
if (!descriptor_checksum(descriptor, strlen(descriptor), &checksum))
errx(ERROR_LIBWALLY, "Can't derive descriptor checksum for tr");
printf("%s#%s\n", descriptor, checksum.csum);
Why this scored 29/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.