lightningd: allow --recover / recover JSON RPC to take mnemonic.

In fact, you *must* use mnemonic to successfully recover a modern node!

Signed-off-by: Rusty Russell <rusty@rustcorp.com.au>
Changelog-Changed: JSON-RPC: `recover` takes a 12-word mnemonic for nodes created by v25.12 or later.
This commit is contained in:
Rusty Russell
2026-01-13 13:19:19 +10:30
parent 4f5e5aad18
commit 779a478437
9 changed files with 126 additions and 66 deletions

View File

@@ -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."
]
}
}

View File

@@ -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*

View File

@@ -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."
]
}
}

View File

@@ -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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
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
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')
# 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 + ["Leet"], stderr=subprocess.PIPE, timeout=TIMEOUT)
assert 'Invalid id: must be lower-case' in out.stderr.decode('utf-8')
assert out.returncode == 2
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()

View File

@@ -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")) {