lightningd: allow --recover / recover JSON RPC to take mnemonic.
What changed, and why it matters
This commit updates Core Lightning's recovery feature so it can accept a 12-word mnemonic phrase, which is now the required way to recover nodes created with version 25.12 or later. Older nodes can still be recovered using the previous codex32 or hex formats. The change is a feature enhancement to support a new wallet backup format, not a fix for an active security vulnerability. However, because recovery material directly controls the node's secret keys, any bugs in how mnemonics are parsed, validated, or written to disk could have serious security consequences. The patch appears careful: it rejects encrypted/password-protected mnemonics during recovery and validates input, but it is a partial view of a larger change so we cannot fully assess the surrounding key-derivation code.
Review the full implementation of `validate_mnemonic()` and `derive_seed_hash()` in related commits to ensure mnemonic parsing and seed derivation are robust. Verify that the `hsm_secret` file format (seed hash + mnemonic) is handled securely by the HSM subsystem and that the mnemonic string is zeroed from memory after use. Continue normal testing; no immediate emergency response is indicated by this commit alone.
Security signals we found
Recovery path now handles mnemonic secrets and writes seed hash + mnemonic to hsm_secret file
Encrypted and password-protected mnemonic recovery is explicitly rejected
Input validation added for mnemonic, hex, and codex32 formats
Documentation and JSON schema updated to describe new required mnemonic behavior
No explicit security bug or CVE referenced in commit message or diff
Evidence from the diff
The patch modifies lightningd/options.c so the --recover option and the recover JSON-RPC command accept a 12-word BIP39-style mnemonic in addition to the existing 32-byte hex and codex32 formats. hsm_secret_arg() now returns a struct hsm_secret that distinguishes plain secrets from mnemonics. For modern nodes, the mnemonic is validated and then written to hsm_secret as a 32-byte seed hash followed by the mnemonic string. The code explicitly refuses to recover HSM_SECRET_ENCRYPTED and HSM_SECRET_MNEMONIC_WITH_PASS cases. Tests are updated to cover both old and new recovery paths, and documentation/schema files are adjusted accordingly. No memory-safety issues are visible in the diff, but the change touches secret handling and file I/O, so correctness of derive_seed_hash() and validate_mnemonic() (not shown) matters.
Changed components
lightningd/options.clightningd/jsonrpc.clightningd/options.htools/lightning-hsmtool.cdoc/schemas/recover.jsondoc/lightningd-config.5.mdtests/test_misc.pyInspect captured patch +124 / −64
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index d3b47fe5..c850bf9e 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -30382,7 +30382,9 @@
"description": [
"The **recover** RPC command wipes your node and restarts it with the `--recover` option. This is only permitted if the node is unused: no channels, no bitcoin addresses issued (you can use `check` to see if recovery is possible).",
"",
- "*hsmsecret* is either a codex32 secret starting with \"cl1\" as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string.",
+ "For nodes created with v25.12 or later, *hsmsecret* MUST be the 12-word mnemonic.",
+ "",
+ "For earlier nodes, *hsmsecret* is either a codex32 secret starting with \"cl1\" as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string.",
"",
"NOTE: this command only currently works with the `sqlite3` database backend."
],
@@ -30395,7 +30397,7 @@
"hsmsecret": {
"type": "string",
"description": [
- "Either a codex32 secret starting with `cl1` as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string."
+ "Usually a 12-word mnemonic; but for old nodes either a codex32 secret starting with `cl1` as returned by `lightning-hsmtool getcodexsecret` or a raw 64 character hex string."
]
}
}
diff --git a/doc/lightningd-config.5.md b/doc/lightningd-config.5.md
index e63d5b32..cebd655c 100644
--- a/doc/lightningd-config.5.md
+++ b/doc/lightningd-config.5.md
@@ -336,9 +336,11 @@ connections. Default is 9736.
### Lightning node customization options
-* **recover**=*hsmsecret*
+* **recover**=*mnemonic*
- Restore the node from a 32-byte secret encoded as either a codex32 secret string or a 64-character hex string: this will fail if the `hsm_secret` file exists. Your node will start the node in offline mode, for manual recovery. The secret can be extracted from the `hsm_secret` using lightning-hsmtool(8).
+ Restore the node from a mnemonic. For pre-25.12 nodes (which didn't have a mnemonic), use a 32-byte secret encoded as either a codex32 secret string or a 64-character hex string.
+
+ This will fail if the `hsm_secret` file exists. Your node will start the node in offline mode, for manual recovery. The secret can be extracted from the `hsm_secret` using lightning-hsmtool(8)'s `getsecret`.
* **alias**=*NAME*
diff --git a/doc/schemas/recover.json b/doc/schemas/recover.json
index 4ce9303f..f0b8e29e 100644
--- a/doc/schemas/recover.json
+++ b/doc/schemas/recover.json
@@ -6,7 +6,9 @@
"description": [
"The **recover** RPC command wipes your node and restarts it with the `--recover` option. This is only permitted if the node is unused: no channels, no bitcoin addresses issued (you can use `check` to see if recovery is possible).",
"",
- "*hsmsecret* is either a codex32 secret starting with \"cl1\" as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string.",
+ "For nodes created with v25.12 or later, *hsmsecret* MUST be the 12-word mnemonic.",
+ "",
+ "For earlier nodes, *hsmsecret* is either a codex32 secret starting with \"cl1\" as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string.",
"",
"NOTE: this command only currently works with the `sqlite3` database backend."
],
@@ -19,7 +21,7 @@
"hsmsecret": {
"type": "string",
"description": [
- "Either a codex32 secret starting with `cl1` as returned by `lightning-hsmtool getcodexsecret`, or a raw 64 character hex string."
+ "Usually a 12-word mnemonic; but for old nodes either a codex32 secret starting with `cl1` as returned by `lightning-hsmtool getcodexsecret` or a raw 64 character hex string."
]
}
}
diff --git a/lightningd/jsonrpc.c b/lightningd/jsonrpc.c
index dbd7fd84..de874894 100644
--- a/lightningd/jsonrpc.c
+++ b/lightningd/jsonrpc.c
@@ -240,17 +240,18 @@ static bool have_channels(struct lightningd *ld)
return false;
}
-static struct command_result *param_codex32_or_hex(struct command *cmd,
- const char *name,
- const char *buffer,
- const jsmntok_t *tok,
- const char **hsm_secret)
+static struct command_result *param_hsm_secret(struct command *cmd,
+ const char *name,
+ const char *buffer,
+ const jsmntok_t *tok,
+ const char **hsm_secret)
{
char *err;
- const u8 *payload;
+ /* We parse here for sanity checking, but we just hand string to --recover */
+ const struct hsm_secret *hsms;
*hsm_secret = json_strdup(cmd, buffer, tok);
- err = hsm_secret_arg(tmpctx, *hsm_secret, &payload);
+ err = hsm_secret_arg(tmpctx, *hsm_secret, &hsms);
if (err)
return command_fail_badparam(cmd, name, buffer, tok, err);
return NULL;
@@ -279,10 +280,10 @@ static struct command_result *json_recover(struct command *cmd,
const jsmntok_t *obj UNNEEDED,
const jsmntok_t *params)
{
- const char *hsm_secret, *dir;
+ const char *dir, *hsm_secret;
if (!param_check(cmd, buffer, params,
- p_req("hsmsecret", param_codex32_or_hex, &hsm_secret),
+ p_req("hsmsecret", param_hsm_secret, &hsm_secret),
NULL))
return command_param_failed();
diff --git a/lightningd/options.c b/lightningd/options.c
index 02b84877..ce4c5476 100644
--- a/lightningd/options.c
+++ b/lightningd/options.c
@@ -1280,41 +1280,53 @@ static char *opt_add_api_beg(const char *arg, struct lightningd *ld)
char *hsm_secret_arg(const tal_t *ctx,
const char *arg,
- const u8 **hsm_secret)
+ const struct hsm_secret **hsm_secret)
{
char *codex32_fail;
struct codex32 *codex32;
+ struct hsm_secret *hsms = tal(tmpctx, struct hsm_secret);
/* We accept hex, or codex32. hex is very very very unlikely to
* give a valid codex32, so try that first */
codex32 = codex32_decode(tmpctx, "cl", arg, &codex32_fail);
if (codex32) {
- *hsm_secret = tal_steal(ctx, codex32->payload);
+ hsms->type = HSM_SECRET_PLAIN;
+ hsms->secret_data = tal_steal(hsms, codex32->payload);
if (codex32->threshold != 0
|| codex32->type != CODEX32_ENCODING_SECRET) {
return "This is only one share of codex32!";
}
+ if (tal_count(hsms->secret_data) != 32)
+ return "Invalid length: must be 32 bytes";
+ /* Not codex32, was it hex? */
+ } else if ((hsms->secret_data = tal_hexdata(hsms, arg, strlen(arg))) != NULL) {
+ hsms->type = HSM_SECRET_PLAIN;
+ if (tal_count(hsms->secret_data) != 32)
+ return "Invalid length: must be 32 bytes";
+ /* Not hex, is is a mnemonic? */
} else {
- /* Not codex32, was it hex? */
- *hsm_secret = tal_hexdata(ctx, arg, strlen(arg));
- if (!*hsm_secret) {
- /* It's not hex! So give codex32 error */
- return codex32_fail;
+ enum hsm_secret_error err = validate_mnemonic(arg);
+ if (err != HSM_SECRET_OK) {
+ /* If it looks kinda like a codex32, give that error. */
+ if (strstarts(arg, "cl"))
+ return codex32_fail;
+ else
+ return "Not a valid mnemonic, hex, or codex32 string";
}
+ hsms->type = HSM_SECRET_MNEMONIC_NO_PASS;
+ hsms->mnemonic = tal_strdup(hsms, arg);
}
- if (tal_count(*hsm_secret) != 32)
- return "Invalid length: must be 32 bytes";
-
+ *hsm_secret = tal_steal(ctx, hsms);
return NULL;
}
-static char *opt_set_codex32_or_hex(const char *arg, struct lightningd *ld)
+static char *opt_set_hsm_secret(const char *arg, struct lightningd *ld)
{
char *err;
- const u8 *payload;
+ const struct hsm_secret *hsm_secret;
- err = hsm_secret_arg(tmpctx, arg, &payload);
+ err = hsm_secret_arg(tmpctx, arg, &hsm_secret);
if (err)
return err;
@@ -1329,10 +1341,36 @@ static char *opt_set_codex32_or_hex(const char *arg, struct lightningd *ld)
strerror(errno));
}
- if (!write_all(fd, payload, tal_count(payload))) {
+ switch (hsm_secret->type) {
+ case HSM_SECRET_PLAIN:
+ /* Legacy 32-byte format */
+ if (!write_all(fd, hsm_secret->secret_data, tal_count(hsm_secret->secret_data))) {
+ unlink_noerr("hsm_secret");
+ return tal_fmt(tmpctx, "Writing HSM: %s",
+ strerror(errno));
+ }
+ break;
+ case HSM_SECRET_ENCRYPTED:
+ case HSM_SECRET_MNEMONIC_WITH_PASS:
+ return tal_fmt(tmpctx, "Recovery of encrypted/passworded secrets not supported");
+ case HSM_SECRET_MNEMONIC_NO_PASS: {
+ struct sha256 seed_hash;
+ if (!derive_seed_hash(hsm_secret->mnemonic, NULL, &seed_hash)) {
+ unlink_noerr("hsm_secret");
+ return tal_fmt(tmpctx, "Deriving from mnemonic failed!");
+ }
+ /* Write seed hash (32 bytes) + mnemonic */
+ if (!write_all(fd, &seed_hash, sizeof(seed_hash))
+ || !write_all(fd, hsm_secret->mnemonic, strlen(hsm_secret->mnemonic))) {
+ unlink_noerr("hsm_secret");
+ return tal_fmt(tmpctx, "Error writing to hsm_secret file: %s", strerror(errno));
+ }
+ break;
+ }
+ case HSM_SECRET_INVALID:
+ /* Shouldn't happen? */
unlink_noerr("hsm_secret");
- return tal_fmt(tmpctx, "Writing HSM: %s",
- strerror(errno));
+ return tal_fmt(tmpctx, "invalid hsm secret?");
}
/*~ fsync (mostly!) ensures that the file has reached the disk. */
@@ -1414,9 +1452,9 @@ static void register_opts(struct lightningd *ld)
&ld->wallet_dsn,
"Location of the wallet database.");
- opt_register_early_arg("--recover", opt_set_codex32_or_hex, NULL,
+ opt_register_early_arg("--recover", opt_set_hsm_secret, NULL,
ld,
- "Populate hsm_secret with the given codex32 secret"
+ "Populate hsm_secret with the given codex32/hex/mnemonic secret"
" and starts the node in `offline` mode.");
/* This affects our features, so set early. */
@@ -1856,7 +1894,7 @@ bool is_known_opt_cb_arg(char *(*cb_arg)(const char *, void *))
|| cb_arg == (void *)opt_set_db_upgrade
|| cb_arg == (void *)arg_log_to_file
|| cb_arg == (void *)opt_add_accept_htlc_tlv
- || cb_arg == (void *)opt_set_codex32_or_hex
+ || cb_arg == (void *)opt_set_hsm_secret
|| cb_arg == (void *)opt_subd_dev_disconnect
|| cb_arg == (void *)opt_set_crash_timeout
|| cb_arg == (void *)opt_add_api_beg
diff --git a/lightningd/options.h b/lightningd/options.h
index 7daa73ef..7091272d 100644
--- a/lightningd/options.h
+++ b/lightningd/options.h
@@ -3,6 +3,7 @@
#include "config.h"
#include <ccan/ccan/opt/opt.h>
+struct hsm_secret;
struct json_stream;
struct lightningd;
@@ -16,7 +17,7 @@ void handle_opts(struct lightningd *ld);
void setup_color_and_alias(struct lightningd *ld);
/**
- * hsm_secret_arg - parse an hsm_secret as hex or codex32
+ * hsm_secret_arg - parse an hsm_secret as hex, codex32 or mnemonic.
* @ctx: context to allocate @hsm_secret from
* @arg: string to parse
* @hsm_secret: set on success.
@@ -25,7 +26,7 @@ void setup_color_and_alias(struct lightningd *ld);
*/
char *hsm_secret_arg(const tal_t *ctx,
const char *arg,
- const u8 **hsm_secret);
+ const struct hsm_secret **hsm_secret);
enum opt_autobool {
OPT_AUTOBOOL_FALSE = 0,
diff --git a/lightningd/test/run-jsonrpc.c b/lightningd/test/run-jsonrpc.c
index c28ca212..611cc048 100644
--- a/lightningd/test/run-jsonrpc.c
+++ b/lightningd/test/run-jsonrpc.c
@@ -32,7 +32,7 @@ u32 get_feerate_floor(const struct chain_topology *topo UNNEEDED)
/* Generated stub for hsm_secret_arg */
char *hsm_secret_arg(const tal_t *ctx UNNEEDED,
const char *arg UNNEEDED,
- const u8 **hsm_secret UNNEEDED)
+ const struct hsm_secret **hsm_secret UNNEEDED)
{ fprintf(stderr, "hsm_secret_arg called!\n"); abort(); }
/* Generated stub for lightningd_deprecated_in_ok */
bool lightningd_deprecated_in_ok(struct lightningd *ld UNNEEDED,
diff --git a/tests/test_misc.py b/tests/test_misc.py
index c95995d8..18c7384b 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1523,50 +1523,61 @@ def test_decode(node_factory, bitcoind):
@unittest.skipIf(os.getenv('TEST_DB_PROVIDER', 'sqlite3') != 'sqlite3', "deletes database, which is assumed sqlite3")
-def test_recover(node_factory, bitcoind):
+@pytest.mark.parametrize("old_hsmsecret", [False, True])
+def test_recover(node_factory, bitcoind, old_hsmsecret):
"""Test the recover option
"""
- # Start the node with --recovery with valid codex32 secret
+ if old_hsmsecret:
+ recoverarg = "cl10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqjdsjnzedu43ns"
+ hsmsecret = bytes.fromhex("ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100")
+ bad_recoverarg = "CL10LEETSLLHDMN9M42VCSAMX24ZRXGS3QQAT3LTDVAKMT73"
+ else:
+ recoverarg = "hockey enroll sure trip track rescue original plate abandon abandon abandon account"
+ hsmsecret = bytes.fromhex("0000000000000000000000000000000000000000000000000000000000000000") + recoverarg.encode('utf-8')
+ bad_recoverarg = "hockey enroll sure trip track rescue original plate abandon abandon abandon abandon"
+
+ # Start the node with --recovery with valid secret
l1 = node_factory.get_node(start=False,
- options={"recover": "cl10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqjdsjnzedu43ns"})
+ options={"recover": recoverarg}, old_hsmsecret=old_hsmsecret)
os.unlink(os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret"))
l1.daemon.start()
- cmd_line = ["tools/lightning-hsmtool", "getcodexsecret", os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")]
- out = subprocess.check_output(cmd_line + ["leet", "0"]).decode('utf-8')
- assert out == "cl10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqjdsjnzedu43ns\n"
+ cmd_line = ["tools/lightning-hsmtool", "getsecret", os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")]
+ out = subprocess.check_output(cmd_line + ["leet"]).decode('utf-8')
+ assert out == recoverarg + "\n"
- # Check bad ids.
- out = subprocess.run(cmd_line + ["lee", "0"], stderr=subprocess.PIPE, timeout=TIMEOUT)
- assert 'Invalid id: must be 4 characters' in out.stderr.decode('utf-8')
- assert out.returncode == 2
-
- out = subprocess.run(cmd_line + ["Leet", "0"], stderr=subprocess.PIPE, timeout=TIMEOUT)
- assert 'Invalid id: must be lower-case' in out.stderr.decode('utf-8')
- assert out.returncode == 2
+ # Check bad ids (we ignore id for modern hsm_secret)
+ if old_hsmsecret:
+ out = subprocess.run(cmd_line + ["lee"], stderr=subprocess.PIPE, timeout=TIMEOUT)
+ assert 'Invalid id: must be 4 characters' in out.stderr.decode('utf-8')
+ assert out.returncode == 2
- out = subprocess.run(cmd_line + ["💔", "0"], stderr=subprocess.PIPE, timeout=TIMEOUT)
- assert 'Invalid id: must be ASCII' in out.stderr.decode('utf-8')
- assert out.returncode == 2
+ out = subprocess.run(cmd_line + ["Leet"], stderr=subprocess.PIPE, timeout=TIMEOUT)
+ assert 'Invalid id: must be lower-case' in out.stderr.decode('utf-8')
+ assert out.returncode == 2
- for bad_bech32 in ['b', 'o', 'i', '1']:
- out = subprocess.run(cmd_line + [bad_bech32 + "eet", "0"], stderr=subprocess.PIPE, timeout=TIMEOUT)
- assert 'Invalid id: must be valid bech32 string' in out.stderr.decode('utf-8')
+ out = subprocess.run(cmd_line + ["💔"], stderr=subprocess.PIPE, timeout=TIMEOUT)
+ assert 'Invalid id: must be ASCII' in out.stderr.decode('utf-8')
assert out.returncode == 2
+ for bad_bech32 in ['b', 'o', 'i', '1']:
+ out = subprocess.run(cmd_line + [bad_bech32 + "eet"], stderr=subprocess.PIPE, timeout=TIMEOUT)
+ assert 'Invalid id: must be valid bech32 string' in out.stderr.decode('utf-8')
+ assert out.returncode == 2
+
basedir = l1.daemon.opts.get("lightning-dir")
with open(os.path.join(basedir, TEST_NETWORK, 'hsm_secret'), 'rb') as f:
buff = f.read()
# Check the node secret
- assert buff.hex() == "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100"
+ assert buff == hsmsecret
l1.stop()
os.unlink(os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "lightningd.sqlite3"))
# Node should throw error to recover flag if HSM already exists.
- l1.daemon.opts['recover'] = "cl10leetsllhdmn9m42vcsamx24zrxgs3qrl7ahwvhw4fnzrhve25gvezzyqqjdsjnzedu43ns"
+ l1.daemon.opts['recover'] = recoverarg
l1.daemon.start(wait_for_initialized=False, stderr_redir=True)
# Will exit with failure code.
@@ -1575,12 +1586,15 @@ def test_recover(node_factory, bitcoind):
os.unlink(os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret"))
- l1.daemon.opts.update({"recover": "CL10LEETSLLHDMN9M42VCSAMX24ZRXGS3QQAT3LTDVAKMT73"})
+ l1.daemon.opts.update({"recover": bad_recoverarg})
l1.daemon.start(wait_for_initialized=False, stderr_redir=True)
assert l1.daemon.wait() == 1
- assert l1.daemon.is_in_stderr(r"Invalid length: must be 32 bytes")
+ if old_hsmsecret:
+ assert l1.daemon.is_in_stderr(r"Invalid length: must be 32 bytes")
+ else:
+ assert l1.daemon.is_in_stderr(r"Not a valid mnemonic, hex, or codex32 string")
- # Can do HSM secret in hex, too!
+ # Old-style can do HSM secret in hex, too!
l1.daemon.opts["recover"] = "6c696768746e696e672d31000000000000000000000000000000000000000000"
l1.daemon.start()
l1.stop()
diff --git a/tools/lightning-hsmtool.c b/tools/lightning-hsmtool.c
index ca5bf542..6526f112 100644
--- a/tools/lightning-hsmtool.c
+++ b/tools/lightning-hsmtool.c
@@ -766,11 +766,11 @@ int main(int argc, char *argv[])
show_usage(argv[0]);
make_rune(argv[2]);
} else if(streq(method, "getsecret")) {
- if (argc < 3)
+ if (argc < 3 || argc > 4)
show_usage(argv[0]);
print_secret(argv[2], argv[3], false);
} else if(streq(method, "getcodexsecret")) {
- if (argc < 4)
+ if (argc != 4)
show_usage(argv[0]);
print_secret(argv[2], argv[3], true);
} else if(streq(method, "getemergencyrecover")) {
Why this scored 28/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.