diff --git a/common/Makefile b/common/Makefile index dec98e099b26..ea59672fece9 100644 --- a/common/Makefile +++ b/common/Makefile @@ -46,6 +46,7 @@ COMMON_SRC_NOGEN := \ common/hmac.c \ common/hsm_capable.c \ common/hsm_encryption.c \ + common/hsm_secret.c \ common/htlc_state.c \ common/htlc_trim.c \ common/htlc_tx.c \ diff --git a/common/hsm_secret.c b/common/hsm_secret.c new file mode 100644 index 000000000000..71b91c34cfdd --- /dev/null +++ b/common/hsm_secret.c @@ -0,0 +1,463 @@ +#include "config.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Length of the encrypted hsm secret header. */ +#define HS_HEADER_LEN crypto_secretstream_xchacha20poly1305_HEADERBYTES +/* From libsodium: "The ciphertext length is guaranteed to always be message + * length + ABYTES" */ +#define HS_CIPHERTEXT_LEN \ + (sizeof(struct secret) + crypto_secretstream_xchacha20poly1305_ABYTES) +/* Total length of an encrypted hsm_secret */ +#define ENCRYPTED_HSM_SECRET_LEN (HS_HEADER_LEN + HS_CIPHERTEXT_LEN) + +void destroy_secret(struct secret *secret) +{ + sodium_munlock(secret->data, sizeof(secret->data)); +} + +/* Helper function to validate a mnemonic string */ +static bool validate_mnemonic(const char *mnemonic, enum hsm_secret_error *err) +{ + struct words *words; + + if (bip39_get_wordlist("en", &words) != WALLY_OK) { + abort(); + } + + if (bip39_mnemonic_validate(words, mnemonic) != WALLY_OK) { + *err = HSM_SECRET_ERR_INVALID_MNEMONIC; + return false; + } + + return true; +} + +struct secret *get_encryption_key(const tal_t *ctx, const char *passphrase) +{ + struct secret *secret = tal(ctx, struct secret); + const u8 salt[16] = "c-lightning\0\0\0\0\0"; + + /* Check bounds. */ + if (strlen(passphrase) < crypto_pwhash_argon2id_PASSWD_MIN) { + return tal_free(secret); + } else if (strlen(passphrase) > crypto_pwhash_argon2id_PASSWD_MAX) { + return tal_free(secret); + } + + /* Don't swap the encryption key ! */ + if (sodium_mlock(secret->data, sizeof(secret->data)) != 0) + return tal_free(secret); + tal_add_destructor(secret, destroy_secret); + + /* Now derive the key. */ + if (crypto_pwhash(secret->data, sizeof(secret->data), passphrase, strlen(passphrase), salt, + /* INTERACTIVE needs 64 MiB of RAM, MODERATE needs 256, + * and SENSITIVE needs 1024. */ + crypto_pwhash_argon2id_OPSLIMIT_MODERATE, + crypto_pwhash_argon2id_MEMLIMIT_MODERATE, + crypto_pwhash_ALG_ARGON2ID13) != 0) { + return tal_free(secret); + } + + return secret; +} + +bool hsm_secret_needs_passphrase(const u8 *hsm_secret, size_t len) +{ + enum hsm_secret_type type = detect_hsm_secret_type(hsm_secret, len); + + switch (type) { + case HSM_SECRET_ENCRYPTED: + case HSM_SECRET_MNEMONIC_WITH_PASS: + return true; + case HSM_SECRET_PLAIN: + case HSM_SECRET_MNEMONIC_NO_PASS: + case HSM_SECRET_INVALID: + return false; + } + abort(); +} + +enum hsm_secret_type detect_hsm_secret_type(const u8 *hsm_secret, size_t len) +{ + /* Check for invalid cases first and return early */ + if (len < HSM_SECRET_PLAIN_SIZE) + return HSM_SECRET_INVALID; + + /* Legacy 32-byte plain format */ + if (len == HSM_SECRET_PLAIN_SIZE) + return HSM_SECRET_PLAIN; + + /* Legacy 73-byte encrypted format */ + if (len == ENCRYPTED_HSM_SECRET_LEN) + return HSM_SECRET_ENCRYPTED; + + /* Check if it starts with our type bytes (mnemonic formats) */ + if (memeqzero(hsm_secret, 32)) + return HSM_SECRET_MNEMONIC_NO_PASS; + else + return HSM_SECRET_MNEMONIC_WITH_PASS; +} + +/* Helper function to derive seed hash from mnemonic + passphrase */ +bool derive_seed_hash(const char *mnemonic, const char *passphrase, struct sha256 *seed_hash) +{ + if (!passphrase) { + /* No passphrase - return zero hash */ + memset(seed_hash, 0, sizeof(*seed_hash)); + return true; + } + + u8 bip32_seed[BIP39_SEED_LEN_512]; + size_t bip32_seed_len; + + if (bip39_mnemonic_to_seed(mnemonic, passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len) != WALLY_OK) + return false; + + sha256(seed_hash, bip32_seed, sizeof(bip32_seed)); + return true; +} + +static bool decrypt_hsm_secret(const struct secret *encryption_key, + const u8 *cipher, + struct secret *output) +{ + crypto_secretstream_xchacha20poly1305_state crypto_state; + + /* The header part */ + if (crypto_secretstream_xchacha20poly1305_init_pull(&crypto_state, cipher, + encryption_key->data) != 0) + return false; + /* The ciphertext part */ + if (crypto_secretstream_xchacha20poly1305_pull(&crypto_state, output->data, + NULL, 0, + cipher + HS_HEADER_LEN, + HS_CIPHERTEXT_LEN, + NULL, 0) != 0) + return false; + + return true; +} + +/* Helper function to convert error codes to human-readable messages */ +const char *hsm_secret_error_str(enum hsm_secret_error err) +{ + switch (err) { + case HSM_SECRET_OK: + return "Success"; + case HSM_SECRET_ERR_PASSPHRASE_REQUIRED: + return "Passphrase required but not provided"; + case HSM_SECRET_ERR_PASSPHRASE_NOT_NEEDED: + return "Passphrase provided but not needed"; + case HSM_SECRET_ERR_WRONG_PASSPHRASE: + return "Wrong passphrase"; + case HSM_SECRET_ERR_INVALID_MNEMONIC: + return "Invalid mnemonic"; + case HSM_SECRET_ERR_ENCRYPTION_FAILED: + return "Encryption failed"; + case HSM_SECRET_ERR_SEED_DERIVATION_FAILED: + return "Could not derive seed from mnemonic"; + case HSM_SECRET_ERR_INVALID_FORMAT: + return "Invalid hsm_secret format"; + case HSM_SECRET_ERR_TERMINAL: + return "Terminal error"; + case HSM_SECRET_ERR_MEMORY: + return "Memory error"; + } + return "Unknown error"; +} + +static struct hsm_secret *extract_plain_secret(const tal_t *ctx, + const u8 *hsm_secret, + size_t len, + enum hsm_secret_error *err) +{ + struct hsm_secret *hsms = tal(ctx, struct hsm_secret); + + hsms->type = HSM_SECRET_PLAIN; + hsms->mnemonic = NULL; + memcpy(&hsms->secret, hsm_secret, sizeof(hsms->secret)); + + *err = HSM_SECRET_OK; + return hsms; +} + +static struct hsm_secret *extract_encrypted_secret(const tal_t *ctx, + const u8 *hsm_secret, + size_t len, + const char *passphrase, + enum hsm_secret_error *err) +{ + struct hsm_secret *hsms = tal(ctx, struct hsm_secret); + struct secret *encryption_key; + bool decrypt_success; + + if (!passphrase) { + *err = HSM_SECRET_ERR_PASSPHRASE_REQUIRED; + return tal_free(hsms); + } + encryption_key = get_encryption_key(tmpctx, passphrase); + if (!encryption_key) { + *err = HSM_SECRET_ERR_WRONG_PASSPHRASE; + return tal_free(hsms); + } + + /* Clear secret data first in case of partial decryption */ + memset(&hsms->secret, 0, sizeof(hsms->secret)); + + /* Attempt decryption */ + decrypt_success = decrypt_hsm_secret(encryption_key, hsm_secret, &hsms->secret); + + /* Clear encryption key immediately after use */ + destroy_secret(encryption_key); + + if (!decrypt_success) { + /* Clear any partial decryption data */ + memset(&hsms->secret, 0, sizeof(hsms->secret)); + *err = HSM_SECRET_ERR_WRONG_PASSPHRASE; + return tal_free(hsms); + } + + hsms->type = HSM_SECRET_ENCRYPTED; + hsms->mnemonic = NULL; + + *err = HSM_SECRET_OK; + return hsms; +} + +static struct hsm_secret *extract_mnemonic_secret(const tal_t *ctx, + const u8 *hsm_secret, + size_t len, + const char *passphrase, + enum hsm_secret_type type, + enum hsm_secret_error *err) +{ + struct hsm_secret *hsms = tal(ctx, struct hsm_secret); + const u8 *mnemonic_start; + size_t mnemonic_len; + + assert(type == HSM_SECRET_MNEMONIC_NO_PASS || type == HSM_SECRET_MNEMONIC_WITH_PASS); + hsms->type = type; + + /* Extract mnemonic portion (skip first 32 bytes which are passphrase hash) */ + mnemonic_start = hsm_secret + PASSPHRASE_HASH_LEN; + mnemonic_len = len - PASSPHRASE_HASH_LEN; + + /* Validate passphrase if required */ + if (type == HSM_SECRET_MNEMONIC_WITH_PASS) { + if (!passphrase) { + *err = HSM_SECRET_ERR_PASSPHRASE_REQUIRED; + return tal_free(hsms); + } + + /* Validate passphrase by comparing stored hash with computed hash */ + struct sha256 stored_hash, computed_hash; + memcpy(&stored_hash, hsm_secret, sizeof(stored_hash)); + if (!derive_seed_hash((const char *)mnemonic_start, passphrase, &computed_hash)) { + *err = HSM_SECRET_ERR_SEED_DERIVATION_FAILED; + return tal_free(hsms); + } + if (!sha256_eq(&stored_hash, &computed_hash)) { + *err = HSM_SECRET_ERR_WRONG_PASSPHRASE; + return tal_free(hsms); + } + } else { + if (passphrase) { + *err = HSM_SECRET_ERR_PASSPHRASE_NOT_NEEDED; + return tal_free(hsms); + } + } + + /* Copy and validate mnemonic */ + hsms->mnemonic = tal_strndup(hsms, (const char *)mnemonic_start, mnemonic_len); + + /* Validate mnemonic */ + if (!validate_mnemonic(hsms->mnemonic, err)) { + return tal_free(hsms); + } + + /* Derive the seed from the mnemonic */ + u8 bip32_seed[BIP39_SEED_LEN_512]; + size_t bip32_seed_len; + + if (bip39_mnemonic_to_seed(hsms->mnemonic, passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len) != WALLY_OK) { + *err = HSM_SECRET_ERR_SEED_DERIVATION_FAILED; + return tal_free(hsms); + } + + /* We only use the first 32 bytes for the hsm_secret */ + memcpy(hsms->secret.data, bip32_seed, sizeof(hsms->secret.data)); + + *err = HSM_SECRET_OK; + return hsms; +} + +/* If hsm_secret_needs_passphrase, passphrase must not be NULL. + * Returns NULL on failure. */ +struct hsm_secret *extract_hsm_secret(const tal_t *ctx, + const u8 *hsm_secret, size_t len, + const char *passphrase, + enum hsm_secret_error *err) +{ + enum hsm_secret_type type = detect_hsm_secret_type(hsm_secret, len); + + switch (type) { + case HSM_SECRET_PLAIN: + return extract_plain_secret(ctx, hsm_secret, len, err); + case HSM_SECRET_ENCRYPTED: + return extract_encrypted_secret(ctx, hsm_secret, len, passphrase, err); + case HSM_SECRET_MNEMONIC_NO_PASS: + case HSM_SECRET_MNEMONIC_WITH_PASS: + return extract_mnemonic_secret(ctx, hsm_secret, len, passphrase, type, err); + case HSM_SECRET_INVALID: + *err = HSM_SECRET_ERR_INVALID_FORMAT; + return NULL; + } + abort(); +} + +bool encrypt_legacy_hsm_secret(const struct secret *encryption_key, + const struct secret *hsm_secret, + u8 *output) +{ + crypto_secretstream_xchacha20poly1305_state crypto_state; + + if (crypto_secretstream_xchacha20poly1305_init_push(&crypto_state, output, + encryption_key->data) != 0) + return false; + if (crypto_secretstream_xchacha20poly1305_push(&crypto_state, + output + HS_HEADER_LEN, + NULL, hsm_secret->data, + sizeof(hsm_secret->data), + /* Additional data and tag */ + NULL, 0, 0)) + return false; + + return true; +} + +static void destroy_passphrase(char *passphrase) +{ + sodium_munlock(passphrase, tal_bytelen(passphrase)); +} + +/* Disable terminal echo if needed */ +static bool disable_echo(struct termios *saved_term) +{ + if (!isatty(fileno(stdin))) + return false; + + if (tcgetattr(fileno(stdin), saved_term) != 0) + return false; + + struct termios tmp = *saved_term; + tmp.c_lflag &= ~ECHO; + + if (tcsetattr(fileno(stdin), TCSANOW, &tmp) != 0) + return false; + + return true; +} + +/* Restore terminal echo if it was disabled */ +static void restore_echo(const struct termios *saved_term) +{ + tcsetattr(fileno(stdin), TCSANOW, saved_term); +} + +/* Read line from stdin (uses tal allocation) */ +static char *read_line(const tal_t *ctx) +{ + char *line = NULL; + size_t size = 0; + + if (getline(&line, &size, stdin) < 0) { + free(line); + return NULL; + } + + /* Strip newline */ + size_t len = strlen(line); + if (len > 0 && line[len - 1] == '\n') + line[len - 1] = '\0'; + + /* Convert to tal string */ + char *result = tal_strndup(ctx, line, len); + free(line); + return result; +} + +const char *read_stdin_pass(const tal_t *ctx, enum hsm_secret_error *err) +{ + *err = HSM_SECRET_OK; + + struct termios saved_term; + bool echo_disabled = disable_echo(&saved_term); + if (isatty(fileno(stdin)) && !echo_disabled) { + *err = HSM_SECRET_ERR_TERMINAL; + return NULL; + } + + char *input = read_line(ctx); + if (!input) { + if (echo_disabled) + restore_echo(&saved_term); + *err = HSM_SECRET_ERR_INVALID_FORMAT; + return NULL; + } + + /* Memory locking is mandatory: failure means we're on an insecure system */ + if (sodium_mlock(input, tal_bytelen(input)) != 0) + abort(); + + tal_add_destructor(input, destroy_passphrase); + + if (echo_disabled) + restore_echo(&saved_term); + + return input; +} + +const char *read_stdin_mnemonic(const tal_t *ctx, enum hsm_secret_error *err) +{ + *err = HSM_SECRET_OK; + + printf("Introduce your BIP39 word list separated by space (at least 12 words):\n"); + fflush(stdout); + + char *line = read_line(ctx); + if (!line) { + *err = HSM_SECRET_ERR_INVALID_FORMAT; + return NULL; + } + + /* Validate mnemonic */ + if (!validate_mnemonic(line, err)) { + return NULL; + } + + return line; +} + +int is_legacy_hsm_secret_encrypted(const char *path) +{ + struct stat st; + + if (stat(path, &st) != 0) + return -1; + + return st.st_size == ENCRYPTED_HSM_SECRET_LEN; +} \ No newline at end of file diff --git a/common/hsm_secret.h b/common/hsm_secret.h new file mode 100644 index 000000000000..526941a6352a --- /dev/null +++ b/common/hsm_secret.h @@ -0,0 +1,152 @@ +#ifndef LIGHTNING_COMMON_HSM_SECRET_H +#define LIGHTNING_COMMON_HSM_SECRET_H +#include "config.h" +#include +#include +#include +#include +#include + +/* Length constants for encrypted HSM secret files */ +#define HS_HEADER_LEN crypto_secretstream_xchacha20poly1305_HEADERBYTES +#define HS_CIPHERTEXT_LEN \ + (sizeof(struct secret) + crypto_secretstream_xchacha20poly1305_ABYTES) +#define ENCRYPTED_HSM_SECRET_LEN (HS_HEADER_LEN + HS_CIPHERTEXT_LEN) +#define PASSPHRASE_HASH_LEN 32 +#define HSM_SECRET_PLAIN_SIZE 32 + +enum hsm_secret_type { + HSM_SECRET_PLAIN = 0, /* Legacy 32-byte format */ + HSM_SECRET_ENCRYPTED = 1, /* Legacy 73-byte encrypted format */ + HSM_SECRET_MNEMONIC_NO_PASS = 2, /* Mnemonic without passphrase */ + HSM_SECRET_MNEMONIC_WITH_PASS = 3, /* Mnemonic with passphrase */ + HSM_SECRET_INVALID = 4, +}; + +enum hsm_secret_error { + HSM_SECRET_OK = 0, + HSM_SECRET_ERR_PASSPHRASE_REQUIRED, + HSM_SECRET_ERR_PASSPHRASE_NOT_NEEDED, + HSM_SECRET_ERR_WRONG_PASSPHRASE, + HSM_SECRET_ERR_INVALID_MNEMONIC, + HSM_SECRET_ERR_ENCRYPTION_FAILED, + HSM_SECRET_ERR_SEED_DERIVATION_FAILED, + HSM_SECRET_ERR_INVALID_FORMAT, + HSM_SECRET_ERR_TERMINAL, + HSM_SECRET_ERR_MEMORY +}; + +/** + * Represents the content of the hsm_secret file, either a raw seed or a mnemonic. + */ +struct hsm_secret { + struct secret secret; + char *mnemonic; /* NULL if not derived from mnemonic */ + enum hsm_secret_type type; +}; + +/** + * Checks whether the hsm_secret data requires a passphrase to decrypt. + * Handles legacy, encrypted, and mnemonic-based formats. + */ +bool hsm_secret_needs_passphrase(const u8 *hsm_secret, size_t len); + +/** + * Parse and decrypt an hsm_secret file. + * + * @ctx - a tal context + * @hsm_secret - raw file contents + * @len - length of file + * @passphrase - passphrase, or NULL if not needed + * @err - optional pointer to set error code on failure + * + * Returns parsed `struct hsm_secret` or NULL on error. + */ +struct hsm_secret *extract_hsm_secret(const tal_t *ctx, + const u8 *hsm_secret, size_t len, + const char *passphrase, + enum hsm_secret_error *err); + +/** + * Encrypt a given hsm_secret using a provided encryption key. + * @encryption_key - derived from passphrase (via Argon2) + * @hsm_secret - plaintext secret to encrypt + * @output - output buffer for encrypted data (must be ENCRYPTED_HSM_SECRET_LEN bytes) + * + * Returns true on success. + */ +bool encrypt_legacy_hsm_secret(const struct secret *encryption_key, + const struct secret *hsm_secret, + u8 *output); + +/** + * Reads a passphrase from stdin, disabling terminal echo. + * Returns a newly allocated string on success, NULL on error. + * @ctx - tal context for allocation + * @err - on failure, this will be set to the error code + * + * Returns allocated passphrase or NULL on error. + */ +const char *read_stdin_pass(const tal_t *ctx, enum hsm_secret_error *err); + +/** + * Derive encryption key from passphrase using Argon2id. + * @ctx - tal context for allocation + * @passphrase - the passphrase to derive from + * + * Returns allocated secret or NULL on failure. + */ +struct secret *get_encryption_key(const tal_t *ctx, const char *passphrase); + +/** + * Convert error code to human-readable string. + * @err - the error code to convert + * + * Returns a string describing the error. + */ +const char *hsm_secret_error_str(enum hsm_secret_error err); + +/** + * Detect the type of hsm_secret based on its content and length. + * @hsm_secret - raw file contents + * @len - length of file + * + * Returns the detected type. + */ +enum hsm_secret_type detect_hsm_secret_type(const u8 *hsm_secret, size_t len); + +/** + * Reads a BIP39 mnemonic from stdin with validation. + * Returns a newly allocated string on success, NULL on error. + * @ctx - tal context for allocation + * @err - optional pointer to set error code on failure + * + * Returns tal-allocated mnemonic string or NULL on error. + */ +const char *read_stdin_mnemonic(const tal_t *ctx, enum hsm_secret_error *err); + +/** + * Derive seed hash from mnemonic + passphrase. + * @mnemonic - the BIP39 mnemonic + * @passphrase - the passphrase (can be NULL) + * @seed_hash - output parameter for the derived seed hash + * + * Returns true on success, false on failure. + */ +bool derive_seed_hash(const char *mnemonic, const char *passphrase, struct sha256 *seed_hash); + +/** + * Check if hsm_secret file is encrypted (legacy format only). + * @path - path to the hsm_secret file + * + * Returns 1 if encrypted, 0 if not encrypted, -1 on error. + */ +int is_legacy_hsm_secret_encrypted(const char *path); + +/** + * Zero and unlock a secret's memory. + * @secret - the secret to destroy + */ +void destroy_secret(struct secret *secret); + +#endif /* LIGHTNING_COMMON_HSM_SECRET_H */ diff --git a/external/libbacktrace b/external/libbacktrace index 2446c6607648..793921876c98 160000 --- a/external/libbacktrace +++ b/external/libbacktrace @@ -1 +1 @@ -Subproject commit 2446c66076480ce07a6bd868badcbceb3eeecc2e +Subproject commit 793921876c981ce49759114d7bb89bb89b2d3a2d diff --git a/hsmd/Makefile b/hsmd/Makefile index b4f51bef2ffd..5855ceb63ed1 100644 --- a/hsmd/Makefile +++ b/hsmd/Makefile @@ -35,7 +35,7 @@ HSMD_COMMON_OBJS := \ common/daemon_conn.o \ common/derive_basepoints.o \ common/hash_u5.o \ - common/hsm_encryption.o \ + common/hsm_secret.o \ common/htlc_wire.o \ common/key_derive.o \ common/lease_rates.o \ diff --git a/hsmd/hsmd.c b/hsmd/hsmd.c index ff6d1e7b2c91..918881c829fe 100644 --- a/hsmd/hsmd.c +++ b/hsmd/hsmd.c @@ -12,20 +12,23 @@ #include #include #include +#include #include #include -#include +#include #include #include #include #include #include #include +#include /*~ _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,66 +273,138 @@ 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; - - 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)); + u8 *msg; + va_list ap; + char *formatted_msg; + + 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; + + status_debug("HSM: Starting create_hsm with passphrase=%s", passphrase ? "provided" : "none"); + + /* Initialize wally tal context for libwally operations */ + + status_debug("HSM: Initialized wally tal context"); + + /* Generate random entropy for new mnemonic */ + randombytes_buf(entropy, sizeof(entropy)); + status_debug("HSM: Generated random 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"); + } + status_debug("HSM: Generated 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"); + } + status_debug("HSM: Derived seed hash from mnemonic"); + + /* Create hsm_secret format: seed_hash (32 bytes) + mnemonic */ + hsm_secret_len = PASSPHRASE_HASH_LEN + strlen(mnemonic); + hsm_secret_data = tal_arr(tmpctx, u8, hsm_secret_len); + + /* Copy seed hash first */ + memcpy(hsm_secret_data, &seed_hash, PASSPHRASE_HASH_LEN); + /* Copy mnemonic after seed hash */ + memcpy(hsm_secret_data + PASSPHRASE_HASH_LEN, mnemonic, strlen(mnemonic)); + status_debug("HSM: Created hsm_secret data structure"); + + /* 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"); + } + status_debug("HSM: Derived BIP32 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)); } + status_debug("HSM: Successfully wrote hsm_secret to file"); } /*~ 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! */ int fd = open("hsm_secret", O_CREAT|O_EXCL|O_WRONLY, 0400); if (fd < 0) { /* If this is not the first time we've run, it will exist. */ - if (errno == EEXIST) + if (errno == EEXIST) { + status_debug("HSM: hsm_secret file already exists, skipping creation"); return; + } status_failed(STATUS_FAIL_INTERNAL_ERROR, "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); + status_debug("HSM: Creating new hsm_secret file"); + + /*~ 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 +442,36 @@ 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 */ + hsm_secret_contents = grab_file(tmpctx, "hsm_secret"); + 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); - } + /* Remove the NUL terminator that grab_file adds */ + tal_resize(&hsm_secret_contents, tal_bytelen(hsm_secret_contents) - 1); + + /* Extract the secret using the new hsm_secret module */ + tal_wally_start(); + hsms = extract_hsm_secret(tmpctx, hsm_secret_contents, + tal_bytelen(hsm_secret_contents), + passphrase, &err); + tal_wally_end(tmpctx); + if (!hsms) { + status_debug("HSM: Failed to load hsm_secret: %s", hsm_secret_error_str(err)); + 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 the extracted secret to our global hsm_secret */ + memcpy(&hsm_secret, &hsms->secret, sizeof(hsm_secret)); } /*~ We have a pre-init call in developer mode, to set dev flags */ @@ -458,9 +507,11 @@ static struct io_plan *init_hsm(struct io_conn *conn, const u8 *msg_in) { struct secret *hsm_encryption_key; + const char *hsm_passphrase = NULL; /* Initialize to NULL */ 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 +526,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,14 +538,19 @@ static struct io_plan *init_hsm(struct io_conn *conn, minversion, maxversion, our_minversion, our_maxversion); - /*~ 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."); + /*~ 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 (and lightningd + * never sets that anymore), and we use the TLV instead. */ + if (tlvs->hsm_passphrase) + hsm_passphrase = (const char *)tlvs->hsm_passphrase; + /*~ 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); @@ -506,16 +562,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)); } @@ -736,6 +791,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 4a8c7c7d9ec1..50d72f6ece2c 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 # Sorry: I should have put version in v2 :( @@ -44,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 7ce12bc083cf..6ddd68f75a3b 100644 --- a/hsmd/libhsmd.c +++ b/hsmd/libhsmd.c @@ -171,6 +171,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: @@ -2300,6 +2301,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/Makefile b/lightningd/Makefile index b58ceaaaf018..bc1644a829c5 100644 --- a/lightningd/Makefile +++ b/lightningd/Makefile @@ -111,7 +111,7 @@ LIGHTNINGD_COMMON_OBJS := \ common/hash_u5.o \ common/hmac.o \ common/hsm_capable.o \ - common/hsm_encryption.o \ + common/hsm_secret.o \ common/htlc_state.o \ common/htlc_trim.o \ common/htlc_tx.o \ diff --git a/lightningd/hsm_control.c b/lightningd/hsm_control.c index 7c0c6587fdc0..fec2ff76275c 100644 --- a/lightningd/hsm_control.c +++ b/lightningd/hsm_control.c @@ -1,11 +1,12 @@ #include "config.h" #include #include +#include #include #include #include #include -#include +#include #include #include #include @@ -100,15 +101,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->config.keypass) { - 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) { @@ -127,20 +119,38 @@ 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 = tal_strdup(tlv, 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); 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 - exit with the specific error code */ + 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, @@ -148,9 +158,8 @@ struct ext_key *hsm_init(struct lightningd *ld) &unused)) { /* nothing to do. */ } else { - if (ld->config.keypass) - 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 68b2e2462ec5..3728b0fc0dad 100644 --- a/lightningd/lightningd.c +++ b/lightningd/lightningd.c @@ -52,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -235,6 +236,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); @@ -314,10 +316,11 @@ 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); @@ -1310,11 +1313,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 65383c46bdad..396223d15227 100644 --- a/lightningd/lightningd.h +++ b/lightningd/lightningd.h @@ -69,8 +69,7 @@ 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; + /* Encryption key derivation is now handled by hsmd internally */ /* How long before we give up waiting for INIT msg */ u32 connection_timeout_secs; @@ -378,7 +377,11 @@ struct lightningd { char *wallet_dsn; - 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 115c7ded7eab..918f5dbe6f39 100644 --- a/lightningd/options.c +++ b/lightningd/options.c @@ -16,7 +16,7 @@ #include #include #include -#include +#include #include #include #include @@ -559,19 +559,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.05", "v26.05")) + return "--encrypted-hsm is deprecated, 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. * @@ -582,36 +628,22 @@ 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->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); + /* Read passphrase from user */ + char *err = read_hsm_passphrase(ld); + if (err) + return err; + return NULL; +} - ld->encrypted_hsm = true; - free(passwd); +/* 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) +{ + /* Read passphrase from user */ + char *err = read_hsm_passphrase(ld); + if (err) + return err; return NULL; } @@ -1550,9 +1582,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, @@ -1861,5 +1896,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/plugins/Makefile b/plugins/Makefile index 4c5cc84b4bb5..c15a70c78c72 100644 --- a/plugins/Makefile +++ b/plugins/Makefile @@ -237,7 +237,7 @@ plugins/topology: common/route.o common/dijkstra.o common/gossmap.o common/scidd plugins/txprepare: $(PLUGIN_TXPREPARE_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) -plugins/exposesecret: $(PLUGIN_EXPOSESECRET_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) common/hsm_encryption.o common/codex32.o +plugins/exposesecret: $(PLUGIN_EXPOSESECRET_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) common/hsm_secret.o common/codex32.o plugins/bcli: $(PLUGIN_BCLI_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) diff --git a/plugins/exposesecret.c b/plugins/exposesecret.c index ea50fa03ce01..3c90fd627027 100644 --- a/plugins/exposesecret.c +++ b/plugins/exposesecret.c @@ -2,13 +2,17 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include +#include #include #include @@ -46,8 +50,9 @@ static struct command_result *json_exposesecret(struct command *cmd, const struct exposesecret *exposesecret = exposesecret_data(cmd->plugin); struct json_stream *js; u8 *contents; - const char *id, *passphrase, *err; - struct secret hsm_secret; + const char *id, *passphrase; + enum hsm_secret_error err; + struct hsm_secret *hsms; struct privkey node_privkey; struct pubkey node_id; char *bip93; @@ -71,17 +76,30 @@ static struct command_result *json_exposesecret(struct command *cmd, return command_fail(cmd, LIGHTNINGD, "Could not open hsm_secret: %s", strerror(errno)); /* grab_file adds a \0 byte at the end for convenience */ - if (tal_bytelen(contents) == sizeof(hsm_secret) + 1) { - memcpy(&hsm_secret, contents, sizeof(hsm_secret)); - } else { - return command_fail(cmd, LIGHTNINGD, "Not a valid hsm_secret file? Bad length (maybe encrypted?)"); + if (tal_bytelen(contents) > 0) + tal_resize(&contents, tal_bytelen(contents) - 1); + + /* Check if the HSM secret needs a passphrase */ + if (hsm_secret_needs_passphrase(contents, tal_bytelen(contents))) { + plugin_log(cmd->plugin, LOG_INFORM, "Secret with passphrase is not supported"); + return command_fail(cmd, LIGHTNINGD, "Secret with passphrase is not supported"); } + + /* Extract the HSM secret without passphrase */ + tal_wally_start(); + hsms = extract_hsm_secret(tmpctx, contents, tal_bytelen(contents), NULL, &err); + tal_wally_end(tmpctx); + + if (!hsms) + return command_fail(cmd, LIGHTNINGD, "Could not parse hsm_secret: %s", hsm_secret_error_str(err)); + + plugin_log(cmd->plugin, LOG_INFORM, "hsms->type: %d", hsms->type); /* Before we expose it, check it's correct! */ hkdf_sha256(&node_privkey, sizeof(node_privkey), &salt, sizeof(salt), - &hsm_secret, - sizeof(hsm_secret), + &hsms->secret, + sizeof(hsms->secret), "nodeid", 6); /* Should not happen! */ @@ -115,9 +133,9 @@ static struct command_result *json_exposesecret(struct command *cmd, } /* This also cannot fail! */ - err = codex32_secret_encode(tmpctx, "cl", id, 0, hsm_secret.data, 32, &bip93); - if (err) - return command_fail(cmd, LIGHTNINGD, "Unexpected failure encoding hsm_secret: %s", err); + const char *encode_err = codex32_secret_encode(tmpctx, "cl", id, 0, hsms->secret.data, 32, &bip93); + if (encode_err) + return command_fail(cmd, LIGHTNINGD, "Unexpected failure encoding hsm_secret: %s", encode_err); /* If we're just checking, stop */ if (command_check_only(cmd)) @@ -126,6 +144,8 @@ static struct command_result *json_exposesecret(struct command *cmd, js = jsonrpc_stream_success(cmd); json_add_string(js, "identifier", id); json_add_string(js, "codex32", bip93); + if (hsms->mnemonic) + json_add_string(js, "mnemonic", hsms->mnemonic); return command_finished(cmd, js); } diff --git a/tests/test_plugin.py b/tests/test_plugin.py index 761d82df2ce4..6d0e58df6586 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -12,6 +12,7 @@ check_coin_moves, first_channel_id, EXPERIMENTAL_DUAL_FUND, mine_funding_to_announce, VALGRIND ) +from tests.test_wallet import HsmTool, write_all, WAIT_TIMEOUT import ast import json @@ -4195,30 +4196,96 @@ def test_exposesecret(node_factory): l1.start() assert l1.rpc.exposesecret(passphrase='test_exposesecret') == {'codex32': 'cl10junxsd35kw6r5de5kueedxyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6mdtn5lql6p8m', - 'identifier': 'junx'} + 'identifier': 'junx'} - # Test with encrypted-hsm (fails!) - password = 'test_exposesecret' - l1.stop() - # We need to simulate a terminal to use termios in `lightningd`. - master_fd, slave_fd = os.openpty() - def write_all(fd, bytestr): - """Wrapper, since os.write can do partial writes""" - off = 0 - while off < len(bytestr): - off += os.write(fd, bytestr[off:]) - - 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 + '\n').encode("utf-8")) - l1.daemon.wait_for_log(r'Confirm hsm_secret password') - write_all(master_fd, (password + '\n').encode("utf-8")) +@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.") +def test_exposesecret_with_hsm_passphrase(node_factory): + """Test that exposesecret plugin correctly handles hsm-passphrase option""" + # Create a node with exposesecret-passphrase option + l1 = node_factory.get_node(options={ + 'exposesecret-passphrase': "test_exposesecret", + }, start=False) + + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + if os.path.exists(hsm_path): + os.remove(hsm_path) + + # Generate hsm_secret with mnemonic and passphrase using hsmtool + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + hsm_passphrase = "test_hsm_passphrase" # Any passphrase, since we expect exposesecret to fail + expected_format = "mnemonic with passphrase" + + hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + 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, f"{hsm_passphrase}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"New hsm_secret file created") + hsmtool.is_in_log(f"Format: {expected_format}") + os.close(master_fd) + os.close(slave_fd) + + # Add --hsm-passphrase option to trigger interactive prompting + l1.daemon.opts["hsm-passphrase"] = None + + # Create a pty to handle the interactive passphrase prompt + master_fd2, slave_fd2 = os.openpty() + l1.daemon.start(stdin=slave_fd2, wait_for_initialized=False) + + # Wait for the passphrase prompt and provide it + l1.daemon.wait_for_log("Enter hsm_secret passphrase:") + print(f"DEBUG: About to send passphrase: '{hsm_passphrase}'") + passphrase_bytes = f"{hsm_passphrase}\n".encode("utf-8") + print(f"DEBUG: Passphrase bytes: {passphrase_bytes}") + write_all(master_fd2, passphrase_bytes) + print("DEBUG: Passphrase sent!") + + # Wait for the node to be ready l1.daemon.wait_for_log("Server started with public key") + + os.close(master_fd2) + os.close(slave_fd2) + + # Test that exposesecret fails with mnemonic+passphrase format since it needs a passphrase + with pytest.raises(RpcError, match="Secret with passphrase is not supported"): + l1.rpc.exposesecret(passphrase="test_exposesecret") + + +@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.") +def test_exposesecret_with_mnemonic_no_passphrase(node_factory): + """Test that exposesecret plugin works correctly with mnemonic-based hsm_secret without passphrase""" + # Create a node with exposesecret-passphrase option + l1 = node_factory.get_node(options={ + 'exposesecret-passphrase': "test_exposesecret", + }, start=False) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + if os.path.exists(hsm_path): + os.remove(hsm_path) + + # Generate hsm_secret with mnemonic and no passphrase using hsmtool + mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + 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 + + # Start the daemon normally (no hsm-passphrase option needed for mnemonic without passphrase) + l1.start() + + # Test that exposesecret works correctly with mnemonic-based hsm_secret without passphrase + result = l1.rpc.exposesecret(passphrase="test_exposesecret") + assert 'codex32' in result + assert 'identifier' in result + assert 'mnemonic' in result - with pytest.raises(RpcError, match="maybe encrypted"): - l1.rpc.exposesecret(passphrase=password) def test_peer_storage(node_factory, bitcoind): diff --git a/tests/test_wallet.py b/tests/test_wallet.py index 63ddba2f1abe..80f1b03a05ce 100644 --- a/tests/test_wallet.py +++ b/tests/test_wallet.py @@ -1245,61 +1245,6 @@ def write_all(fd, bytestr): while off < len(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): @@ -1311,47 +1256,23 @@ def __init__(self, directory, *args): @unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.") def test_hsmtool_secret_decryption(node_factory): - l1 = node_factory.get_node() - password = "reckless123#{ù}\n" + """Test that we can encrypt and decrypt hsm_secret using hsmtool""" + l1 = node_factory.get_node(start=False) # Don't start the node + password = "test_password\n" hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") - # We need to simulate a terminal to use termios in `lightningd`. - master_fd, slave_fd = os.openpty() - - # Encrypt the master seed - 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") - node_id = l1.rpc.getinfo()["id"] - l1.stop() - - # We can't use a wrong password ! - master_fd, slave_fd = os.openpty() - hsmtool = HsmTool(node_factory.directory, "decrypt", hsm_path) - hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Enter hsm_secret password:") - write_all(master_fd, "A wrong pass\n\n".encode("utf-8")) - hsmtool.proc.wait(WAIT_TIMEOUT) - hsmtool.is_in_log(r"Wrong password") - - # Decrypt it with hsmtool - master_fd, slave_fd = os.openpty() - hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Enter hsm_secret password:") - write_all(master_fd, password.encode("utf-8")) - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - - # Then test we can now start it without password - l1.daemon.opts.pop("encrypted-hsm") - l1.daemon.start(stdin=slave_fd, wait_for_initialized=True) - assert node_id == l1.rpc.getinfo()["id"] - l1.stop() - - # Test we can encrypt it offline + + # Write a known 32-byte key to hsm_secret + known_secret = b'\x01' * 32 # 32 bytes of 0x01 + with open(hsm_path, 'wb') as f: + f.write(known_secret) + + # Read the hsm_secret to verify it's what we expect + with open(hsm_path, 'rb') as f: + content = f.read() + assert content == known_secret, f"Expected {known_secret}, got {content}" + assert len(content) == 32, f"Expected 32 bytes, got {len(content)}" + + # Encrypt it using hsmtool master_fd, slave_fd = os.openpty() hsmtool = HsmTool(node_factory.directory, "encrypt", hsm_path) hsmtool.start(stdin=slave_fd) @@ -1360,51 +1281,28 @@ def test_hsmtool_secret_decryption(node_factory): hsmtool.wait_for_log(r"Confirm hsm_secret password:") write_all(master_fd, password.encode("utf-8")) assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - # Now we need to pass the encrypted-hsm startup option - l1.stop() - with pytest.raises(subprocess.CalledProcessError, match=r'returned non-zero exit status {}'.format(HSM_ERROR_IS_ENCRYPT)): - subprocess.check_call(l1.daemon.cmd_line) - - l1.daemon.opts.update({"encrypted-hsm": None}) - master_fd, slave_fd = os.openpty() - 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") - print(node_id, l1.rpc.getinfo()["id"]) - assert node_id == l1.rpc.getinfo()["id"] - l1.stop() - - # And finally test that we can also decrypt if encrypted with hsmtool + hsmtool.is_in_log(r"Successfully encrypted") + + # Read the hsm_secret again - it should now be encrypted (73 bytes) + with open(hsm_path, 'rb') as f: + encrypted_content = f.read() + assert len(encrypted_content) == 73, f"Expected 73 bytes after encryption, got {len(encrypted_content)}" + assert encrypted_content != known_secret, "File should be encrypted and different from original" + + # Decrypt it using hsmtool master_fd, slave_fd = os.openpty() hsmtool = HsmTool(node_factory.directory, "decrypt", hsm_path) hsmtool.start(stdin=slave_fd) hsmtool.wait_for_log(r"Enter hsm_secret password:") write_all(master_fd, password.encode("utf-8")) assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - l1.daemon.opts.pop("encrypted-hsm") - l1.daemon.start(stdin=slave_fd, wait_for_initialized=True) - assert node_id == l1.rpc.getinfo()["id"] - - # We can roundtrip encryption and decryption using a password provided - # through stdin. - hsmtool = HsmTool(node_factory.directory, "encrypt", hsm_path) - hsmtool.start(stdin=subprocess.PIPE) - hsmtool.proc.stdin.write(password.encode("utf-8")) - hsmtool.proc.stdin.write(password.encode("utf-8")) - hsmtool.proc.stdin.flush() - hsmtool.wait_for_log("Successfully encrypted") - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - - master_fd, slave_fd = os.openpty() - hsmtool = HsmTool(node_factory.directory, "decrypt", hsm_path) - hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log("Enter hsm_secret password:") - write_all(master_fd, password.encode("utf-8")) - hsmtool.wait_for_log("Successfully decrypted") - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"Successfully decrypted") + + # Read the hsm_secret again - it should now be back to the original 32 bytes + with open(hsm_path, 'rb') as f: + decrypted_content = f.read() + assert decrypted_content == known_secret, f"Expected {known_secret}, got {decrypted_content}" + assert len(decrypted_content) == 32, f"Expected 32 bytes after decryption, got {len(decrypted_content)}" @unittest.skipIf(TEST_NETWORK == 'liquid-regtest', '') @@ -1444,102 +1342,360 @@ def test_hsmtool_dump_descriptors(node_factory, bitcoind): actual_index = len(cln_addrs) - 1 + index_offset res = bitcoind.rpc.scantxoutset("start", [{"desc": descriptor, "range": [actual_index, actual_index]}]) assert res["total_amount"] == Decimal('0.00001000') + + # Explicitly stop the node to avoid race conditions during cleanup + l1.stop() + +@pytest.mark.parametrize("mnemonic,passphrase,expected_format", [ + ("ritual idle hat sunny universe pluck key alpha wing cake have wedding", "test_passphrase", "mnemonic with passphrase"), + ("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", "", "mnemonic without passphrase"), +]) +def test_hsmtool_generatehsm_variants(node_factory, mnemonic, passphrase, expected_format): + """Test generating mnemonic-based hsm_secret with various configurations""" + # Only set hsm-passphrase option if there's actually a passphrase + node_options = {'hsm-passphrase': None} if passphrase else {} + l1 = node_factory.get_node(start=False, options=node_options) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + os.remove(hsm_path) # Remove the auto-generated one + + # Generate hsm_secret with mnemonic and passphrase + hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + master_fd, slave_fd = os.openpty() + hsmtool.start(stdin=slave_fd) + hsmtool.wait_for_log(r"Introduce your BIP39 word list separated by space") + write_all(master_fd, f"{mnemonic}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Enter your passphrase:") + write_all(master_fd, f"{passphrase}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"New hsm_secret file created") + hsmtool.is_in_log(f"Format: {expected_format}") + + # Verify file format + with open(hsm_path, 'rb') as f: + content = f.read() + if passphrase: + # First 32 bytes should NOT be zeros (has passphrase hash) + assert content[:32] != b'\x00' * 32 + assert mnemonic.encode('utf-8') in content[32:] + else: + # First 32 bytes should be zeros (no passphrase) + assert content[:32] == b'\x00' * 32 + # Rest should be the mnemonic + 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}") + + l1.stop() -def test_hsmtool_generatehsm(node_factory): +@pytest.mark.parametrize("test_case", [ + pytest.param({ + "name": "with_passphrase", + "mnemonic": "ritual idle hat sunny universe pluck key alpha wing cake have wedding", + "passphrase": "secret_passphrase", + "check_passphrase": "secret_passphrase", + "check_mnemonic": "ritual idle hat sunny universe pluck key alpha wing cake have wedding", + "expected_exit": 0, + "expected_log": "OK" + }, id="correct_mnemonic_with_passphrase"), + pytest.param({ + "name": "no_passphrase", + "mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "passphrase": "", + "check_passphrase": "", + "check_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "expected_exit": 0, + "expected_log": "OK" + }, id="correct_mnemonic_no_passphrase"), + pytest.param({ + "name": "wrong_passphrase", + "mnemonic": "ritual idle hat sunny universe pluck key alpha wing cake have wedding", + "passphrase": "correct_passphrase", + "check_passphrase": "wrong_passphrase", + "check_mnemonic": "ritual idle hat sunny universe pluck key alpha wing cake have wedding", + "expected_exit": 5, # ERROR_KEYDERIV + "expected_log": "resulting hsm_secret did not match" + }, id="wrong_passphrase_should_fail"), + pytest.param({ + "name": "wrong_mnemonic", + "mnemonic": "ritual idle hat sunny universe pluck key alpha wing cake have wedding", + "passphrase": "", + "check_passphrase": "", + "check_mnemonic": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", + "expected_exit": 5, # ERROR_KEYDERIV + "expected_log": "resulting hsm_secret did not match" + }, id="wrong_mnemonic_should_fail") +]) +def test_hsmtool_checkhsm_variants(node_factory, test_case): + """Test checkhsm with various configurations""" l1 = node_factory.get_node(start=False) - hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, - "hsm_secret") + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + os.remove(hsm_path) + # Create hsm_secret with known mnemonic and passphrase hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + master_fd, slave_fd = os.openpty() + hsmtool.start(stdin=slave_fd) + hsmtool.wait_for_log(r"Introduce your BIP39 word list separated by space") + write_all(master_fd, f"{test_case['mnemonic']}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Enter your passphrase:") + write_all(master_fd, f"{test_case['passphrase']}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - # You cannot re-generate an already existing hsm_secret + # Test checkhsm with credentials + hsmtool = HsmTool(node_factory.directory, "checkhsm", hsm_path) master_fd, slave_fd = os.openpty() hsmtool.start(stdin=slave_fd) - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 2 + + # If the original had a passphrase, we need to unlock the file first + if test_case['passphrase']: + hsmtool.wait_for_log(r"Enter hsm_secret password:") # Decrypt file + write_all(master_fd, f"{test_case['passphrase']}\n".encode("utf-8")) + + hsmtool.wait_for_log(r"Enter your mnemonic passphrase:") # Backup verification + write_all(master_fd, f"{test_case['check_passphrase']}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Introduce your BIP39 word list separated by space") + write_all(master_fd, f"{test_case['check_mnemonic']}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == test_case['expected_exit'] + hsmtool.is_in_log(test_case['expected_log']) + + +def test_hsmtool_checkhsm_legacy_encrypted_with_mnemonic_no_passphrase(node_factory): + """Test checkhsm with legacy encrypted hsm_secret containing mnemonic without passphrase""" + l1 = node_factory.get_node(start=False) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") os.remove(hsm_path) - - # We can generate a valid hsm_secret from a wordlist and a "passphrase" + seed_hex = "31bb58d1180831868fd5f562bb74659dca1e9673d034af635df53d677b9e5f03" + seed_bytes = bytes.fromhex(seed_hex) + + # Write the 32-byte seed directly to file (simulating old generatehsm output) + # Make sure we write exactly 32 bytes with no newline + assert len(seed_bytes) == 32, f"Seed should be exactly 32 bytes, got {len(seed_bytes)}" + with open(hsm_path, 'wb') as f: + f.write(seed_bytes) + + # Verify it's exactly 32 bytes + with open(hsm_path, 'rb') as f: + content = f.read() + print(content) + assert content == seed_bytes, "File content doesn't match expected seed" + + # Now encrypt it using the legacy encrypt command + encryption_password = "encryption_password" + hsmtool = HsmTool(node_factory.directory, "encrypt", hsm_path) master_fd, slave_fd = os.openpty() hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Select your language:") - write_all(master_fd, "0\n".encode("utf-8")) - hsmtool.wait_for_log(r"Introduce your BIP39 word list") - write_all(master_fd, "ritual idle hat sunny universe pluck key alpha wing " - "cake have wedding\n".encode("utf-8")) - hsmtool.wait_for_log(r"Enter your passphrase:") - write_all(master_fd, "This is actually not a passphrase\n".encode("utf-8")) - if hsmtool.proc.wait(WAIT_TIMEOUT) != 0: - hsmtool.logs_catchup() - print("hsmtool failure! Logs:") - for l in hsmtool.logs: - print(' ' + l) - assert False - hsmtool.is_in_log(r"New hsm_secret file created") + hsmtool.wait_for_log(r"Enter hsm_secret password:") + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Confirm hsm_secret password:") + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"Successfully encrypted") - # Check should pass. + # Verify the file is now encrypted (73 bytes) + with open(hsm_path, 'rb') as f: + content = f.read() + assert len(content) == 73, f"Expected 73 bytes after encryption, got {len(content)}" + + # Test checkhsm - should prompt for encryption password first, then mnemonic passphrase hsmtool = HsmTool(node_factory.directory, "checkhsm", hsm_path) master_fd, slave_fd = os.openpty() hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Enter your passphrase:") - write_all(master_fd, "This is actually not a passphrase\n".encode("utf-8")) - hsmtool.wait_for_log(r"Select your language:") - write_all(master_fd, "0\n".encode("utf-8")) - hsmtool.wait_for_log(r"Introduce your BIP39 word list") - write_all(master_fd, "ritual idle hat sunny universe pluck key alpha wing " - "cake have wedding\n".encode("utf-8")) + hsmtool.wait_for_log(r"Enter hsm_secret password:") # Encryption password + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Enter your mnemonic passphrase:") # Mnemonic passphrase (empty) + write_all(master_fd, "\n".encode("utf-8")) # Empty passphrase + hsmtool.wait_for_log(r"Introduce your BIP39 word list separated by space") + write_all(master_fd, "blame expire peanut sell door zoo bundle motor truth outside artist siren\n".encode("utf-8")) assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 hsmtool.is_in_log(r"OK") - # Wrong mnemonic will fail. + +def test_hsmtool_checkhsm_legacy_encrypted_with_mnemonic_passphrase(node_factory): + """Test checkhsm with legacy encrypted hsm_secret containing mnemonic with passphrase""" + l1 = node_factory.get_node(start=False) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + os.remove(hsm_path) + + # Directly write the 32-byte seed from mnemonic with passphrase + # Mnemonic: "blame expire peanut sell door zoo bundle motor truth outside artist siren" + # Passphrase: "passphrase" + # Expected BIP39 seed (first 32 bytes): 161d740bcfd3c5e2a1769159bee86868ab35e7544e83e825042a43b929ad950c + seed_hex = "161d740bcfd3c5e2a1769159bee86868ab35e7544e83e825042a43b929ad950c" + seed_bytes = bytes.fromhex(seed_hex) + + # Write the 32-byte seed directly to file (simulating old generatehsm output) + with open(hsm_path, 'wb') as f: + f.write(seed_bytes) + + # Verify it's 32 bytes + with open(hsm_path, 'rb') as f: + content = f.read() + assert len(content) == 32, f"Expected 32 bytes, got {len(content)}" + + # Now encrypt it using the legacy encrypt command + encryption_password = "encryption_password" + hsmtool = HsmTool(node_factory.directory, "encrypt", hsm_path) master_fd, slave_fd = os.openpty() hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Enter your passphrase:") - write_all(master_fd, "This is actually not a passphrase\n".encode("utf-8")) - hsmtool.wait_for_log(r"Select your language:") - write_all(master_fd, "0\n".encode("utf-8")) - hsmtool.wait_for_log(r"Introduce your BIP39 word list") - write_all(master_fd, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n".encode("utf-8")) - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 5 - hsmtool.is_in_log(r"resulting hsm_secret did not match") + hsmtool.wait_for_log(r"Enter hsm_secret password:") + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Confirm hsm_secret password:") + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"Successfully encrypted") + + # Verify the file is now encrypted (73 bytes) + with open(hsm_path, 'rb') as f: + content = f.read() + assert len(content) == 73, f"Expected 73 bytes after encryption, got {len(content)}" - # Wrong passphrase will fail. + # Test checkhsm - should prompt for encryption password first, then mnemonic passphrase + hsmtool = HsmTool(node_factory.directory, "checkhsm", hsm_path) master_fd, slave_fd = os.openpty() hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Enter your passphrase:") - write_all(master_fd, "This is actually not a passphrase \n".encode("utf-8")) - hsmtool.wait_for_log(r"Select your language:") - write_all(master_fd, "0\n".encode("utf-8")) - hsmtool.wait_for_log(r"Introduce your BIP39 word list") - write_all(master_fd, "ritual idle hat sunny universe pluck key alpha wing " - "cake have wedding\n".encode("utf-8")) - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 5 - hsmtool.is_in_log(r"resulting hsm_secret did not match") + hsmtool.wait_for_log(r"Enter hsm_secret password:") # Encryption password + write_all(master_fd, f"{encryption_password}\n".encode("utf-8")) + hsmtool.wait_for_log(r"Enter your mnemonic passphrase:") # Mnemonic passphrase + write_all(master_fd, "passphrase\n".encode("utf-8")) + hsmtool.wait_for_log(r"Introduce your BIP39 word list separated by space") + write_all(master_fd, "blame expire peanut sell door zoo bundle motor truth outside artist siren\n".encode("utf-8")) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 + hsmtool.is_in_log(r"OK") - # We can start the node with this hsm_secret - l1.start() - assert l1.info['id'] == '02244b73339edd004bc6dfbb953a87984c88e9e7c02ca14ef6ec593ca6be622ba7' - l1.stop() - # We can do the entire thing non-interactive! - os.remove(hsm_path) - subprocess.check_output(["tools/hsmtool", - "generatehsm", hsm_path, - "en", - "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"]) - assert open(hsm_path, "rb").read().hex() == "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc1" +def test_hsmtool_generatehsm_file_exists_error(node_factory): + """Test that generatehsm fails if file already exists""" + l1 = node_factory.get_node(start=False) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") + + # File already exists from node creation + hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + master_fd, slave_fd = os.openpty() + hsmtool.start(stdin=slave_fd) + assert hsmtool.proc.wait(WAIT_TIMEOUT) == 2 # ERROR_USAGE + hsmtool.is_in_log(r"hsm_secret file.*already exists") + - # Including passphrase +def test_hsmtool_all_commands_work_with_mnemonic_formats(node_factory): + """Test that all hsmtool commands work with mnemonic formats""" + l1 = node_factory.get_node(start=False) + hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") os.remove(hsm_path) - subprocess.check_output(["tools/hsmtool", - "generatehsm", hsm_path, - "en", - "ritual idle hat sunny universe pluck key alpha wing cake have wedding", - "This is actually not a passphrase"]) + # Create a mnemonic-based hsm_secret (no passphrase for simplicity) + hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) + 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, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\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 + + # Test various commands work with mnemonic format + test_commands = [ + (["getnodeid", hsm_path], "03653e90c1ce4660fd8505dd6d643356e93cfe202af109d382787639dd5890e87d"), + (["getcodexsecret", hsm_path, "test"], "cl10testst6cqh0wu7p5ssjyf4z4ez42ks9jlt3zneju9uuypr2hddak6tlqsghuxusm6m6azq"), + (["makerune", hsm_path], "6VkrWMI2hm2a2UTkg-EyUrrBJN0RcuPB80I1pCVkTD89MA=="), + (["dumponchaindescriptors", hsm_path], "wpkh(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*)#2jtshmk0\nsh(wpkh(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*))#u6am4was\ntr(xpub661MyMwAqRbcG9kjo3mdWQuSDbtdJzsd3K2mvifyeUMF3GhLcBAfELqjuxCvxUkYqQVe6rJ9SzmpipoUedb5MD79MJaLL8RME2A3J3Fw6Zd/0/0/*)#v9hf4756"), + ] + + for cmd_args, expected_output in test_commands: + cmd_line = ["tools/hsmtool"] + cmd_args + out = subprocess.check_output(cmd_line).decode("utf8") + actual_output = out.strip() + 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() - assert l1.info['id'] == '02244b73339edd004bc6dfbb953a87984c88e9e7c02ca14ef6ec593ca6be622ba7' + 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] + mnemonic_bytes = content[32:] + + # Find the end of the mnemonic (it might not be null-terminated) + null_pos = mnemonic_bytes.find(b'\x00') + if null_pos != -1: + mnemonic_bytes = mnemonic_bytes[:null_pos] + 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 @@ -1826,40 +1982,6 @@ def test_upgradewallet(node_factory, bitcoind): upgrade = l1.rpc.upgradewallet(feerate="urgent", reservedok=True) assert upgrade['upgraded_outs'] == 0 - -def test_hsmtool_makerune(node_factory): - """Test we can make a valid rune before the node really exists""" - l1 = node_factory.get_node(start=False, options={ - 'allow-deprecated-apis': True, - }) - - # get_node() creates a secret, but in usual case we generate one. - hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret") - os.remove(hsm_path) - - hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path) - master_fd, slave_fd = os.openpty() - hsmtool.start(stdin=slave_fd) - hsmtool.wait_for_log(r"Select your language:") - write_all(master_fd, "0\n".encode("utf-8")) - hsmtool.wait_for_log(r"Introduce your BIP39 word list") - write_all(master_fd, "ritual idle hat sunny universe pluck key alpha wing " - "cake have wedding\n".encode("utf-8")) - hsmtool.wait_for_log(r"Enter your passphrase:") - write_all(master_fd, "This is actually not a passphrase\n".encode("utf-8")) - assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0 - hsmtool.is_in_log(r"New hsm_secret file created") - - cmd_line = ["tools/hsmtool", "makerune", hsm_path] - out = subprocess.check_output(cmd_line).decode("utf8").split("\n")[0] - - l1.start() - - # We have to generate a rune now, for commando to even start processing! - rune = l1.rpc.createrune()['rune'] - assert rune == out - - def test_hsmtool_getnodeid(node_factory): l1 = node_factory.get_node() diff --git a/tools/Makefile b/tools/Makefile index dc8e2a5de65d..a952aca03a17 100644 --- a/tools/Makefile +++ b/tools/Makefile @@ -18,7 +18,7 @@ tools/headerversions: $(FORCE) tools/headerversions.o libccan.a tools/headerversions.o: ccan/config.h tools/check-bolt: tools/check-bolt.o $(TOOLS_COMMON_OBJS) -tools/hsmtool: tools/hsmtool.o $(TOOLS_COMMON_OBJS) $(BITCOIN_OBJS) common/amount.o common/autodata.o common/bech32.o common/bech32_util.o common/bigsize.o common/codex32.o common/configdir.o common/configvar.o common/derive_basepoints.o common/descriptor_checksum.o common/hsm_encryption.o common/node_id.o common/version.o wire/fromwire.o wire/towire.o +tools/hsmtool: tools/hsmtool.o $(TOOLS_COMMON_OBJS) $(BITCOIN_OBJS) common/amount.o common/autodata.o common/bech32.o common/bech32_util.o common/bigsize.o common/codex32.o common/configdir.o common/configvar.o common/derive_basepoints.o common/descriptor_checksum.o common/hsm_secret.o common/node_id.o common/version.o wire/fromwire.o wire/towire.o tools/lightning-hsmtool: tools/hsmtool cp $< $@ diff --git a/tools/hsmtool.c b/tools/hsmtool.c index 94ee25fdbba1..7cc857501b91 100644 --- a/tools/hsmtool.c +++ b/tools/hsmtool.c @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -16,12 +17,13 @@ #include #include #include -#include +#include #include #include #include #include #include +#include #include #include #include @@ -37,13 +39,11 @@ static void show_usage(const char *progname) { printf("%s [arguments]\n", progname); printf("methods:\n"); - printf(" - decrypt \n"); - printf(" - encrypt \n"); - printf(" - dumpcommitments " - "\n"); - printf(" - guesstoremote " - "\n"); - printf(" - generatehsm [ []]\n"); + printf(" - decrypt [LEGACY - binary format only]\n"); + printf(" - encrypt [LEGACY - binary format only]\n"); + printf(" - dumpcommitments \n"); + printf(" - guesstoremote \n"); + printf(" - generatehsm \n"); printf(" - checkhsm \n"); printf(" - dumponchaindescriptors [--show-secrets] [network]\n"); printf(" - makerune \n"); @@ -53,6 +53,23 @@ static void show_usage(const char *progname) exit(0); } +static const char *format_type_name(enum hsm_secret_type type) +{ + switch (type) { + case HSM_SECRET_PLAIN: + return "plain (32-byte binary)"; + case HSM_SECRET_ENCRYPTED: + return "encrypted (73-byte binary)"; + case HSM_SECRET_MNEMONIC_NO_PASS: + return "mnemonic (no password)"; + case HSM_SECRET_MNEMONIC_WITH_PASS: + return "mnemonic (with password)"; + case HSM_SECRET_INVALID: + return "invalid"; + } + return "unknown"; +} + static bool ensure_hsm_secret_exists(int fd, const char *path) { const char *config_dir = path_dirname(NULL, path); @@ -75,161 +92,71 @@ static bool ensure_hsm_secret_exists(int fd, const char *path) tal_free(config_dir); return true; } - -static void grab_hsm_file(const char *hsm_secret_path, - void *dst, size_t dstlen) +/* Load hsm_secret using the unified interface */ +static struct hsm_secret *load_hsm_secret(const tal_t *ctx, const char *hsm_secret_path) { u8 *contents = grab_file(tmpctx, hsm_secret_path); + const char *passphrase = NULL; + struct hsm_secret *hsms; + enum hsm_secret_error err; + if (!contents) errx(EXITCODE_ERROR_HSM_FILE, "Reading hsm_secret"); - /* grab_file always appends a NUL char for convenience */ - if (tal_bytelen(contents) != dstlen + 1) - errx(EXITCODE_ERROR_HSM_FILE, - "hsm_secret invalid length %zu (expected %zu)", - tal_bytelen(contents)-1, dstlen); - memcpy(dst, contents, dstlen); -} - -static void get_unencrypted_hsm_secret(struct secret *hsm_secret, - const char *hsm_secret_path) -{ - grab_hsm_file(hsm_secret_path, hsm_secret, sizeof(*hsm_secret)); -} - -/* Derive the encryption key from the password provided, and try to decrypt - * the cipher. */ -static void get_encrypted_hsm_secret(struct secret *hsm_secret, - const char *hsm_secret_path, - const char *passwd) -{ - struct secret key; - struct encrypted_hsm_secret encrypted_secret; - const char *err; - int exit_code; + /* Remove the NUL terminator that grab_file adds */ + tal_resize(&contents, tal_bytelen(contents) - 1); - grab_hsm_file(hsm_secret_path, - &encrypted_secret, sizeof(encrypted_secret)); + /* Get passphrase if needed */ + if (hsm_secret_needs_passphrase(contents, tal_bytelen(contents))) { + printf("Enter hsm_secret password:\n"); + fflush(stdout); + passphrase = read_stdin_pass(tmpctx, &err); + if (!passphrase) + errx(EXITCODE_ERROR_HSM_FILE, "Could not read password: %s", hsm_secret_error_str(err)); + } - exit_code = hsm_secret_encryption_key_with_exitcode(passwd, &key, &err); - if (exit_code > 0) - errx(exit_code, "%s", err); - if (!decrypt_hsm_secret(&key, &encrypted_secret, hsm_secret)) - errx(ERROR_LIBSODIUM, "Could not retrieve the seed. Wrong password ?"); + hsms = extract_hsm_secret(ctx, contents, tal_bytelen(contents), passphrase, &err); + if (!hsms) { + errx(EXITCODE_ERROR_HSM_FILE, "%s", hsm_secret_error_str(err)); + } + return hsms; } -/* Taken from hsmd. */ -static void get_channel_seed(struct secret *channel_seed, struct node_id *peer_id, - u64 dbid, struct secret *hsm_secret) +/* Legacy function - only works with binary encrypted format */ +static int decrypt_hsm(const char *hsm_secret_path) { - struct secret channel_base; - u8 input[sizeof(peer_id->k) + sizeof(dbid)]; - /*~ Again, "per-peer" should be "per-channel", but Hysterical Raisins */ - const char *info = "per-peer seed"; + int fd; + struct hsm_secret *hsms; + const char *dir, *backup; - /*~ We use the DER encoding of the pubkey, because it's platform - * independent. Since the dbid is unique, however, it's completely - * unnecessary, but again, existing users can't be broken. */ - /* FIXME: lnd has a nicer BIP32 method for deriving secrets which we - * should migrate to. */ - hkdf_sha256(&channel_base, sizeof(struct secret), NULL, 0, - hsm_secret, sizeof(*hsm_secret), - /*~ Initially, we didn't support multiple channels per - * peer at all: a channel had to be completely forgotten - * before another could exist. That was slightly relaxed, - * but the phrase "peer seed" is wired into the seed - * generation here, so we need to keep it that way for - * existing clients, rather than using "channel seed". */ - "peer seed", strlen("peer seed")); - memcpy(input, peer_id->k, sizeof(peer_id->k)); - BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); - /*~ For all that talk about platform-independence, note that this - * field is endian-dependent! But let's face it, little-endian won. - * In related news, we don't support EBCDIC or middle-endian. */ - memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); + /* Check if it's a format we can decrypt */ + u8 *contents = grab_file(tmpctx, hsm_secret_path); + if (!contents) + errx(EXITCODE_ERROR_HSM_FILE, "Reading hsm_secret"); - hkdf_sha256(channel_seed, sizeof(*channel_seed), - input, sizeof(input), - &channel_base, sizeof(channel_base), - info, strlen(info)); -} + tal_resize(&contents, tal_bytelen(contents) - 1); + enum hsm_secret_type type = detect_hsm_secret_type(contents, tal_bytelen(contents)); -/* We detect an encrypted hsm_secret as a hsm_secret which is 73-bytes long. */ -static bool hsm_secret_is_encrypted(const char *hsm_secret_path) -{ - switch (is_hsm_secret_encrypted(hsm_secret_path)) { - case -1: - err(EXITCODE_ERROR_HSM_FILE, "Cannot open '%s'", hsm_secret_path); - case 1: - return true; - case 0: { - /* Extra sanity check on HSM file! */ - struct stat st; - stat(hsm_secret_path, &st); - if (st.st_size != 32) - errx(EXITCODE_ERROR_HSM_FILE, - "Invalid hsm_secret '%s' (neither plaintext " - "nor encrypted).", hsm_secret_path); - return false; - } + if (type != HSM_SECRET_ENCRYPTED) { + errx(ERROR_USAGE, "decrypt command only works on legacy encrypted binary format (73 bytes).\n" + "Current file is: %s\n" + "For mnemonic formats, use the generatehsm command to create a new hsm_secret instead.", + format_type_name(type)); } - abort(); -} - -/* If encrypted, ask for a passphrase */ -static void get_hsm_secret(struct secret *hsm_secret, - const char *hsm_secret_path) -{ - /* This checks the file existence, too. */ - if (hsm_secret_is_encrypted(hsm_secret_path)) { - int exit_code; - char *passwd; - const char *err; - - printf("Enter hsm_secret password:\n"); - fflush(stdout); - passwd = read_stdin_pass_with_exit_code(&err, &exit_code); - if (!passwd) - errx(exit_code, "%s", err); - get_encrypted_hsm_secret(hsm_secret, hsm_secret_path, passwd); - free(passwd); - } else { - get_unencrypted_hsm_secret(hsm_secret, hsm_secret_path); - } -} - -static int decrypt_hsm(const char *hsm_secret_path) -{ - int fd; - struct secret hsm_secret; - char *passwd; - const char *dir, *backup, *err; - int exit_code = 0; - /* This checks the file existence, too. */ - if (!hsm_secret_is_encrypted(hsm_secret_path)) - errx(ERROR_USAGE, "hsm_secret is not encrypted"); - printf("Enter hsm_secret password:\n"); - fflush(stdout); - passwd = read_stdin_pass_with_exit_code(&err, &exit_code); - if (!passwd) - errx(exit_code, "%s", err); + /* Load the hsm_secret */ + hsms = load_hsm_secret(tmpctx, hsm_secret_path); dir = path_dirname(NULL, hsm_secret_path); backup = path_join(dir, dir, "hsm_secret.backup"); - get_encrypted_hsm_secret(&hsm_secret, hsm_secret_path, passwd); - /* Once the encryption key derived, we don't need it anymore. */ - if (passwd) - free(passwd); - /* Create a backup file, "just in case". */ rename(hsm_secret_path, backup); fd = open(hsm_secret_path, O_CREAT|O_EXCL|O_WRONLY, 0400); if (fd < 0) errx(EXITCODE_ERROR_HSM_FILE, "Could not open new hsm_secret"); - if (!write_all(fd, &hsm_secret, sizeof(hsm_secret))) { + if (!write_all(fd, &hsms->secret, sizeof(hsms->secret))) { unlink_noerr(hsm_secret_path); close(fd); rename("hsm_secret.backup", hsm_secret_path); @@ -251,85 +178,62 @@ static int decrypt_hsm(const char *hsm_secret_path) return 0; } -static int make_codexsecret(const char *hsm_secret_path, - const char *id) +/* Legacy function - only works with binary plain format */ +static int encrypt_hsm(const char *hsm_secret_path) { - struct secret hsm_secret; - char *bip93; - const char *err; - get_hsm_secret(&hsm_secret, hsm_secret_path); + int fd; + struct hsm_secret *hsms; + u8 encrypted_hsm_secret[ENCRYPTED_HSM_SECRET_LEN]; + const char *passwd, *passwd_confirmation; + const char *dir, *backup; + enum hsm_secret_error pass_err; - err = codex32_secret_encode(tmpctx, "cl", id, 0, hsm_secret.data, 32, &bip93); - if (err) - errx(ERROR_USAGE, "%s", err); + /* Check if it's a format we can encrypt */ + u8 *contents = grab_file(tmpctx, hsm_secret_path); + if (!contents) + errx(EXITCODE_ERROR_HSM_FILE, "Reading hsm_secret"); - printf("%s\n", bip93); - return 0; -} + tal_resize(&contents, tal_bytelen(contents) - 1); + enum hsm_secret_type type = detect_hsm_secret_type(contents, tal_bytelen(contents)); -static int getemergencyrecover(const char *emer_rec_path) -{ - u8 *scb = grab_file(tmpctx, emer_rec_path); - char *output, *hrp = "clnemerg"; - if (!scb) { - errx(EXITCODE_ERROR_HSM_FILE, "Reading emergency.recover"); - } else { - /* grab_file adds nul term */ - tal_resize(&scb, tal_bytelen(scb) - 1); + if (type != HSM_SECRET_PLAIN) { + errx(ERROR_USAGE, "encrypt command only works on legacy plain binary format (32 bytes).\n" + "Current file is: %s\n" + "For mnemonic formats, the passphrase is already integrated into the format.", + format_type_name(type)); } - u5 *data = tal_arr(tmpctx, u5, 0); - - bech32_push_bits(&data, scb, tal_bytelen(scb) * 8); - output = tal_arr(tmpctx, char, strlen(hrp) + tal_count(data) + 8); - - bech32_encode(output, hrp, data, tal_count(data), (size_t)-1, - BECH32_ENCODING_BECH32); - - printf("%s\n", output); - return 0; -} - -static int encrypt_hsm(const char *hsm_secret_path) -{ - int fd; - struct secret key, hsm_secret; - struct encrypted_hsm_secret encrypted_hsm_secret; - char *passwd, *passwd_confirmation; - const char *err, *dir, *backup; - int exit_code = 0; - /* This checks the file existence, too. */ - if (hsm_secret_is_encrypted(hsm_secret_path)) - errx(ERROR_USAGE, "hsm_secret is already encrypted"); + /* Load the hsm_secret */ + hsms = load_hsm_secret(tmpctx, hsm_secret_path); printf("Enter hsm_secret password:\n"); fflush(stdout); - passwd = read_stdin_pass_with_exit_code(&err, &exit_code); + passwd = read_stdin_pass(tmpctx, &pass_err); if (!passwd) - errx(exit_code, "%s", err); + errx(EXITCODE_ERROR_HSM_FILE, "Could not read password: %s", hsm_secret_error_str(pass_err)); + printf("Confirm hsm_secret password:\n"); fflush(stdout); - passwd_confirmation = read_stdin_pass_with_exit_code(&err, &exit_code); + passwd_confirmation = read_stdin_pass(tmpctx, &pass_err); if (!passwd_confirmation) - errx(exit_code, "%s", err); + errx(EXITCODE_ERROR_HSM_FILE, "Could not read password: %s", hsm_secret_error_str(pass_err)); + if (!streq(passwd, passwd_confirmation)) errx(ERROR_USAGE, "Passwords confirmation mismatch."); - get_unencrypted_hsm_secret(&hsm_secret, hsm_secret_path); dir = path_dirname(NULL, hsm_secret_path); backup = path_join(dir, dir, "hsm_secret.backup"); - /* Derive the encryption key from the password provided, and try to encrypt - * the seed. */ - exit_code = hsm_secret_encryption_key_with_exitcode(passwd, &key, &err); - if (exit_code > 0) - errx(exit_code, "%s", err); - if (!encrypt_hsm_secret(&key, &hsm_secret, &encrypted_hsm_secret)) + /* Create encryption key and encrypt */ + struct secret *encryption_key = get_encryption_key(tmpctx, passwd); + if (!encryption_key) + errx(ERROR_LIBSODIUM, "Could not derive encryption key"); + + if (!encrypt_legacy_hsm_secret(encryption_key, &hsms->secret, encrypted_hsm_secret)) errx(ERROR_LIBSODIUM, "Could not encrypt the hsm_secret seed."); - /* Once the encryption key derived, we don't need it anymore. */ - free(passwd); - free(passwd_confirmation); + /* Securely discard the encryption key */ + destroy_secret(encryption_key); /* Create a backup file, "just in case". */ rename(hsm_secret_path, backup); @@ -338,8 +242,8 @@ static int encrypt_hsm(const char *hsm_secret_path) errx(EXITCODE_ERROR_HSM_FILE, "Could not open new hsm_secret"); /* Write the encrypted hsm_secret. */ - if (!write_all(fd, encrypted_hsm_secret.data, - sizeof(encrypted_hsm_secret.data))) { + if (!write_all(fd, encrypted_hsm_secret, + ENCRYPTED_HSM_SECRET_LEN)) { unlink_noerr(hsm_secret_path); close(fd); rename(backup, hsm_secret_path); @@ -360,14 +264,73 @@ static int encrypt_hsm(const char *hsm_secret_path) return 0; } +/* Taken from hsmd. */ +static void get_channel_seed(struct secret *channel_seed, struct node_id *peer_id, + u64 dbid, struct secret *hsm_secret) +{ + struct secret channel_base; + u8 input[sizeof(peer_id->k) + sizeof(dbid)]; + const char *info = "per-peer seed"; + + hkdf_sha256(&channel_base, sizeof(struct secret), NULL, 0, + hsm_secret, sizeof(*hsm_secret), + "peer seed", strlen("peer seed")); + memcpy(input, peer_id->k, sizeof(peer_id->k)); + BUILD_ASSERT(sizeof(peer_id->k) == PUBKEY_CMPR_LEN); + memcpy(input + PUBKEY_CMPR_LEN, &dbid, sizeof(dbid)); + + hkdf_sha256(channel_seed, sizeof(*channel_seed), + input, sizeof(input), + &channel_base, sizeof(channel_base), + info, strlen(info)); +} + +static int make_codexsecret(const char *hsm_secret_path, const char *id) +{ + struct secret hsm_secret; + char *bip93; + const char *err; + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; + + err = codex32_secret_encode(tmpctx, "cl", id, 0, hsm_secret.data, 32, &bip93); + if (err) + errx(ERROR_USAGE, "%s", err); + + printf("%s\n", bip93); + return 0; +} + +static int getemergencyrecover(const char *emer_rec_path) +{ + u8 *scb = grab_file(tmpctx, emer_rec_path); + char *output, *hrp = "clnemerg"; + if (!scb) { + errx(EXITCODE_ERROR_HSM_FILE, "Reading emergency.recover"); + } else { + /* grab_file adds nul term */ + tal_resize(&scb, tal_bytelen(scb) - 1); + } + u5 *data = tal_arr(tmpctx, u5, 0); + + bech32_push_bits(&data, scb, tal_bytelen(scb) * 8); + output = tal_arr(tmpctx, char, strlen(hrp) + tal_count(data) + 8); + + bech32_encode(output, hrp, data, tal_count(data), (size_t)-1, + BECH32_ENCODING_BECH32); + + printf("%s\n", output); + return 0; +} + static int dump_commitments_infos(struct node_id *node_id, u64 channel_id, u64 depth, char *hsm_secret_path) { struct sha256 shaseed; struct secret hsm_secret, channel_seed, per_commitment_secret; struct pubkey per_commitment_point; - - get_hsm_secret(&hsm_secret, hsm_secret_path); + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; get_channel_seed(&channel_seed, node_id, channel_id, &hsm_secret); derive_shaseed(&channel_seed, &shaseed); @@ -387,31 +350,13 @@ static int dump_commitments_infos(struct node_id *node_id, u64 channel_id, return 0; } -/* In case of an unilateral close from the remote side while we suffered a - * loss of data, this tries to recover the private key from the `to_remote` - * output. - * This basically iterates over every `dbid` to derive the channel_seed and - * then derives the payment basepoint to compare to the pubkey hash specified - * in the witness programm. - * Note that since a node generates the key for the to_remote output from its - * *local* per_commitment_point, there is nothing we can do if - * `option_static_remotekey` was not negotiated. - * - * :param address: The bech32 address of the v0 P2WPKH witness programm - * :param node_id: The id of the node with which the channel was established - * :param tries: How many dbids to try. - * :param hsm_secret_path: The path to the hsm_secret - * :param passwd: The *optional* hsm_secret password - */ static int guess_to_remote(const char *address, struct node_id *node_id, u64 tries, char *hsm_secret_path) { struct secret hsm_secret, channel_seed, basepoint_secret; struct pubkey basepoint; struct ripemd160 pubkeyhash; - /* We only support P2WPKH, hence 20. */ u8 goal_pubkeyhash[20]; - /* See common/bech32.h for buffer size. */ char hrp[strlen(address) - 6]; int witver; size_t witlen; @@ -422,7 +367,8 @@ static int guess_to_remote(const char *address, struct node_id *node_id, if (segwit_addr_decode(&witver, goal_pubkeyhash, &witlen, hrp, address) != 1) errx(ERROR_USAGE, "Wrong bech32 address"); - get_hsm_secret(&hsm_secret, hsm_secret_path); + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; for (u64 dbid = 1; dbid < tries ; dbid++) { get_channel_seed(&channel_seed, node_id, dbid, &hsm_secret); @@ -450,147 +396,65 @@ static int guess_to_remote(const char *address, struct node_id *node_id, return 1; } -struct wordlist_lang { - char *abbr; - char *name; -}; - -struct wordlist_lang languages[] = { - {"en", "English"}, - {"es", "Spanish"}, - {"fr", "French"}, - {"it", "Italian"}, - {"jp", "Japanese"}, - {"zhs", "Chinese Simplified"}, - {"zht", "Chinese Traditional"}, -}; - -static bool check_lang(const char *abbr) +static int generate_hsm(const char *hsm_secret_path) { - for (size_t i = 0; i < ARRAY_SIZE(languages); i++) { - if (streq(abbr, languages[i].abbr)) - return true; - } - return false; -} - -static void get_words(struct words **words) { - - printf("Select your language:\n"); - for (size_t i = 0; i < ARRAY_SIZE(languages); i++) { - printf(" %zu) %s (%s)\n", i, languages[i].name, languages[i].abbr); - } - printf("Select [0-%zu]: ", ARRAY_SIZE(languages)-1); - fflush(stdout); - - char *selected = NULL; - size_t size = 0; - size_t characters = getline(&selected, &size, stdin); - if (characters < 0) - errx(ERROR_USAGE, "Could not read line from stdin."); - - /* To distinguish success/failure after call */ - errno = 0; - char *endptr; - long val = strtol(selected, &endptr, 10); - if (errno == ERANGE || (errno != 0 && val == 0) || endptr == selected || val < 0 || val >= ARRAY_SIZE(languages)) - errx(ERROR_USAGE, "Invalid language selection, select one from the list [0-6]."); - - free(selected); - bip39_get_wordlist(languages[val].abbr, words); -} + const char *mnemonic, *passphrase; + enum hsm_secret_error err; -static char *get_mnemonic(void) { - char *line = NULL; - size_t line_size = 0; + /* Get mnemonic from user using consistent interface */ + mnemonic = read_stdin_mnemonic(tmpctx, &err); + if (!mnemonic) + errx(EXITCODE_ERROR_HSM_FILE, "Could not read mnemonic: %s", hsm_secret_error_str(err)); - printf("Introduce your BIP39 word list separated by space (at least 12 words):\n"); + /* Get optional passphrase */ + printf("Warning: remember that different passphrases yield different " + "bitcoin wallets.\n"); + printf("If left empty, no password is used (echo is disabled).\n"); + printf("Enter your passphrase: \n"); fflush(stdout); - size_t characters = getline(&line, &line_size, stdin); - if (characters < 0) - errx(ERROR_USAGE, "Could not read line from stdin."); - line[characters-1] = '\0'; - return line; -} - -static char *read_mnemonic(void) { - /* Get words for the mnemonic language */ - struct words *words; - get_words(&words); - - /* Get mnemonic */ - char *mnemonic; - mnemonic = get_mnemonic(); - - if (bip39_mnemonic_validate(words, mnemonic) != 0) { - errx(ERROR_USAGE, "Invalid mnemonic: \"%s\"", mnemonic); - } - return mnemonic; -} - -static int generate_hsm(const char *hsm_secret_path, - const char *lang_id, - char *mnemonic, - char *passphrase) -{ - const char *err; - int exit_code = 0; - - if (lang_id == NULL) { - mnemonic = read_mnemonic(); - printf("Warning: remember that different passphrases yield different " - "bitcoin wallets.\n"); - printf("If left empty, no password is used (echo is disabled).\n"); - printf("Enter your passphrase: \n"); - fflush(stdout); - passphrase = read_stdin_pass_with_exit_code(&err, &exit_code); - if (!passphrase) - errx(exit_code, "%s", err); - if (strlen(passphrase) == 0) { - free(passphrase); - passphrase = NULL; - } - } else { - struct words *words; - - bip39_get_wordlist(lang_id, &words); - - if (bip39_mnemonic_validate(words, mnemonic) != 0) - errx(ERROR_USAGE, "Invalid mnemonic: \"%s\"", mnemonic); + passphrase = read_stdin_pass(tmpctx, &err); + if (!passphrase) + errx(EXITCODE_ERROR_HSM_FILE, "Could not read passphrase: %s", hsm_secret_error_str(err)); + if (strlen(passphrase) == 0) { + passphrase = NULL; } - u8 bip32_seed[BIP39_SEED_LEN_512]; - size_t bip32_seed_len; - - if (bip39_mnemonic_to_seed(mnemonic, passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len) != WALLY_OK) - errx(ERROR_LIBWALLY, "Unable to derive BIP32 seed from BIP39 mnemonic"); - + /* Write to file using your new mnemonic format */ int fd = open(hsm_secret_path, O_CREAT|O_EXCL|O_WRONLY, 0400); if (fd < 0) { errx(ERROR_USAGE, "Unable to create hsm_secret file"); } - /* Write only the first 32 bytes, length of the (plaintext) seed in the - * hsm_secret. */ - if (!write_all(fd, bip32_seed, 32)) - errx(ERROR_USAGE, "Error writing secret to hsm_secret file"); + + /* Hash the derived seed for validation */ + struct sha256 seed_hash; + if (!derive_seed_hash(mnemonic, passphrase, &seed_hash)) + errx(ERROR_USAGE, "Error deriving seed from mnemonic"); + + /* Write seed hash (32 bytes) + mnemonic */ + if (!write_all(fd, &seed_hash, sizeof(seed_hash))) + errx(ERROR_USAGE, "Error writing seed hash to hsm_secret file"); + + /* Write the mnemonic */ + if (!write_all(fd, mnemonic, strlen(mnemonic))) + errx(ERROR_USAGE, "Error writing mnemonic to hsm_secret file"); if (fsync(fd) != 0) errx(ERROR_USAGE, "Error fsyncing hsm_secret file"); - /* This should never fail if fsync succeeded. But paranoia is good, and bugs exist */ if (close(fd) != 0) errx(ERROR_USAGE, "Error closing hsm_secret file"); printf("New hsm_secret file created at %s\n", hsm_secret_path); - printf("Use the `encrypt` command to encrypt the BIP32 seed if needed\n"); + printf("Format: %s\n", passphrase ? "mnemonic with passphrase" : "mnemonic without passphrase"); + if (passphrase) { + printf("Remember your passphrase - it's required to use this hsm_secret!\n"); + } - free(mnemonic); - free(passphrase); + /* passphrase and mnemonic will be automatically cleaned up by tmpctx */ return 0; } static int dumponchaindescriptors(const char *hsm_secret_path, - const char *old_passwd UNUSED, const u32 version, bool show_secrets) { struct secret hsm_secret; @@ -599,11 +463,9 @@ static int dumponchaindescriptors(const char *hsm_secret_path, struct ext_key master_extkey; char *enc_xkey, *descriptor; struct descriptor_checksum checksum; - - get_hsm_secret(&hsm_secret, hsm_secret_path); - - /* We use m/0/0/k as the derivation tree for onchain funds. */ - + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; + /* The root seed is derived from hsm_secret using hkdf.. */ do { hkdf_sha256(bip32_seed, sizeof(bip32_seed), @@ -651,43 +513,49 @@ static int dumponchaindescriptors(const char *hsm_secret_path, return 0; } +/* Check HSM secret by comparing with backup mnemonic */ static int check_hsm(const char *hsm_secret_path) { - char *mnemonic; - struct secret hsm_secret; + struct secret file_secret, derived_secret; u8 bip32_seed[BIP39_SEED_LEN_512]; size_t bip32_seed_len; - int exit_code; - char *passphrase; - const char *err; + const char *mnemonic_passphrase, *mnemonic; + enum hsm_secret_error err; - get_hsm_secret(&hsm_secret, hsm_secret_path); + /* Load the hsm_secret (handles decryption automatically if needed) */ + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + file_secret = hsms->secret; + /* Ask user for their backup mnemonic passphrase */ printf("Warning: remember that different passphrases yield different " "bitcoin wallets.\n"); printf("If left empty, no password is used (echo is disabled).\n"); - printf("Enter your passphrase: \n"); + printf("Enter your mnemonic passphrase: \n"); fflush(stdout); - passphrase = read_stdin_pass_with_exit_code(&err, &exit_code); - if (!passphrase) - errx(exit_code, "%s", err); - if (strlen(passphrase) == 0) { - free(passphrase); - passphrase = NULL; + mnemonic_passphrase = read_stdin_pass(tmpctx, &err); + if (!mnemonic_passphrase) + errx(EXITCODE_ERROR_HSM_FILE, "Could not read passphrase: %s", hsm_secret_error_str(err)); + if (strlen(mnemonic_passphrase) == 0) { + mnemonic_passphrase = NULL; } - mnemonic = read_mnemonic(); - if (bip39_mnemonic_to_seed(mnemonic, passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len) != WALLY_OK) + /* Ask user for their backup mnemonic using consistent interface */ + mnemonic = read_stdin_mnemonic(tmpctx, &err); + if (!mnemonic) + errx(EXITCODE_ERROR_HSM_FILE, "Could not read mnemonic: %s", hsm_secret_error_str(err)); + + /* Derive seed from user's backup mnemonic + passphrase */ + if (bip39_mnemonic_to_seed(mnemonic, mnemonic_passphrase, bip32_seed, sizeof(bip32_seed), &bip32_seed_len) != WALLY_OK) errx(ERROR_LIBWALLY, "Unable to derive BIP32 seed from BIP39 mnemonic"); - /* We only use first 32 bytes */ - if (memcmp(bip32_seed, hsm_secret.data, sizeof(hsm_secret.data)) != 0) + /* Copy first 32 bytes to our secret for comparison */ + memcpy(derived_secret.data, bip32_seed, sizeof(derived_secret.data)); + + /* Compare the seeds */ + if (memcmp(derived_secret.data, file_secret.data, sizeof(file_secret.data)) != 0) errx(ERROR_KEYDERIV, "resulting hsm_secret did not match"); printf("OK\n"); - - free(mnemonic); - free(passphrase); return 0; } @@ -695,9 +563,8 @@ static int make_rune(const char *hsm_secret_path) { struct secret hsm_secret, derived_secret, rune_secret; struct rune *master_rune, *rune; - - /* Get hsm_secret */ - get_hsm_secret(&hsm_secret, hsm_secret_path); + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; /* HSM derives a root secret for `makesecret` */ hkdf_sha256(&derived_secret, sizeof(struct secret), NULL, 0, @@ -724,9 +591,8 @@ static int get_node_id(const char *hsm_secret_path) struct secret hsm_secret; struct privkey node_privkey; struct pubkey node_id; - - /* Get hsm_secret */ - get_hsm_secret(&hsm_secret, hsm_secret_path); + struct hsm_secret *hsms = load_hsm_secret(tmpctx, hsm_secret_path); + hsm_secret = hsms->secret; /*~ So, there is apparently a 1 in 2^127 chance that a random value is * not a valid private key, so this never actually loops. */ @@ -795,12 +661,10 @@ int main(int argc, char *argv[]) } if (streq(method, "generatehsm")) { - // argv[2] file, argv[3] lang_id, argv[4] word list, argv[5] passphrase - if (argc < 3 || argc > 6 || argc == 4) + if (argc != 3) show_usage(argv[0]); char *hsm_secret_path = argv[2]; - char *lang_id, *word_list, *passphrase; /* if hsm_secret already exists we abort the process * we do not want to lose someone else's funds */ @@ -808,15 +672,7 @@ int main(int argc, char *argv[]) if (stat(hsm_secret_path, &st) == 0) errx(ERROR_USAGE, "hsm_secret file at %s already exists", hsm_secret_path); - lang_id = (argc > 3 ? argv[3] : NULL); - if (lang_id && !check_lang(lang_id)) - show_usage(argv[0]); - - /* generate_hsm expects to free these, so use strdup */ - word_list = (argc > 4 ? strdup(argv[4]) : NULL); - passphrase = (argc > 5 ? strdup(argv[5]) : NULL); - - return generate_hsm(hsm_secret_path, lang_id, word_list, passphrase); + return generate_hsm(hsm_secret_path); } if (streq(method, "dumponchaindescriptors")) { @@ -869,7 +725,7 @@ int main(int argc, char *argv[]) else version = BIP32_VER_MAIN_PRIVATE; - return dumponchaindescriptors(fname, NULL, version, show_secrets); + return dumponchaindescriptors(fname, version, show_secrets); } if (streq(method, "checkhsm")) { @@ -903,4 +759,4 @@ int main(int argc, char *argv[]) } show_usage(argv[0]); -} +} \ No newline at end of file diff --git a/wallet/test/run-wallet.c b/wallet/test/run-wallet.c index 5787f640c25f..cd13152e0789 100644 --- a/wallet/test/run-wallet.c +++ b/wallet/test/run-wallet.c @@ -347,6 +347,9 @@ bool fromwire_hsmd_get_output_scriptpubkey_reply(const tal_t *ctx UNNEEDED, cons /* 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(); } +/* 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_new_channel_reply */ bool fromwire_hsmd_new_channel_reply(const void *p UNNEEDED) { fprintf(stderr, "fromwire_hsmd_new_channel_reply called!\n"); abort(); } @@ -435,9 +438,15 @@ bool invoices_resolve(struct invoices *invoices UNNEEDED, const struct json_escape *label UNNEEDED, const struct bitcoin_outpoint *outpoint UNNEEDED) { fprintf(stderr, "invoices_resolve called!\n"); abort(); } -/* Generated stub for is_hsm_secret_encrypted */ -int is_hsm_secret_encrypted(const char *path UNNEEDED) -{ fprintf(stderr, "is_hsm_secret_encrypted called!\n"); abort(); } +/* Generated stub for is_legacy_hsm_secret_encrypted */ +int is_legacy_hsm_secret_encrypted(const char *path UNNEEDED) +{ fprintf(stderr, "is_legacy_hsm_secret_encrypted called!\n"); abort(); } +/* Generated stub for hsm_secret_error_str */ +const char *hsm_secret_error_str(enum hsm_secret_error err UNNEEDED) +{ fprintf(stderr, "hsm_secret_error_str called!\n"); abort(); } +/* Generated stub for read_stdin_pass */ +const char *read_stdin_pass(const tal_t *ctx UNNEEDED, enum hsm_secret_error *err UNNEEDED) +{ fprintf(stderr, "read_stdin_pass called!\n"); abort(); } /* Generated stub for json_add_address */ void json_add_address(struct json_stream *response UNNEEDED, const char *fieldname UNNEEDED, const struct wireaddr *addr UNNEEDED) @@ -1038,6 +1047,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 to_canonical_invstr */ const char *to_canonical_invstr(const tal_t *ctx UNNEEDED, const char *invstring UNNEEDED) { fprintf(stderr, "to_canonical_invstr called!\n"); abort(); } @@ -1141,7 +1153,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)