lightningd: initialize ECDH before replaying blinded HTLCs
What changed, and why it matters
This commit fixes a startup crash in Core Lightning. If the node had stored a special type of payment (a 'blinded HTLC') that needed to be replayed when restarting, the code could try to decode its encrypted onion before the secure key-handling helper was ready. The fix simply initializes that helper earlier in startup and adds a regression test. It is a reliability bug, not a code-execution or theft vulnerability.
Apply the patch. It is a straightforward initialization-order fix with a regression test. No immediate incident response is warranted beyond normal update deployment.
Security signals we found
Fixes a startup crash (denial of service against the local node)
Involves ECDH/HSM initialization ordering
Adds regression test for blinded HTLC replay
Changelog labels it as a fixed crash
Evidence from the diff
The patch moves ecdh_hsmd_setup() before htlcs_reconnect() in lightningd’s main() so that the hsmd-backed ECDH wrapper is initialized before any stored blinded HTLCs are replayed. Blinded onion decoding may call ecdh(), which previously relied on uninitialized stashed_hsm_fd/stashed_failed, causing an assertion failure or crash. The change also adds asserts in common/ecdh_hsmd.c and a regression test in tests/test_plugin.py.
Changed components
lightningd/lightningd.ccommon/ecdh_hsmd.ctests/test_plugin.pyInspect captured patch +80 / −4
diff --git a/common/ecdh_hsmd.c b/common/ecdh_hsmd.c
index 5e186d51..8afa4581 100644
--- a/common/ecdh_hsmd.c
+++ b/common/ecdh_hsmd.c
@@ -1,4 +1,5 @@
#include "config.h"
+#include <assert.h>
#include <common/ecdh.h>
#include <common/ecdh_hsmd.h>
#include <common/utils.h>
@@ -12,6 +13,9 @@ void ecdh(const struct pubkey *point, struct secret *ss)
{
const u8 *msg = towire_hsmd_ecdh_req(NULL, point);
+ assert(stashed_hsm_fd >= 0);
+ assert(stashed_failed != NULL);
+
if (!wire_sync_write(stashed_hsm_fd, take(msg)))
stashed_failed(STATUS_FAIL_HSM_IO, "Write ECDH to hsmd failed");
diff --git a/lightningd/lightningd.c b/lightningd/lightningd.c
index fd5a978e..0241b992 100644
--- a/lightningd/lightningd.c
+++ b/lightningd/lightningd.c
@@ -1358,6 +1358,10 @@ int main(int argc, char *argv[])
goto stop;
}
+ /*~ Set up the hsmd-backed ecdh() wrapper before replaying any stored
+ * HTLCs, since blinded onions may need ECDH during decode. */
+ ecdh_hsmd_setup(ld->hsm_fd, hsm_ecdh_failed);
+
/*~ Process any HTLCs we were in the middle of when we exited, now
* that plugins (who might want to know via htlc_accepted hook) are
* active. These will immediately fail, since no peers are connected,
@@ -1442,9 +1446,6 @@ int main(int argc, char *argv[])
* a backtrace if we fail during startup. */
crashlog = ld->log;
- /*~ This sets up the ecdh() function in ecdh_hsmd to talk to hsmd */
- ecdh_hsmd_setup(ld->hsm_fd, hsm_ecdh_failed);
-
/*~ The root of every backtrace (almost). This is our main event
* loop. We don't even call it if they've already called `stop` */
if (!ld->stop_conn) {
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
index 428864c4..6815fb08 100644
--- a/tests/test_plugin.py
+++ b/tests/test_plugin.py
@@ -5,13 +5,15 @@ from fixtures import * # noqa: F401,F403
from hashlib import sha256
from pyln.client import RpcError, Millisatoshi
from pyln.proto import Invoice
+from pyln.proto.onion import TlvPayload
from pyln.testing.utils import FUNDAMOUNT
from utils import (
only_one, sync_blockheight, TIMEOUT, wait_for, TEST_NETWORK,
expected_peer_features, expected_node_features,
expected_channel_features, account_balance,
check_coin_moves, first_channel_id, EXPERIMENTAL_DUAL_FUND,
- mine_funding_to_announce, VALGRIND, first_scid
+ mine_funding_to_announce, VALGRIND, first_scid,
+ serialize_payload_tlv, tu64_encode
)
from tests.test_wallet import HsmTool, write_all, WAIT_TIMEOUT
@@ -1274,6 +1276,75 @@ def test_htlc_accepted_hook_direct_restart(node_factory, executor):
f1.result()
+def test_htlc_accepted_hook_direct_restart_blinded(node_factory, executor):
+ """l2 restarts while it is pondering what to do with a blinded HTLC."""
+ l1, l2 = node_factory.line_graph(2, opts=[
+ {'may_reconnect': True},
+ {'may_reconnect': True,
+ 'dev-allow-localhost': None,
+ 'plugin': os.path.join(os.getcwd(), 'tests/plugins/hold_htlcs.py')}
+ ], wait_for_announce=True)
+
+ blockheight = l1.rpc.getinfo()['blockheight']
+ offer = l2.rpc.offer('any')
+ inv = l1.rpc.fetchinvoice(offer['bolt12'], '1000msat')
+ decoded = l1.rpc.decode(inv['invoice'])
+ assert len(decoded['invoice_paths']) == 1
+ assert decoded['invoice_paths'][0]['first_node_id'] == l2.info['id']
+
+ path_key = decoded['invoice_paths'][0]['first_path_key']
+ path = decoded['invoice_paths'][0]['path']
+ assert len(path) == 1
+
+ final_tlvs = TlvPayload()
+ final_tlvs.add_field(2, tu64_encode(1000))
+ final_tlvs.add_field(4, tu64_encode(blockheight + 18))
+ final_tlvs.add_field(10, bytes.fromhex(path[0]['encrypted_recipient_data']))
+ final_tlvs.add_field(12, bytes.fromhex(path_key))
+ final_tlvs.add_field(18, tu64_encode(1000))
+
+ hops = [{'pubkey': l1.info['id'],
+ 'payload': serialize_payload_tlv(1000, 18 + 6,
+ first_scid(l1, l2),
+ blockheight).hex()},
+ {'pubkey': l2.info['id'],
+ 'payload': final_tlvs.to_bytes().hex()}]
+ onion = l1.rpc.createonion(hops=hops,
+ assocdata=decoded['invoice_payment_hash'])
+
+ f1 = executor.submit(l1.rpc.injectpaymentonion,
+ onion=onion['onion'],
+ payment_hash=decoded['invoice_payment_hash'],
+ amount_msat=1000,
+ cltv_expiry=blockheight + 18 + 6,
+ partid=1,
+ groupid=0,
+ invstring=inv['invoice'])
+
+ l2.daemon.wait_for_log(r'Holding onto an incoming htlc for 10 seconds')
+
+ # Check that the status mentions the HTLC being held.
+ l2.rpc.listpeers()
+ channel = only_one(l2.rpc.listpeerchannels()['channels'])
+ htlc_status = channel['htlcs'][0].get('status', None)
+ assert htlc_status == "Waiting for the htlc_accepted hook of plugin hold_htlcs.py"
+
+ needle = l2.daemon.logsearch_start
+ l2.restart()
+
+ # The replayed HTLC should be reprocessed after startup and plugin init.
+ l2.daemon.logsearch_start = needle + 1
+ l2.daemon.wait_for_log(r'hold_htlcs.py initializing')
+ l2.daemon.wait_for_log(r'Replaying old unprocessed HTLC #')
+ l2.daemon.wait_for_log(r'Holding onto an incoming htlc for 10 seconds')
+
+ ret = f1.result()
+ assert sha256(bytes.fromhex(ret['payment_preimage'])).hexdigest() == decoded['invoice_payment_hash']
+
+ label = f"{decoded['offer_id']}-{decoded['invreq_payer_id']}-0"
+ assert only_one(l2.rpc.listinvoices(label)['invoices'])['status'] == 'paid'
+
+
def test_htlc_accepted_hook_forward_restart(node_factory, executor):
"""l2 restarts while it is pondering what to do with an HTLC.
"""
Why this scored 38/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.