hsmd: take the passphrase raw, not the derived secret.
What changed, and why it matters
This commit changes how Core Lightning's main daemon (lightningd) sends an HSM passphrase to the separate HSM daemon (hsmd). Previously, lightningd derived an encryption key from the passphrase locally and sent that derived key to hsmd. Now it sends the raw passphrase inside a new optional protocol field (a TLV extension), and lets hsmd derive the key itself. The change is framed as preparation for BIP-39 mnemonic support. It is a protocol and internal API change, not a clear-cut security fix, but it does alter where the passphrase is handled and how it travels between processes.
Treat this as a design change requiring security review rather than an immediate vulnerability. Review whether the raw passphrase is adequately protected in transit (Unix domain socket) and at rest in memory; ensure the legacy hsm_encryption_key path cannot be abused to downgrade to a weaker key; verify that passphrase memory is locked and zeroed after use; and confirm that BIP-39 integration does not introduce passphrase normalization or encoding issues.
Security signals we found
Change in inter-process secret handling: raw passphrase now crosses the lightningd->hsmd boundary instead of a derived key
Legacy encryption-key field retained but explicitly overridden, creating dual-path key material handling
Passphrase stored as a heap-allocated string in lightningd (ld->hsm_passphrase) rather than a derived secret that was previously discarded with sodium_munlock
Removal of the explicit sodium_munlock/discard_key cleanup path for the derived key in main()
No explicit memory-locking or scrubbing of the new raw passphrase string is visible in the diff
Comment acknowledges compatibility concerns and override behavior, indicating intentional but non-trivial protocol evolution
Evidence from the diff
The patch extends the hsmd_init wire message with a new TLV block containing an optional hsm_passphrase field. lightningd now stores the raw passphrase (ld->hsm_passphrase) instead of a pre-derived secret (config.keypass), and passes NULL for the legacy hsm_encryption_key while attaching the passphrase TLV. hsmd, on receipt, derives hsm_encryption_key from the passphrase itself. The old field is kept in the wire format for backward compatibility but is overridden when the TLV is present. This moves key derivation from lightningd into hsmd and keeps the raw passphrase in lightningd memory longer.
Changed components
hsmd/hsmd.chsmd/hsmd_wire.csvlightningd/hsm_control.clightningd/lightningd.clightningd/lightningd.hlightningd/options.cwallet/test/run-wallet.cInspect captured patch +47 / −24
diff --git a/hsmd/hsmd.c b/hsmd/hsmd.c
index 56f2283..6c47c94 100644
--- a/hsmd/hsmd.c
+++ b/hsmd/hsmd.c
@@ -461,6 +461,7 @@ static struct io_plan *init_hsm(struct io_conn *conn,
struct bip32_key_version bip32_key_version;
u32 minversion, maxversion;
const u32 our_minversion = 4, our_maxversion = 6;
+ struct tlv_hsmd_init_tlvs *tlvs;
/* This must be lightningd. */
assert(is_lightningd(c));
@@ -475,7 +476,7 @@ static struct io_plan *init_hsm(struct io_conn *conn,
&dev_force_bip32_seed,
&dev_force_channel_secrets,
&dev_force_channel_secrets_shaseed,
- &minversion, &maxversion))
+ &minversion, &maxversion, &tlvs))
return bad_req(conn, c, msg_in);
/*~ Usually we don't worry about API breakage between internal daemons,
@@ -487,6 +488,25 @@ static struct io_plan *init_hsm(struct io_conn *conn,
minversion, maxversion,
our_minversion, our_maxversion);
+ /*~ We used to have lightningd hand us the encryption key derived from
+ * the passphrase which was used to encrypt the `hsm_secret` file. Then
+ * Rusty gave me the thankless task of introducing BIP-39 mnemonics. I
+ * 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 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,
diff --git a/hsmd/hsmd_wire.csv b/hsmd/hsmd_wire.csv
index 4f4a40d..4746ac5 100644
--- a/hsmd/hsmd_wire.csv
+++ b/hsmd/hsmd_wire.csv
@@ -29,6 +29,9 @@ msgdata,hsmd_init,dev_force_channel_secrets,?secrets,
msgdata,hsmd_init,dev_force_channel_secrets_shaseed,?sha256,
msgdata,hsmd_init,hsm_wire_min_version,u32,
msgdata,hsmd_init,hsm_wire_max_version,u32,
+msgdata,hsmd_init,tlvs,hsmd_init_tlvs,
+tlvtype,hsmd_init_tlvs,hsm_passphrase,1
+tlvdata,hsmd_init_tlvs,hsm_passphrase,passphrase,wirestring,
#include <common/bip32.h>
# Sorry: I should have put version in v2 :(
diff --git a/lightningd/hsm_control.c b/lightningd/hsm_control.c
index 599ef40..61eedee 100644
--- a/lightningd/hsm_control.c
+++ b/lightningd/hsm_control.c
@@ -98,7 +98,7 @@ struct ext_key *hsm_init(struct lightningd *ld)
/* 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->config.keypass) {
+ 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.");
@@ -122,16 +122,24 @@ struct ext_key *hsm_init(struct lightningd *ld)
err(EXITCODE_HSM_GENERIC_ERROR, "Writing preinit msg to hsm");
}
+ /* Create TLV for passphrase if needed */
+ struct tlv_hsmd_init_tlvs *tlv = NULL;
+ if (ld->hsm_passphrase) {
+ tlv = tlv_hsmd_init_tlvs_new(tmpctx);
+ tlv->hsm_passphrase = ld->hsm_passphrase;
+ }
+
if (!wire_sync_write(ld->hsm_fd, towire_hsmd_init(tmpctx,
&chainparams->bip32_key_version,
chainparams,
- ld->config.keypass,
+ NULL,
ld->dev_force_privkey,
ld->dev_force_bip32_seed,
ld->dev_force_channel_secrets,
ld->dev_force_channel_secrets_shaseed,
HSM_MIN_VERSION,
- HSM_MAX_VERSION)))
+ HSM_MAX_VERSION,
+ tlv)))
err(EXITCODE_HSM_GENERIC_ERROR, "Writing init msg to hsm");
bip32_base = tal(ld, struct ext_key);
@@ -143,7 +151,7 @@ struct ext_key *hsm_init(struct lightningd *ld)
&unused)) {
/* nothing to do. */
} else {
- if (ld->config.keypass)
+ 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");
}
diff --git a/lightningd/lightningd.c b/lightningd/lightningd.c
index 2ce30d3..5de7a12 100644
--- a/lightningd/lightningd.c
+++ b/lightningd/lightningd.c
@@ -309,10 +309,10 @@ static struct lightningd *new_lightningd(const tal_t *ctx)
/*~ This is set when a JSON RPC command comes in to shut us down. */
ld->stop_conn = NULL;
- /*~ This is used to signal that `hsm_secret` is encrypted, and will
- * be set to `true` if the `--encrypted-hsm` option is passed at startup.
+ /*~ This is used to store the passphrase for hsm_secret if needed.
+ * It will be set if the `--hsm-passphrase` option is passed at startup.
*/
- ld->encrypted_hsm = false;
+ ld->hsm_passphrase = NULL;
/* This is used to override subdaemons */
strmap_init(&ld->alt_subdaemons);
@@ -1305,11 +1305,6 @@ int main(int argc, char *argv[])
/*~ This is the ccan/io central poll override from above. */
io_poll_override(io_poll_lightningd);
- /*~ If hsm_secret is encrypted, we don't need its encryption key
- * anymore. Note that sodium_munlock() also zeroes the memory.*/
- if (ld->config.keypass)
- discard_key(take(ld->config.keypass));
-
/*~ Our default color and alias are derived from our node id, so we
* can only set those now (if not set by config options). */
setup_color_and_alias(ld);
diff --git a/lightningd/lightningd.h b/lightningd/lightningd.h
index ce8b62b..b87c973 100644
--- a/lightningd/lightningd.h
+++ b/lightningd/lightningd.h
@@ -69,8 +69,6 @@ struct config {
/* Minimal amount of effective funding_satoshis for accepting channels */
u64 min_capacity_sat;
- /* This is the key we use to encrypt `hsm_secret`. */
- struct secret *keypass;
/* How long before we give up waiting for INIT msg */
u32 connection_timeout_secs;
@@ -387,7 +385,9 @@ struct lightningd {
char *old_bookkeeper_dir;
char *old_bookkeeper_db;
- bool encrypted_hsm;
+ /* HSM passphrase for any format that needs it */
+ char *hsm_passphrase;
+
/* What (additional) messages the HSM accepts */
u32 *hsm_capabilities;
diff --git a/lightningd/options.c b/lightningd/options.c
index 1ef5d1f..f2364de 100644
--- a/lightningd/options.c
+++ b/lightningd/options.c
@@ -597,13 +597,7 @@ static char *opt_set_hsm_password(struct lightningd *ld)
}
prompt(ld, "");
- ld->config.keypass = tal(NULL, struct secret);
-
- opt_exitcode = hsm_secret_encryption_key_with_exitcode(passwd, ld->config.keypass, &err_msg);
- if (opt_exitcode > 0)
- return cast_const(char *, err_msg);
-
- ld->encrypted_hsm = true;
+ ld->hsm_passphrase = tal_strdup(ld, passwd);
free(passwd);
return NULL;
diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c
index c616710..2fac666 100644
--- a/wallet/test/run-wallet.c
+++ b/wallet/test/run-wallet.c
@@ -641,6 +641,9 @@ void tell_connectd_peer_importance(struct peer *peer UNNEEDED,
/* Generated stub for tlv_hsmd_dev_preinit_tlvs_new */
struct tlv_hsmd_dev_preinit_tlvs *tlv_hsmd_dev_preinit_tlvs_new(const tal_t *ctx UNNEEDED)
{ fprintf(stderr, "tlv_hsmd_dev_preinit_tlvs_new called!\n"); abort(); }
+/* Generated stub for tlv_hsmd_init_tlvs_new */
+struct tlv_hsmd_init_tlvs *tlv_hsmd_init_tlvs_new(const tal_t *ctx UNNEEDED)
+{ fprintf(stderr, "tlv_hsmd_init_tlvs_new called!\n"); abort(); }
/* Generated stub for topology_add_sync_waiter_ */
void topology_add_sync_waiter_(const tal_t *ctx UNNEEDED,
struct chain_topology *topo UNNEEDED,
@@ -712,7 +715,7 @@ u8 *towire_hsmd_forget_channel(const tal_t *ctx UNNEEDED, const struct node_id *
u8 *towire_hsmd_get_output_scriptpubkey(const tal_t *ctx UNNEEDED, u64 channel_id UNNEEDED, const struct node_id *peer_id UNNEEDED, const struct pubkey *commitment_point UNNEEDED)
{ fprintf(stderr, "towire_hsmd_get_output_scriptpubkey called!\n"); abort(); }
/* Generated stub for towire_hsmd_init */
-u8 *towire_hsmd_init(const tal_t *ctx UNNEEDED, const struct bip32_key_version *bip32_key_version UNNEEDED, const struct chainparams *chainparams UNNEEDED, const struct secret *hsm_encryption_key UNNEEDED, const struct privkey *dev_force_privkey UNNEEDED, const struct secret *dev_force_bip32_seed UNNEEDED, const struct secrets *dev_force_channel_secrets UNNEEDED, const struct sha256 *dev_force_channel_secrets_shaseed UNNEEDED, u32 hsm_wire_min_version UNNEEDED, u32 hsm_wire_max_version UNNEEDED)
+u8 *towire_hsmd_init(const tal_t *ctx UNNEEDED, const struct bip32_key_version *bip32_key_version UNNEEDED, const struct chainparams *chainparams UNNEEDED, const struct secret *hsm_encryption_key UNNEEDED, const struct privkey *dev_force_privkey UNNEEDED, const struct secret *dev_force_bip32_seed UNNEEDED, const struct secrets *dev_force_channel_secrets UNNEEDED, const struct sha256 *dev_force_channel_secrets_shaseed UNNEEDED, u32 hsm_wire_min_version UNNEEDED, u32 hsm_wire_max_version UNNEEDED, const struct tlv_hsmd_init_tlvs *tlvs UNNEEDED)
{ fprintf(stderr, "towire_hsmd_init called!\n"); abort(); }
/* Generated stub for towire_hsmd_new_channel */
u8 *towire_hsmd_new_channel(const tal_t *ctx UNNEEDED, const struct node_id *id UNNEEDED, u64 dbid UNNEEDED)
Why this scored 35/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.