diff --git a/common/hsm_version.h b/common/hsm_version.h index 920dc77ee..d6a9000da 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 81455d2a0..086738795 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 6c47c94ad..c64a7e9aa 100644 --- a/hsmd/hsmd.c +++ b/hsmd/hsmd.c @@ -12,9 +12,10 @@ #include #include #include +#include #include #include -#include +#include #include #include #include @@ -25,7 +26,9 @@ /*~ _wiregen files are autogenerated by tools/generate-wire.py */ #include #include +#include #include +#include #include /*~ 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)); + u8 *hsm_secret_contents; + struct hsm_secret *hsms; + enum hsm_secret_error err; - /* 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)); - } + /* 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; + * 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; - 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, - 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 4746ac57f..cfca1e69e 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 f459f03f1..9fde4d140 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 61eedee48..45cea82e5 100644 --- a/lightningd/hsm_control.c +++ b/lightningd/hsm_control.c @@ -1,10 +1,11 @@ #include "config.h" #include #include +#include #include #include #include -#include +#include #include #include #include @@ -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 5de7a12ca..f5128229c 100644 --- a/lightningd/lightningd.c +++ b/lightningd/lightningd.c @@ -51,7 +51,7 @@ #include #include #include -#include +#include #include #include #include @@ -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 b87c973c3..2f7343ebb 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 f2364de9e..071468b16 100644 --- a/lightningd/options.c +++ b/lightningd/options.c @@ -12,7 +12,7 @@ #include #include #include -#include +#include #include #include #include @@ -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:"); + return read_hsm_passphrase(ld); +} - 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 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 a1267e4ec..88a6bd04a 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 2fac66644..bdfe1af56 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(); }