Skip to content

Commit aaea608

Browse files
committed
exposesecret: Add support for mnemonic-based HSM secrets
Update the exposesecret plugin to work with the new unified HSM secret format that supports BIP39 mnemonics.
1 parent f6030aa commit aaea608

File tree

5 files changed

+121
-35
lines changed

5 files changed

+121
-35
lines changed

hsmd/hsmd.c

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ static struct io_plan *init_hsm(struct io_conn *conn,
507507
const u8 *msg_in)
508508
{
509509
struct secret *hsm_encryption_key;
510-
const char *hsm_passphrase;
510+
const char *hsm_passphrase = NULL; /* Initialize to NULL */
511511
struct bip32_key_version bip32_key_version;
512512
u32 minversion, maxversion;
513513
const u32 our_minversion = 4, our_maxversion = 6;

plugins/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ plugins/topology: common/route.o common/dijkstra.o common/gossmap.o common/scidd
237237

238238
plugins/txprepare: $(PLUGIN_TXPREPARE_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS)
239239

240-
plugins/exposesecret: $(PLUGIN_EXPOSESECRET_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) common/hsm_encryption.o common/codex32.o
240+
plugins/exposesecret: $(PLUGIN_EXPOSESECRET_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS) common/hsm_secret.o common/codex32.o
241241

242242
plugins/bcli: $(PLUGIN_BCLI_OBJS) $(PLUGIN_LIB_OBJS) $(PLUGIN_COMMON_OBJS) $(JSMN_OBJS)
243243

plugins/exposesecret.c

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
#include <bitcoin/privkey.h>
33
#include <ccan/array_size/array_size.h>
44
#include <ccan/crypto/hkdf_sha256/hkdf_sha256.h>
5+
#include <common/setup.h>
6+
#include <common/utils.h>
57
#include <ccan/crypto/sha256/sha256.h>
68
#include <ccan/tal/grab_file/grab_file.h>
79
#include <ccan/tal/str/str.h>
810
#include <common/bech32.h>
911
#include <common/codex32.h>
12+
#include <common/hsm_secret.h>
1013
#include <common/json_param.h>
1114
#include <common/json_stream.h>
15+
#include <common/setup.h>
1216
#include <errno.h>
1317
#include <plugins/libplugin.h>
1418

@@ -46,8 +50,9 @@ static struct command_result *json_exposesecret(struct command *cmd,
4650
const struct exposesecret *exposesecret = exposesecret_data(cmd->plugin);
4751
struct json_stream *js;
4852
u8 *contents;
49-
const char *id, *passphrase, *err;
50-
struct secret hsm_secret;
53+
const char *id, *passphrase;
54+
enum hsm_secret_error err;
55+
struct hsm_secret *hsms;
5156
struct privkey node_privkey;
5257
struct pubkey node_id;
5358
char *bip93;
@@ -71,17 +76,30 @@ static struct command_result *json_exposesecret(struct command *cmd,
7176
return command_fail(cmd, LIGHTNINGD, "Could not open hsm_secret: %s", strerror(errno));
7277

7378
/* grab_file adds a \0 byte at the end for convenience */
74-
if (tal_bytelen(contents) == sizeof(hsm_secret) + 1) {
75-
memcpy(&hsm_secret, contents, sizeof(hsm_secret));
76-
} else {
77-
return command_fail(cmd, LIGHTNINGD, "Not a valid hsm_secret file? Bad length (maybe encrypted?)");
79+
if (tal_bytelen(contents) > 0)
80+
tal_resize(&contents, tal_bytelen(contents) - 1);
81+
82+
/* Check if the HSM secret needs a passphrase */
83+
if (hsm_secret_needs_passphrase(contents, tal_bytelen(contents))) {
84+
plugin_log(cmd->plugin, LOG_INFORM, "Secret with passphrase is not supported");
85+
return command_fail(cmd, LIGHTNINGD, "Secret with passphrase is not supported");
7886
}
87+
88+
/* Extract the HSM secret without passphrase */
89+
tal_wally_start();
90+
hsms = extract_hsm_secret(tmpctx, contents, tal_bytelen(contents), NULL, &err);
91+
tal_wally_end(tmpctx);
92+
93+
if (!hsms)
94+
return command_fail(cmd, LIGHTNINGD, "Could not parse hsm_secret: %s", hsm_secret_error_str(err));
95+
96+
plugin_log(cmd->plugin, LOG_INFORM, "hsms->type: %d", hsms->type);
7997

8098
/* Before we expose it, check it's correct! */
8199
hkdf_sha256(&node_privkey, sizeof(node_privkey),
82100
&salt, sizeof(salt),
83-
&hsm_secret,
84-
sizeof(hsm_secret),
101+
&hsms->secret,
102+
sizeof(hsms->secret),
85103
"nodeid", 6);
86104

87105
/* Should not happen! */
@@ -115,9 +133,9 @@ static struct command_result *json_exposesecret(struct command *cmd,
115133
}
116134

117135
/* This also cannot fail! */
118-
err = codex32_secret_encode(tmpctx, "cl", id, 0, hsm_secret.data, 32, &bip93);
119-
if (err)
120-
return command_fail(cmd, LIGHTNINGD, "Unexpected failure encoding hsm_secret: %s", err);
136+
const char *encode_err = codex32_secret_encode(tmpctx, "cl", id, 0, hsms->secret.data, 32, &bip93);
137+
if (encode_err)
138+
return command_fail(cmd, LIGHTNINGD, "Unexpected failure encoding hsm_secret: %s", encode_err);
121139

122140
/* If we're just checking, stop */
123141
if (command_check_only(cmd))
@@ -126,6 +144,8 @@ static struct command_result *json_exposesecret(struct command *cmd,
126144
js = jsonrpc_stream_success(cmd);
127145
json_add_string(js, "identifier", id);
128146
json_add_string(js, "codex32", bip93);
147+
if (hsms->mnemonic)
148+
json_add_string(js, "mnemonic", hsms->mnemonic);
129149
return command_finished(cmd, js);
130150
}
131151

tests/test_plugin.py

Lines changed: 87 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
check_coin_moves, first_channel_id, EXPERIMENTAL_DUAL_FUND,
1313
mine_funding_to_announce, VALGRIND
1414
)
15+
from tests.test_wallet import HsmTool, write_all, WAIT_TIMEOUT
1516

1617
import ast
1718
import json
@@ -4141,7 +4142,6 @@ def test_important_plugin_shutdown(node_factory):
41414142
l1.rpc.plugin_start(os.path.join(os.getcwd(), 'plugins/pay'))
41424143

41434144

4144-
@pytest.mark.skip(reason="Temporarily disabled expose secrets test")
41454145
@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.")
41464146
def test_exposesecret(node_factory):
41474147
l1, l2 = node_factory.get_nodes(2, opts=[{'exposesecret-passphrase': "test_exposesecret"}, {}])
@@ -4196,30 +4196,96 @@ def test_exposesecret(node_factory):
41964196
l1.start()
41974197

41984198
assert l1.rpc.exposesecret(passphrase='test_exposesecret') == {'codex32': 'cl10junxsd35kw6r5de5kueedxyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6mdtn5lql6p8m',
4199-
'identifier': 'junx'}
4199+
'identifier': 'junx'}
42004200

4201-
# Test with encrypted-hsm (fails!)
4202-
password = 'test_exposesecret'
4203-
l1.stop()
4204-
# We need to simulate a terminal to use termios in `lightningd`.
4205-
master_fd, slave_fd = os.openpty()
42064201

4207-
def write_all(fd, bytestr):
4208-
"""Wrapper, since os.write can do partial writes"""
4209-
off = 0
4210-
while off < len(bytestr):
4211-
off += os.write(fd, bytestr[off:])
4212-
4213-
l1.daemon.opts.update({"encrypted-hsm": None})
4214-
l1.daemon.start(stdin=slave_fd, wait_for_initialized=False)
4215-
l1.daemon.wait_for_log(r'Enter hsm_secret password')
4216-
write_all(master_fd, (password + '\n').encode("utf-8"))
4217-
l1.daemon.wait_for_log(r'Confirm hsm_secret password')
4218-
write_all(master_fd, (password + '\n').encode("utf-8"))
4202+
@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.")
4203+
def test_exposesecret_with_hsm_passphrase(node_factory):
4204+
"""Test that exposesecret plugin correctly handles hsm-passphrase option"""
4205+
# Create a node with exposesecret-passphrase option
4206+
l1 = node_factory.get_node(options={
4207+
'exposesecret-passphrase': "test_exposesecret",
4208+
}, start=False)
4209+
4210+
hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")
4211+
if os.path.exists(hsm_path):
4212+
os.remove(hsm_path)
4213+
4214+
# Generate hsm_secret with mnemonic and passphrase using hsmtool
4215+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
4216+
hsm_passphrase = "test_hsm_passphrase" # Any passphrase, since we expect exposesecret to fail
4217+
expected_format = "mnemonic with passphrase"
4218+
4219+
hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path)
4220+
master_fd, slave_fd = os.openpty()
4221+
hsmtool.start(stdin=slave_fd)
4222+
hsmtool.wait_for_log(r"Introduce your BIP39 word list")
4223+
write_all(master_fd, f"{mnemonic}\n".encode("utf-8"))
4224+
hsmtool.wait_for_log(r"Enter your passphrase:")
4225+
write_all(master_fd, f"{hsm_passphrase}\n".encode("utf-8"))
4226+
assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0
4227+
hsmtool.is_in_log(r"New hsm_secret file created")
4228+
hsmtool.is_in_log(f"Format: {expected_format}")
4229+
os.close(master_fd)
4230+
os.close(slave_fd)
4231+
4232+
# Add --hsm-passphrase option to trigger interactive prompting
4233+
l1.daemon.opts["hsm-passphrase"] = None
4234+
4235+
# Create a pty to handle the interactive passphrase prompt
4236+
master_fd2, slave_fd2 = os.openpty()
4237+
l1.daemon.start(stdin=slave_fd2, wait_for_initialized=False)
4238+
4239+
# Wait for the passphrase prompt and provide it
4240+
l1.daemon.wait_for_log("Enter hsm_secret passphrase:")
4241+
print(f"DEBUG: About to send passphrase: '{hsm_passphrase}'")
4242+
passphrase_bytes = f"{hsm_passphrase}\n".encode("utf-8")
4243+
print(f"DEBUG: Passphrase bytes: {passphrase_bytes}")
4244+
write_all(master_fd2, passphrase_bytes)
4245+
print("DEBUG: Passphrase sent!")
4246+
4247+
# Wait for the node to be ready
42194248
l1.daemon.wait_for_log("Server started with public key")
4249+
4250+
os.close(master_fd2)
4251+
os.close(slave_fd2)
4252+
4253+
# Test that exposesecret fails with mnemonic+passphrase format since it needs a passphrase
4254+
with pytest.raises(RpcError, match="Secret with passphrase is not supported"):
4255+
l1.rpc.exposesecret(passphrase="test_exposesecret")
4256+
4257+
4258+
@unittest.skipIf(VALGRIND, "It does not play well with prompt and key derivation.")
4259+
def test_exposesecret_with_mnemonic_no_passphrase(node_factory):
4260+
"""Test that exposesecret plugin works correctly with mnemonic-based hsm_secret without passphrase"""
4261+
# Create a node with exposesecret-passphrase option
4262+
l1 = node_factory.get_node(options={
4263+
'exposesecret-passphrase': "test_exposesecret",
4264+
}, start=False)
4265+
hsm_path = os.path.join(l1.daemon.lightning_dir, TEST_NETWORK, "hsm_secret")
4266+
if os.path.exists(hsm_path):
4267+
os.remove(hsm_path)
4268+
4269+
# Generate hsm_secret with mnemonic and no passphrase using hsmtool
4270+
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
4271+
hsmtool = HsmTool(node_factory.directory, "generatehsm", hsm_path)
4272+
master_fd, slave_fd = os.openpty()
4273+
hsmtool.start(stdin=slave_fd)
4274+
hsmtool.wait_for_log(r"Introduce your BIP39 word list")
4275+
write_all(master_fd, f"{mnemonic}\n".encode("utf-8"))
4276+
hsmtool.wait_for_log(r"Enter your passphrase:")
4277+
write_all(master_fd, "\n".encode("utf-8"))
4278+
assert hsmtool.proc.wait(WAIT_TIMEOUT) == 0
4279+
4280+
# Start the daemon normally (no hsm-passphrase option needed for mnemonic without passphrase)
4281+
l1.start()
4282+
4283+
# Test that exposesecret works correctly with mnemonic-based hsm_secret without passphrase
4284+
result = l1.rpc.exposesecret(passphrase="test_exposesecret")
4285+
assert 'codex32' in result
4286+
assert 'identifier' in result
4287+
assert 'mnemonic' in result
42204288

4221-
with pytest.raises(RpcError, match="maybe encrypted"):
4222-
l1.rpc.exposesecret(passphrase=password)
42234289

42244290

42254291
def test_peer_storage(node_factory, bitcoind):

0 commit comments

Comments
 (0)