hsmd: use the new mnemonic-compatible hsm_secret routines.
What changed, and why it matters
This commit changes how Core Lightning's Hardware Security Module daemon (hsmd) creates and stores the master secret for new nodes. Instead of a raw 32-byte random secret (optionally encrypted with a password-derived key), new nodes now get a 12-word BIP-39 mnemonic phrase as their root secret, with an optional passphrase. The old --encrypted-hsm option is deprecated in favor of --hsm-passphrase. The change is a feature redesign of key storage, not a fix for a known exploitable bug, but it touches critical cryptographic material and removes legacy encrypted-secret handling.
Review the new common/hsm_secret.h implementation (not shown in the diff) for correct BIP-39 entropy generation, passphrase handling, memory zeroization, and error handling. Verify that legacy encrypted hsm_secret files are still supported for existing users or that migration guidance is documented. Audit the new hsmd_init_reply_failure path for memory safety and ensure the old --encrypted-hsm deprecation window is clearly communicated.
Security signals we found
Change to root secret storage format and derivation
Deprecation of encrypted-hsm in favor of hsm-passphrase
Removal of legacy encrypted hsm_secret upgrade path
Addition of hsmd_init_reply_failure error reporting
Use of BIP-39 mnemonic + optional passphrase for new nodes
Memory locking of hsm_secret.secret.data with sodium_mlock retained
Evidence from the diff
The patch replaces the legacy encrypted/raw hsm_secret format with a mnemonic-compatible one. New hsm_secret files are written as a 32-byte seed hash followed by the BIP-39 mnemonic. The HSM derives the actual 32-byte secret via bip39_mnemonic_to_seed using an optional passphrase. The –encrypted-hsm config option is deprecated and superseded by –hsm-passphrase. A new wire message, hsmd_init_reply_failure, is added so hsmd can report initialization errors (e.g., bad passphrase) to lightningd. The old is_hsm_secret_encrypted check and encrypted-secret upgrade path are removed. Tests are updated to exercise mnemonic creation and deterministic node IDs.
Changed components
hsmd/hsmd.chsmd/libhsmd.chsmd/hsmd_wire.csvlightningd/hsm_control.clightningd/options.clightningd/lightningd.ccommon/hsm_version.htests/test_wallet.pyInspect captured patch +317 / −218
diff --git a/common/hsm_version.h b/common/hsm_version.h
index 920dc77..d6a9000 100644
--- a/common/hsm_version.h
+++ b/common/hsm_version.h
@@ -29,6 +29,7 @@
* v6 with sign_bolt12_2 (tweak using node id): 8fcb731279a10af3f95aeb8be1da6b2ced76a1984afa18c5f46a03515d70ea0e
* v6 with dev_warn_on_overgrind: a273b68e19336073e551c01a78bcd1e1f8cc510da7d0dde3afc45e249f9830cc
* v6 with bip137_sign_message: 4bfe28b02e92aae276b8eca2228e32f32d5dee8d5381639e7364939fa2fa1370
+ * v6 with hsm_passphrase changes: c646d557d7561dd885df3cad5b99c82895cda4b040699f3853980ec61b2873fa
*/
#define HSM_MIN_VERSION 5
#define HSM_MAX_VERSION 6
diff --git a/doc/developers-guide/deprecated-features.md b/doc/developers-guide/deprecated-features.md
index 81455d2..0867387 100644
--- a/doc/developers-guide/deprecated-features.md
+++ b/doc/developers-guide/deprecated-features.md
@@ -21,6 +21,7 @@ hidden: false
| channel_state_changed.null_scid | Notification Field | v25.09 | v26.09 | In channel_state_changed notification, `short_channel_id` will be missing instead of `null` |
| notification.payload | Notification Field | v25.09 | v26.09 | Notifications from plugins used to have fields in `payload` sub-object, now they are not (just like normal notifications) |
| pay_notifications.raw_fields | Field | v25.09 | v26.09 | `channel_hint_update`, `pay_failure` and `pay_success` notifications now wrap members in an object of the same name |
+| encrypted_hsm | Config | v25.12 | v26.12 | `hsm-passphrase` is a name which also makes sense for modern hsm_secrets which use BIP 39 |
Inevitably there are features which need to change: either to be generalized, or removed when they can no longer be supported.
diff --git a/hsmd/hsmd.c b/hsmd/hsmd.c
index 6c47c94..c64a7e9 100644
--- a/hsmd/hsmd.c
+++ b/hsmd/hsmd.c
@@ -12,9 +12,10 @@
#include <ccan/io/fdpass/fdpass.h>
#include <ccan/noerr/noerr.h>
#include <ccan/read_write_all/read_write_all.h>
+#include <ccan/tal/grab_file/grab_file.h>
#include <ccan/tal/str/str.h>
#include <common/daemon_conn.h>
-#include <common/hsm_encryption.h>
+#include <common/hsm_secret.h>
#include <common/memleak.h>
#include <common/status.h>
#include <common/status_wiregen.h>
@@ -25,7 +26,9 @@
/*~ _wiregen files are autogenerated by tools/generate-wire.py */
#include <hsmd/libhsmd.h>
#include <hsmd/permissions.h>
+#include <stdarg.h>
#include <sys/stat.h>
+#include <wally_bip39.h>
#include <wire/wire_io.h>
/*~ Each subdaemon is started with stdin connected to lightningd (for status
@@ -35,7 +38,7 @@
#define REQ_FD 3
/* Temporary storage for the secret until we pass it to `hsmd_init` */
-struct secret hsm_secret;
+struct hsm_secret hsm_secret;
/*~ We keep track of clients, but there's not much to keep. */
struct client {
@@ -270,33 +273,93 @@ static struct io_plan *req_reply(struct io_conn *conn,
return io_write_wire(conn, msg_out, client_read_next, c);
}
-/*~ This encrypts the content of the `struct secret hsm_secret` and
- * stores it in hsm_secret, this is called instead of create_hsm() if
- * `lightningd` is started with --encrypted-hsm.
- */
-static void create_encrypted_hsm(int fd, const struct secret *encryption_key)
+/* Send an init reply failure message to lightningd and then call status_failed */
+static void hsmd_send_init_reply_failure(enum hsm_secret_error error_code, enum status_failreason reason, const char *error_msg, ...)
{
- struct encrypted_hsm_secret cipher;
+ u8 *msg;
+ va_list ap;
+ char *formatted_msg;
- if (!encrypt_hsm_secret(encryption_key, &hsm_secret,
- &cipher))
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "Encrypting hsm_secret");
- if (!write_all(fd, cipher.data, ENCRYPTED_HSM_SECRET_LEN)) {
- unlink_noerr("hsm_secret");
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "Writing encrypted hsm_secret: %s", strerror(errno));
+ va_start(ap, error_msg);
+ formatted_msg = tal_vfmt(tmpctx, error_msg, ap);
+ va_end(ap);
+
+ /* Send the init reply failure first */
+ msg = towire_hsmd_init_reply_failure(NULL, error_code, formatted_msg);
+ if (msg) {
+ /* Send directly to lightningd via REQ_FD */
+ write_all(REQ_FD, msg, tal_bytelen(msg));
+ tal_free(msg);
}
+
+ /* Then call status_failed with the error message */
+ status_failed(reason, "%s", formatted_msg);
}
-static void create_hsm(int fd)
+static void create_hsm(int fd, const char *passphrase)
{
- /*~ ccan/read_write_all has a more convenient return than write() where
- * we'd have to check the return value == the length we gave: write()
- * can return short on normal files if we run out of disk space. */
- if (!write_all(fd, &hsm_secret, sizeof(hsm_secret))) {
- /* ccan/noerr contains useful routines like this, which don't
- * clobber errno, so we can use it in our error report. */
+ u8 *hsm_secret_data;
+ size_t hsm_secret_len;
+ int ret;
+ /* Always create a mnemonic-based hsm_secret */
+ u8 entropy[BIP39_ENTROPY_LEN_128];
+ char *mnemonic = NULL;
+ struct sha256 seed_hash;
+
+ /* Initialize wally tal context for libwally operations */
+
+ /* Generate random entropy for new mnemonic */
+ randombytes_buf(entropy, sizeof(entropy));
+
+
+ /* Generate mnemonic from entropy */
+ tal_wally_start();
+ ret = bip39_mnemonic_from_bytes(NULL, entropy, sizeof(entropy), &mnemonic);
+ tal_wally_end(tmpctx);
+
+ if (ret != WALLY_OK) {
+ unlink_noerr("hsm_secret");
+ hsmd_send_init_reply_failure(HSM_SECRET_ERR_SEED_DERIVATION_FAILED, STATUS_FAIL_INTERNAL_ERROR,
+ "Failed to generate mnemonic from entropy");
+ }
+
+ if (!mnemonic) {
+ unlink_noerr("hsm_secret");
+ hsmd_send_init_reply_failure(HSM_SECRET_ERR_SEED_DERIVATION_FAILED, STATUS_FAIL_INTERNAL_ERROR,
+ "Failed to get generated mnemonic");
+ }
+
+ /* Derive seed hash from mnemonic + passphrase (or zero if no passphrase) */
+ if (!derive_seed_hash(mnemonic, passphrase, &seed_hash)) {
+ unlink_noerr("hsm_secret");
+ hsmd_send_init_reply_failure(HSM_SECRET_ERR_SEED_DERIVATION_FAILED, STATUS_FAIL_INTERNAL_ERROR,
+ "Failed to derive seed hash from mnemonic");
+ }
+
+ /* Create hsm_secret format: seed_hash (32 bytes) + mnemonic */
+ hsm_secret_data = tal_arr(tmpctx, u8, 0);
+ towire_sha256(&hsm_secret_data, &seed_hash);
+ towire(&hsm_secret_data, mnemonic, strlen(mnemonic));
+ hsm_secret_len = tal_count(hsm_secret_data);
+
+ /* Derive the actual secret from mnemonic + passphrase for our global hsm_secret */
+ u8 bip32_seed[BIP39_SEED_LEN_512];
+ size_t bip32_seed_len;
+
+ tal_wally_start();
+ ret = bip39_mnemonic_to_seed(mnemonic, passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len);
+ tal_wally_end(tmpctx);
+ if (ret != WALLY_OK) {
+ unlink_noerr("hsm_secret");
+ hsmd_send_init_reply_failure(HSM_SECRET_ERR_SEED_DERIVATION_FAILED, STATUS_FAIL_INTERNAL_ERROR,
+ "Failed to derive seed from mnemonic");
+ }
+
+ /* Use first 32 bytes for hsm_secret */
+ memcpy(&hsm_secret.secret, bip32_seed, sizeof(hsm_secret.secret));
+
+ /* Write the hsm_secret data to file */
+ if (!write_all(fd, hsm_secret_data, hsm_secret_len)) {
unlink_noerr("hsm_secret");
status_failed(STATUS_FAIL_INTERNAL_ERROR,
"writing: %s", strerror(errno));
@@ -304,9 +367,12 @@ static void create_hsm(int fd)
}
/*~ We store our root secret in a "hsm_secret" file (like all of Core Lightning,
- * we run in the user's .lightning directory). */
-static void maybe_create_new_hsm(const struct secret *encryption_key,
- bool random_hsm)
+ * we run in the user's .lightning directory).
+ *
+ * NOTE: This function no longer creates encrypted 32-byte secrets. New hsm_secret
+ * files will use mnemonic format with passphrases.
+ */
+static void maybe_create_new_hsm(const char *passphrase)
{
/*~ Note that this is opened for write-only, even though the permissions
* are set to read-only. That's perfectly valid! */
@@ -319,17 +385,10 @@ static void maybe_create_new_hsm(const struct secret *encryption_key,
"creating: %s", strerror(errno));
}
- /*~ This is libsodium's cryptographic randomness routine: we assume
- * it's doing a good job. */
- if (random_hsm)
- randombytes_buf(&hsm_secret, sizeof(hsm_secret));
-
- /*~ If an encryption_key was provided, store an encrypted seed. */
- if (encryption_key)
- create_encrypted_hsm(fd, encryption_key);
- /*~ Otherwise store the seed in clear.. */
- else
- create_hsm(fd);
+
+ /*~ Store the seed in clear. New hsm_secret files will use mnemonic format
+ * with passphrases, not encrypted 32-byte secrets. */
+ create_hsm(fd, passphrase);
/*~ fsync (mostly!) ensures that the file has reached the disk. */
if (fsync(fd) != 0) {
unlink_noerr("hsm_secret");
@@ -367,62 +426,35 @@ static void maybe_create_new_hsm(const struct secret *encryption_key,
/*~ We always load the HSM file, even if we just created it above. This
* both unifies the code paths, and provides a nice sanity check that the
* file contents are as they will be for future invocations. */
-static void load_hsm(const struct secret *encryption_key)
+static void load_hsm(const char *passphrase)
{
- struct stat st;
- int fd = open("hsm_secret", O_RDONLY);
- if (fd < 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "opening: %s", strerror(errno));
- if (stat("hsm_secret", &st) != 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "stating: %s", strerror(errno));
-
- /* If the seed is stored in clear. */
- if (st.st_size == 32) {
- if (!read_all(fd, &hsm_secret, sizeof(hsm_secret)))
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "reading: %s", strerror(errno));
- /* If an encryption key was passed with a not yet encrypted hsm_secret,
- * remove the old one and create an encrypted one. */
- if (encryption_key) {
- if (close(fd) != 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "closing: %s", strerror(errno));
- if (remove("hsm_secret") != 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "removing clear hsm_secret: %s", strerror(errno));
- maybe_create_new_hsm(encryption_key, false);
- fd = open("hsm_secret", O_RDONLY);
- if (fd < 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "opening: %s", strerror(errno));
- }
+ u8 *hsm_secret_contents;
+ struct hsm_secret *hsms;
+ enum hsm_secret_error err;
+
+ /* Read the hsm_secret file */
+ size_t hsm_secret_len;
+ hsm_secret_contents = grab_file_contents(tmpctx, "hsm_secret", &hsm_secret_len);
+ if (!hsm_secret_contents) {
+ hsmd_send_init_reply_failure(HSM_SECRET_ERR_INVALID_FORMAT, STATUS_FAIL_INTERNAL_ERROR,
+ "Could not read hsm_secret: %s", strerror(errno));
}
- /* If an encryption key was passed and the `hsm_secret` is stored
- * encrypted, recover the seed from the cipher. */
- else if (st.st_size == ENCRYPTED_HSM_SECRET_LEN) {
- struct encrypted_hsm_secret encrypted_secret;
-
- /* hsm_control must have checked it! */
- assert(encryption_key);
- if (!read_all(fd, encrypted_secret.data, ENCRYPTED_HSM_SECRET_LEN))
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "Reading encrypted hsm_secret: %s", strerror(errno));
- if (!decrypt_hsm_secret(encryption_key, &encrypted_secret,
- &hsm_secret)) {
- /* Exit but don't throw a backtrace when the user made a mistake in typing
- * its password. Instead exit and `lightningd` will be able to give
- * an error message. */
- exit(1);
- }
+ /* Extract the secret using the new hsm_secret module */
+ tal_wally_start();
+ hsms = extract_hsm_secret(tmpctx, hsm_secret_contents,
+ hsm_secret_len,
+ passphrase, &err);
+ tal_wally_end(tmpctx);
+ if (!hsms) {
+ hsmd_send_init_reply_failure(err, STATUS_FAIL_INTERNAL_ERROR,
+ "Failed to load hsm_secret: %s", hsm_secret_error_str(err));
}
- else
- status_failed(STATUS_FAIL_INTERNAL_ERROR, "Invalid hsm_secret, "
- "no plaintext nor encrypted"
- " seed.");
- close(fd);
+
+ /* Copy only the secret field to our global hsm_secret */
+ hsm_secret.secret = hsms->secret;
+ hsm_secret.type = hsms->type;
+ hsm_secret.mnemonic = hsms->mnemonic;
}
/*~ We have a pre-init call in developer mode, to set dev flags */
@@ -458,6 +490,7 @@ static struct io_plan *init_hsm(struct io_conn *conn,
const u8 *msg_in)
{
struct secret *hsm_encryption_key;
+ const char *hsm_passphrase = NULL;
struct bip32_key_version bip32_key_version;
u32 minversion, maxversion;
const u32 our_minversion = 4, our_maxversion = 6;
@@ -494,27 +527,13 @@ static struct io_plan *init_hsm(struct io_conn *conn,
* think this is some kind of obscure CLN hazing ritual? Anyway, the
* passphrase needs to be *appended* to the mnemonic, so the HSM needs
* the raw passphrase. To avoid a compatibility break, I put it inside
- * the TLV, and left the old "hsm_encryption_key" field in place, even
- * though we override it here for the old-style non-BIP39 hsm_secret. */
- if (tlvs->hsm_passphrase) {
- const char *hsm_passphrase = (const char *)tlvs->hsm_passphrase;
- const char *err_msg;
-
- hsm_encryption_key = tal(NULL, struct secret);
- if (hsm_secret_encryption_key_with_exitcode(hsm_passphrase, hsm_encryption_key, &err_msg) != 0)
- return bad_req_fmt(conn, c, msg_in,
- "Bad passphrase: %s", err_msg);
- }
- tal_free(tlvs);
+ * the TLV, and left the old "hsm_encryption_key" field in place (and lightningd
+ * never sets that anymore), and we use the TLV instead. */
+ if (tlvs->hsm_passphrase)
+ hsm_passphrase = tlvs->hsm_passphrase;
- /*~ The memory is actually copied in towire(), so lock the `hsm_secret`
- * encryption key (new) memory again here. */
- if (hsm_encryption_key && sodium_mlock(hsm_encryption_key,
- sizeof(hsm_encryption_key)) != 0)
- status_failed(STATUS_FAIL_INTERNAL_ERROR,
- "Could not lock memory for hsm_secret encryption key.");
/*~ Don't swap this. */
- sodium_mlock(hsm_secret.data, sizeof(hsm_secret.data));
+ sodium_mlock(hsm_secret.secret.data, sizeof(hsm_secret.secret.data));
if (!developer) {
assert(!dev_force_privkey);
@@ -526,16 +545,15 @@ static struct io_plan *init_hsm(struct io_conn *conn,
/* Once we have read the init message we know which params the master
* will use */
c->chainparams = chainparams;
- maybe_create_new_hsm(hsm_encryption_key, true);
- load_hsm(hsm_encryption_key);
-
- /*~ We don't need the hsm_secret encryption key anymore. */
- if (hsm_encryption_key)
- discard_key(take(hsm_encryption_key));
+ maybe_create_new_hsm(hsm_passphrase);
+ load_hsm(hsm_passphrase);
/* Define the minimum common max version for the hsmd one */
hsmd_mutual_version = maxversion < our_maxversion ? maxversion : our_maxversion;
- return req_reply(conn, c, hsmd_init(hsm_secret, hsmd_mutual_version,
+
+ /* This was tallocated off NULL, and memleak complains if we don't free it */
+ tal_free(tlvs);
+ return req_reply(conn, c, hsmd_init(hsm_secret.secret, hsmd_mutual_version,
bip32_key_version));
}
@@ -756,6 +774,7 @@ static struct io_plan *handle_client(struct io_conn *conn, struct client *c)
case WIRE_HSMD_SIGN_WITHDRAWAL_REPLY:
case WIRE_HSMD_SIGN_INVOICE_REPLY:
case WIRE_HSMD_INIT_REPLY_V4:
+ case WIRE_HSMD_INIT_REPLY_FAILURE:
case WIRE_HSMD_DERIVE_SECRET_REPLY:
case WIRE_HSMSTATUS_CLIENT_BAD_REQUEST:
case WIRE_HSMD_SIGN_COMMITMENT_TX_REPLY:
diff --git a/hsmd/hsmd_wire.csv b/hsmd/hsmd_wire.csv
index 4746ac5..cfca1e6 100644
--- a/hsmd/hsmd_wire.csv
+++ b/hsmd/hsmd_wire.csv
@@ -47,6 +47,11 @@ msgdata,hsmd_init_reply_v4,node_id,node_id,
msgdata,hsmd_init_reply_v4,bip32,ext_key,
msgdata,hsmd_init_reply_v4,bolt12,pubkey,
+# HSM initialization failure response
+msgtype,hsmd_init_reply_failure,115
+msgdata,hsmd_init_reply_failure,error_code,u32,
+msgdata,hsmd_init_reply_failure,error_message,wirestring,
+
# Declare a new channel.
msgtype,hsmd_new_channel,30
msgdata,hsmd_new_channel,id,node_id,
diff --git a/hsmd/libhsmd.c b/hsmd/libhsmd.c
index f459f03..9fde4d1 100644
--- a/hsmd/libhsmd.c
+++ b/hsmd/libhsmd.c
@@ -172,6 +172,7 @@ bool hsmd_check_client_capabilities(struct hsmd_client *client,
case WIRE_HSMD_SIGN_WITHDRAWAL_REPLY:
case WIRE_HSMD_SIGN_INVOICE_REPLY:
case WIRE_HSMD_INIT_REPLY_V4:
+ case WIRE_HSMD_INIT_REPLY_FAILURE:
case WIRE_HSMSTATUS_CLIENT_BAD_REQUEST:
case WIRE_HSMD_SIGN_COMMITMENT_TX_REPLY:
case WIRE_HSMD_VALIDATE_COMMITMENT_TX_REPLY:
@@ -2303,6 +2304,7 @@ u8 *hsmd_handle_client_message(const tal_t *ctx, struct hsmd_client *client,
case WIRE_HSMD_SIGN_WITHDRAWAL_REPLY:
case WIRE_HSMD_SIGN_INVOICE_REPLY:
case WIRE_HSMD_INIT_REPLY_V4:
+ case WIRE_HSMD_INIT_REPLY_FAILURE:
case WIRE_HSMSTATUS_CLIENT_BAD_REQUEST:
case WIRE_HSMD_SIGN_COMMITMENT_TX_REPLY:
case WIRE_HSMD_VALIDATE_COMMITMENT_TX_REPLY:
diff --git a/lightningd/hsm_control.c b/lightningd/hsm_control.c
index 61eedee..45cea82 100644
--- a/lightningd/hsm_control.c
+++ b/lightningd/hsm_control.c
@@ -1,10 +1,11 @@
#include "config.h"
#include <ccan/err/err.h>
#include <ccan/fdpass/fdpass.h>
+#include <ccan/tal/str/str.h>
#include <common/bolt12_id.h>
#include <common/errcode.h>
#include <common/hsm_capable.h>
-#include <common/hsm_encryption.h>
+#include <common/hsm_secret.h>
#include <common/hsm_version.h>
#include <common/json_command.h>
#include <errno.h>
@@ -95,15 +96,6 @@ struct ext_key *hsm_init(struct lightningd *ld)
if (!ld->hsm)
err(EXITCODE_HSM_GENERIC_ERROR, "Could not subd hsm");
- /* If hsm_secret is encrypted and the --encrypted-hsm startup option is
- * not passed, don't let hsmd use the first 32 bytes of the cypher as the
- * actual secret. */
- if (!ld->hsm_passphrase) {
- if (is_hsm_secret_encrypted("hsm_secret") == 1)
- errx(EXITCODE_HSM_ERROR_IS_ENCRYPT, "hsm_secret is encrypted, you need to pass the "
- "--encrypted-hsm startup option.");
- }
-
ld->hsm_fd = fds[0];
if (ld->developer) {
@@ -126,7 +118,7 @@ struct ext_key *hsm_init(struct lightningd *ld)
struct tlv_hsmd_init_tlvs *tlv = NULL;
if (ld->hsm_passphrase) {
tlv = tlv_hsmd_init_tlvs_new(tmpctx);
- tlv->hsm_passphrase = ld->hsm_passphrase;
+ tlv->hsm_passphrase = tal_strdup(tlv, ld->hsm_passphrase);
}
if (!wire_sync_write(ld->hsm_fd, towire_hsmd_init(tmpctx,
@@ -144,6 +136,16 @@ struct ext_key *hsm_init(struct lightningd *ld)
bip32_base = tal(ld, struct ext_key);
msg = wire_sync_read(tmpctx, ld->hsm_fd);
+
+ /* Check for init reply failure first */
+ u32 error_code;
+ char *error_message;
+ if (fromwire_hsmd_init_reply_failure(tmpctx, msg, &error_code, &error_message)) {
+ /* HSM initialization failed: tell user the error (particularly to give feedback if it's a bad passphrase! */
+ errx(error_code, "HSM initialization failed: %s", error_message);
+ }
+
+ /* Check for successful init reply */
if (fromwire_hsmd_init_reply_v4(ld, msg,
&hsm_version,
&ld->hsm_capabilities,
@@ -151,9 +153,8 @@ struct ext_key *hsm_init(struct lightningd *ld)
&unused)) {
/* nothing to do. */
} else {
- if (ld->hsm_passphrase)
- errx(EXITCODE_HSM_BAD_PASSWORD, "Wrong password for encrypted hsm_secret.");
- errx(EXITCODE_HSM_GENERIC_ERROR, "HSM did not give init reply");
+ /* Unknown message type */
+ errx(EXITCODE_HSM_GENERIC_ERROR, "HSM sent unknown message type");
}
if (!pubkey_from_node_id(&ld->our_pubkey, &ld->our_nodeid))
diff --git a/lightningd/lightningd.c b/lightningd/lightningd.c
index 5de7a12..f512822 100644
--- a/lightningd/lightningd.c
+++ b/lightningd/lightningd.c
@@ -51,7 +51,7 @@
#include <common/deprecation.h>
#include <common/ecdh_hsmd.h>
#include <common/errcode.h>
-#include <common/hsm_encryption.h>
+#include <common/hsm_secret.h>
#include <common/memleak.h>
#include <common/timeout.h>
#include <common/trace.h>
@@ -231,6 +231,7 @@ static struct lightningd *new_lightningd(const tal_t *ctx)
ld->alias = NULL;
ld->rgb = NULL;
ld->recover = NULL;
+ ld->hsm_passphrase = NULL;
list_head_init(&ld->connects);
list_head_init(&ld->waitsendpay_commands);
list_head_init(&ld->close_commands);
diff --git a/lightningd/lightningd.h b/lightningd/lightningd.h
index b87c973..2f7343e 100644
--- a/lightningd/lightningd.h
+++ b/lightningd/lightningd.h
@@ -69,7 +69,6 @@ struct config {
/* Minimal amount of effective funding_satoshis for accepting channels */
u64 min_capacity_sat;
-
/* How long before we give up waiting for INIT msg */
u32 connection_timeout_secs;
diff --git a/lightningd/options.c b/lightningd/options.c
index f2364de..071468b 100644
--- a/lightningd/options.c
+++ b/lightningd/options.c
@@ -12,7 +12,7 @@
#include <common/configdir.h>
#include <common/configvar.h>
#include <common/errcode.h>
-#include <common/hsm_encryption.h>
+#include <common/hsm_secret.h>
#include <common/version.h>
#include <dirent.h>
#include <errno.h>
@@ -552,19 +552,65 @@ static void prompt(struct lightningd *ld, const char *str)
fflush(stdout);
}
+/* Read HSM passphrase from user input */
+static char *read_hsm_passphrase(struct lightningd *ld)
+{
+ const char *passphrase, *passphrase_confirmation;
+ enum hsm_secret_error err;
+
+ prompt(ld, "The hsm_secret requires a passphrase. In order to "
+ "access it and start the node you must provide the passphrase.");
+ prompt(ld, "Enter hsm_secret passphrase:");
+
+ passphrase = read_stdin_pass(tmpctx, &err);
+ if (err != HSM_SECRET_OK) {
+ opt_exitcode = EXITCODE_HSM_PASSWORD_INPUT_ERR;
+ return tal_strdup(tmpctx, hsm_secret_error_str(err));
+ }
+
+ /* We need confirmation if the hsm_secret file doesn't exist yet */
+ if (!path_is_file("hsm_secret")) {
+ prompt(ld, "Confirm hsm_secret passphrase:");
+ fflush(stdout);
+ passphrase_confirmation = read_stdin_pass(tmpctx, &err);
+ if (err != HSM_SECRET_OK) {
+ opt_exitcode = EXITCODE_HSM_PASSWORD_INPUT_ERR;
+ return tal_strdup(tmpctx, hsm_secret_error_str(err));
+ }
+
+ if (!streq(passphrase, passphrase_confirmation)) {
+ opt_exitcode = EXITCODE_HSM_BAD_PASSWORD;
+ return "Passphrase confirmation mismatch.";
+ }
+ }
+
+ /* Store passphrase in lightningd struct */
+ ld->hsm_passphrase = tal_strdup(ld, passphrase);
+
+ /* Encryption key derivation is handled by hsmd internally */
+
+ return NULL;
+}
+
/* Prompt the user to enter a password, from which will be derived the key used
* for `hsm_secret` encryption.
* The algorithm used to derive the key is Argon2(id), to which libsodium
* defaults. However argon2id-specific constants are used in case someone runs it
* with a libsodium version which default constants differs (typically <1.0.9).
+ *
+ * DEPRECATED: Use --hsm-passphrase instead.
*/
static char *opt_set_hsm_password(struct lightningd *ld)
{
- char *passwd, *passwd_confirmation;
- const char *err_msg;
int is_encrypted;
- is_encrypted = is_hsm_secret_encrypted("hsm_secret");
+ /* Show deprecation warning */
+ if (!opt_deprecated_ok(ld, "encrypted_hsm",
+ "Use --hsm-passphrase instead",
+ "v25.12", "v26.12"))
+ return "--encrypted-hsm was removed, use --hsm-passphrase instead";
+
+ is_encrypted = is_legacy_hsm_secret_encrypted("hsm_secret");
/* While lightningd is performing the first initialization
* this check is always true because the file does not exist.
*
@@ -575,32 +621,15 @@ static char *opt_set_hsm_password(struct lightningd *ld)
log_info(ld->log, "'hsm_secret' does not exist (%s)",
strerror(errno));
- prompt(ld, "The hsm_secret is encrypted with a password. In order to "
- "decrypt it and start the node you must provide the password.");
- prompt(ld, "Enter hsm_secret password:");
-
- passwd = read_stdin_pass_with_exit_code(&err_msg, &opt_exitcode);
- if (!passwd)
- return cast_const(char *, err_msg);
- if (!is_encrypted) {
- prompt(ld, "Confirm hsm_secret password:");
- fflush(stdout);
- passwd_confirmation = read_stdin_pass_with_exit_code(&err_msg, &opt_exitcode);
- if (!passwd_confirmation)
- return cast_const(char *, err_msg);
-
- if (!streq(passwd, passwd_confirmation)) {
- opt_exitcode = EXITCODE_HSM_BAD_PASSWORD;
- return "Passwords confirmation mismatch.";
- }
- free(passwd_confirmation);
- }
- prompt(ld, "");
-
- ld->hsm_passphrase = tal_strdup(ld, passwd);
- free(passwd);
+ return read_hsm_passphrase(ld);
+}
- return NULL;
+/* Set flag to indicate hsm_secret needs a passphrase.
+ * This replaces the old --encrypted-hsm option which was for legacy encrypted secrets.
+ */
+static char *opt_set_hsm_passphrase(struct lightningd *ld)
+{
+ return read_hsm_passphrase(ld);
}
static char *opt_force_privkey(const char *optarg, struct lightningd *ld)
@@ -1517,9 +1546,12 @@ static void register_opts(struct lightningd *ld)
opt_register_early_noarg("--disable-dns", opt_set_invbool, &ld->config.use_dns,
"Disable DNS lookups of peers");
+ /* Deprecated: use --hsm-passphrase instead */
opt_register_noarg("--encrypted-hsm", opt_set_hsm_password, ld,
- "Set the password to encrypt hsm_secret with. If no password is passed through command line, "
- "you will be prompted to enter it.");
+ opt_hidden);
+
+ opt_register_noarg("--hsm-passphrase", opt_set_hsm_passphrase, ld,
+ "Prompt for passphrase for encrypted hsm_secret (replaces --encrypted-hsm)");
opt_register_arg("--rpc-file-mode", &opt_set_mode, &opt_show_mode,
&ld->rpc_filemode,
@@ -1835,5 +1867,6 @@ bool is_known_opt_cb_arg(char *(*cb_arg)(const char *, void *))
|| cb_arg == (void *)opt_force_privkey
|| cb_arg == (void *)opt_force_bip32_seed
|| cb_arg == (void *)opt_force_channel_secrets
- || cb_arg == (void *)opt_force_tmp_channel_id;
+ || cb_arg == (void *)opt_force_tmp_channel_id
+ || cb_arg == (void *)opt_set_hsm_passphrase;
}
diff --git a/tests/test_wallet.py b/tests/test_wallet.py
index a1267e4..88a6bd0 100644
--- a/tests/test_wallet.py
+++ b/tests/test_wallet.py
@@ -13,7 +13,6 @@ import os
import pytest
import subprocess
import sys
-import time
import unittest
@@ -1246,60 +1245,6 @@ def write_all(fd, bytestr):
off += os.write(fd, bytestr[off:])
-@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.")
-def test_hsm_secret_encryption(node_factory):
- l1 = node_factory.get_node(may_fail=True) # May fail when started without key
- password = "reckful&é🍕\n"
- # We need to simulate a terminal to use termios in `lightningd`.
- master_fd, slave_fd = os.openpty()
-
- # Test we can encrypt an already-existing and not encrypted hsm_secret
- l1.stop()
- l1.daemon.opts.update({"encrypted-hsm": None})
- l1.daemon.start(stdin=slave_fd, wait_for_initialized=False)
- l1.daemon.wait_for_log(r'Enter hsm_secret password')
- write_all(master_fd, password.encode("utf-8"))
- l1.daemon.wait_for_log(r'Confirm hsm_secret password')
- write_all(master_fd, password.encode("utf-8"))
- l1.daemon.wait_for_log("Server started with public key")
- id = l1.rpc.getinfo()["id"]
- l1.stop()
-
- # Test we cannot start the same wallet without specifying --encrypted-hsm
- l1.daemon.opts.pop("encrypted-hsm")
- with pytest.raises(subprocess.CalledProcessError, match=r'returned non-zero exit status {}'.format(HSM_ERROR_IS_ENCRYPT)):
- subprocess.check_call(l1.daemon.cmd_line)
-
- # Test we cannot restore the same wallet with another password
- l1.daemon.opts.update({"encrypted-hsm": None})
- l1.daemon.start(stdin=slave_fd, wait_for_initialized=False, stderr_redir=True)
- l1.daemon.wait_for_log(r'Enter hsm_secret password')
- write_all(master_fd, password[2:].encode("utf-8"))
- assert(l1.daemon.proc.wait(WAIT_TIMEOUT) == HSM_BAD_PASSWORD)
- assert(l1.daemon.is_in_stderr("Wrong password for encrypted hsm_secret."))
-
- # Not sure why this helps, but seems to reduce flakiness where
- # tail() thread in testing/utils.py gets 'ValueError: readline of
- # closed file' and we get `ValueError: Process died while waiting for logs`
- # when waiting for "Server started with public key" below.
- time.sleep(10)
-
- # Test we can restore the same wallet with the same password
- l1.daemon.start(stdin=slave_fd, wait_for_initialized=False)
- l1.daemon.wait_for_log(r'The hsm_secret is encrypted')
- write_all(master_fd, password.encode("utf-8"))
- l1.daemon.wait_for_log("Server started with public key")
- assert id == l1.rpc.getinfo()["id"]
- l1.stop()
-
- # We can restore the same wallet with the same password provided through stdin
- l1.daemon.start(stdin=subprocess.PIPE, wait_for_initialized=False)
- l1.daemon.proc.stdin.write(password.encode("utf-8"))
- l1.daemon.proc.stdin.flush()
- l1.daemon.wait_for_log("Server started with public key")
- assert id == l1.rpc.getinfo()["id"]
-
-
class HsmTool(TailableProc):
"""Helper for testing the hsmtool as a subprocess"""
def __init__(self, directory, *args):
@@ -1437,6 +1382,36 @@ def test_hsmtool_generatehsm_variants(node_factory, mnemonic, passphrase, expect
mnemonic_part = content[32:].decode('utf-8')
assert mnemonic in mnemonic_part
+ # Verify Lightning node can use it
+ if passphrase:
+ # For passphrase case, start with hsm-passphrase option and handle prompt
+ master_fd, slave_fd = os.openpty()
+ l1.daemon.start(stdin=slave_fd, wait_for_initialized=False)
+ # Wait for the passphrase prompt
+ l1.daemon.wait_for_log("Enter hsm_secret passphrase:")
+ write_all(master_fd, f"{passphrase}\n".encode("utf-8"))
+ l1.daemon.wait_for_log("Server started with public key")
+ else:
+ # For no passphrase case, start normally without expecting a prompt
+ l1.daemon.start(wait_for_initialized=False)
+ l1.daemon.wait_for_log("Server started with public key")
+
+ node_id = l1.rpc.getinfo()['id']
+ print(f"Node ID for mnemonic '{mnemonic}' with passphrase '{passphrase}': {node_id}")
+ assert len(node_id) == 66 # Valid node ID
+
+ # Expected node IDs for deterministic testing
+ expected_node_ids = {
+ ("ritual idle hat sunny universe pluck key alpha wing cake have wedding", "test_passphrase"): "039020371fb803cd4ce1e9a909b502d7b0a9e0f10cccc35c3e9be959c52d3ba6bd",
+ ("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", ""): "03653e90c1ce4660fd8505dd6d643356e93cfe202af109d382787639dd5890e87d",
+ }
+
+ expected_id = expected_node_ids.get((mnemonic, passphrase))
+ if expected_id:
+ assert node_id == expected_id, f"Expected node ID {expected_id}, got {node_id}"
+ else:
+ print(f"No expected node ID found for this combination, got: {node_id}")
+
@pytest.mark.parametrize("test_case", [
pytest.param({
@@ -1658,6 +1633,65 @@ def test_hsmtool_all_commands_work_with_mnemonic_formats(node_factory):
assert actual_output == expected_output, f"Command {cmd_args[0]} output mismatch"
+def test_hsmtool_deterministic_node_ids(node_factory):
+ """Test that HSM daemon creates deterministic node IDs in new mnemonic format"""
+ # Create a node and start it to trigger HSM daemon to create new format
+ l1 = node_factory.get_node(start=False)
+ hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")
+
+ # Delete any existing hsm_secret so HSM daemon creates it in new format
+ if os.path.exists(hsm_path):
+ os.remove(hsm_path)
+
+ # Start the node to get its node ID (this will create a new hsm_secret in new format)
+ l1.start()
+ normal_node_id = l1.rpc.getinfo()['id']
+ l1.stop()
+
+ # Verify the hsm_secret was created in the new mnemonic format
+ with open(hsm_path, 'rb') as f:
+ content = f.read()
+ # Should be longer than 32 bytes (new format has 32-byte hash + mnemonic)
+ assert len(content) > 32, f"Expected new mnemonic format, got {len(content)} bytes"
+
+ # First 32 bytes should be the passphrase hash (likely zeros for no passphrase)
+ passphrase_hash = content[:32]
+ assert passphrase_hash == b'\x00' * 32
+ mnemonic_bytes = content[32:]
+
+ # Decode the mnemonic bytes
+ mnemonic = mnemonic_bytes.decode('utf-8').strip()
+
+ # Verify it's a valid mnemonic (should be 12 words)
+ words = mnemonic.split()
+ assert len(words) == 12, f"Expected 12 words, got {len(words)}: {mnemonic}"
+
+ # Create a second node and use generatehsm with the mnemonic from the first node
+ l2 = node_factory.get_node(start=False)
+ hsm_path2 = os.path.join(l2.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")
+
+ # Delete any existing hsm_secret for the second node
+ if os.path.exists(hsm_path2):
+ os.remove(hsm_path2)
+
+ # Generate hsm_secret with the mnemonic from the first node
+ hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path2)
+ master_fd, slave_fd = os.openpty()
+ hsmtool.start(stdin=slave_fd)
+ hsmtool.wait_for_log(r"Introduce your BIP39 word list")
+ write_all(master_fd, f"{mnemonic}\n".encode("utf-8"))
+ hsmtool.wait_for_log(r"Enter your passphrase:")
+ write_all(master_fd, "\n".encode("utf-8"))
+ assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0
+
+ # Get the node ID from the generated hsm_secret
+ cmd_line = ["tools/hsmtool", "getnodeid", hsm_path2]
+ generated_node_id = subprocess.check_output(cmd_line).decode("utf8").strip()
+
+ # Verify both node IDs are identical
+ assert normal_node_id == generated_node_id, f"Node IDs don't match: {normal_node_id} != {generated_node_id}"
+
+
# this test does a 'listtransactions' on a yet unconfirmed channel
def test_fundchannel_listtransaction(node_factory, bitcoind):
l1, l2 = node_factory.get_nodes(2)
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index 2fac666..bdfe1af 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -336,6 +336,9 @@ bool fromwire_hsmd_forget_channel_reply(const void *p UNNEEDED)
/* Generated stub for fromwire_hsmd_get_output_scriptpubkey_reply */
bool fromwire_hsmd_get_output_scriptpubkey_reply(const tal_t *ctx UNNEEDED, const void *p UNNEEDED, u8 **script UNNEEDED)
{ fprintf(stderr, "fromwire_hsmd_get_output_scriptpubkey_reply called!\n"); abort(); }
+/* Generated stub for fromwire_hsmd_init_reply_failure */
+bool fromwire_hsmd_init_reply_failure(const tal_t *ctx UNNEEDED, const void *p UNNEEDED, u32 *error_code UNNEEDED, wirestring **error_message UNNEEDED)
+{ fprintf(stderr, "fromwire_hsmd_init_reply_failure called!\n"); abort(); }
/* Generated stub for fromwire_hsmd_init_reply_v4 */
bool fromwire_hsmd_init_reply_v4(const tal_t *ctx UNNEEDED, const void *p UNNEEDED, u32 *hsm_version UNNEEDED, u32 **hsm_capabilities UNNEEDED, struct node_id *node_id UNNEEDED, struct ext_key *bip32 UNNEEDED, struct pubkey *bolt12 UNNEEDED)
{ fprintf(stderr, "fromwire_hsmd_init_reply_v4 called!\n"); abort(); }
Why this scored 33/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.