What changed, and why it matters
This commit fixes how Electrum builds encrypted routes for sending private messages over the Lightning network. Previously, the code accidentally included the sender's own first hop in the encrypted onion path, which could cause message delivery to fail or produce an invalid route. The fix removes that unnecessary first hop and adds a unit test to confirm the route is built correctly.
Treat as a normal bug-fix commit. Reviewers should verify that the new loop bounds correctly align `hops_data` indices with `hop_shared_secrets` and `blinded_node_ids`, and that the direct-peer case still hands the onion to the correct peer with the right path key.
Security signals we found
Incorrect route/payload construction in onion-message routing
Potential message delivery failure or malformed onion packet
Fix accompanied by regression unit test
Lightning Network BOLT12/blinded-path message privacy feature affected
Evidence from the diff
In create_route_to_introduction_point(), the old code built hops_data starting from path[0] (the edge from the local node to the first peer). The patch changes the loop to start at path[1:], excluding the local-to-first-peer hop because no onion payload is needed for the sender. It also removes the special-case branch for len(path) == 0 and always derives path_key from the session key. A new unit test verifies that for a 5-node line graph the resulting hops_data contains exactly 3 hops (Bob→Carol, Carol→Dave, Dave→Edward) and that when the introduction point is a direct peer the result has zero hops and uses the blinded path’s first path key.
Changed components
electrum/onion_message.pytests/test_onion_message.pytests/test_lnpeer.pyInspect captured patch +106 / −42
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index f3af0ef..a2f5360 100644
--- a/electrum/onion_message.py
+++ b/electrum/onion_message.py
@@ -208,51 +208,50 @@ def create_route_to_introduction_point(
# last edge is to introduction point and start of blinded path. remove from route
assert path[-1].end_node == introduction_point, 'last hop in route must be introduction point'
+ assert len(path) > 1, "if we are directly connected to the IP, why didn't we return the peer above?"
peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node)
assert peer, "first hop is not a peer"
+ # rm last hop (ip-1 -> ip) as it is added explicitly from the blinded path we got (final_hop_pre_ip)
path = path[:-1]
- if len(path) == 0:
- # we pass the onion directly to the introduction point
- path_key = blinded_path['first_path_key']
- else:
- # we construct a route to the introduction point
- payment_path_pubkeys = [edge.end_node for edge in path]
- hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
- payment_path_pubkeys,
- session_key)
-
- for edge in path[:-1]:
- hop = OnionHopsDataSingle(
- tlv_stream_name='onionmsg_tlv',
- blind_fields={'next_node_id': {'node_id': edge.end_node}},
- )
- hops_data.append(hop)
-
- # final hop pre-ip, add next_path_key_override
- final_hop_pre_ip = OnionHopsDataSingle(
+ # we construct a route to the introduction point
+ payment_path_pubkeys = [edge.end_node for edge in path]
+ hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
+ payment_path_pubkeys,
+ session_key)
+
+ # exclude first hop (us to first node on path): we don't need to a layer for ourselves
+ for edge in path[1:]:
+ hop = OnionHopsDataSingle(
tlv_stream_name='onionmsg_tlv',
- blind_fields={
- 'next_node_id': {'node_id': introduction_point},
- 'next_path_key_override': {'path_key': blinded_path['first_path_key']},
- },
+ blind_fields={'next_node_id': {'node_id': edge.end_node}},
)
- hops_data.append(final_hop_pre_ip)
-
- # encrypt encrypted_data_tlv here
- for i, hop in enumerate(hops_data):
- encrypted_recipient_data = encrypt_onionmsg_data_tlv(
- shared_secret=hop_shared_secrets[i],
- **hop.blind_fields)
- payload = dict(hop.payload)
- payload['encrypted_recipient_data'] = {
- 'encrypted_recipient_data': encrypted_recipient_data
- }
- hops_data[i] = dataclasses.replace(hop, payload=payload)
+ hops_data.append(hop)
+
+ # final hop pre-ip, add next_path_key_override
+ final_hop_pre_ip = OnionHopsDataSingle(
+ tlv_stream_name='onionmsg_tlv',
+ blind_fields={
+ 'next_node_id': {'node_id': introduction_point},
+ 'next_path_key_override': {'path_key': blinded_path['first_path_key']},
+ },
+ )
+ hops_data.append(final_hop_pre_ip)
+
+ # encrypt encrypted_data_tlv here
+ for i, hop in enumerate(hops_data):
+ encrypted_recipient_data = encrypt_onionmsg_data_tlv(
+ shared_secret=hop_shared_secrets[i],
+ **hop.blind_fields)
+ payload = dict(hop.payload)
+ payload['encrypted_recipient_data'] = {
+ 'encrypted_recipient_data': encrypted_recipient_data
+ }
+ hops_data[i] = dataclasses.replace(hop, payload=payload)
- path_key = ecc.ECPrivkey(session_key).get_public_key_bytes()
+ path_key = ecc.ECPrivkey(session_key).get_public_key_bytes()
return peer, path_key, hops_data, blinded_node_ids
@@ -628,7 +627,7 @@ class OnionMessageManager(Logger):
try:
self._send_pending_message(key)
except BaseException as e:
- self.logger.debug(f'error while sending {key=} {e!r}')
+ self.logger.debug(f'error while sending {key=}: ', exc_info=True)
req.future.set_exception(copy.copy(e))
# NOTE: above, when passing the caught exception instance e directly it leads to GeneratorExit() in
if isinstance(e, NoRouteFound) and e.peer_address:
diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index 8669931..72b6f09 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -377,6 +377,7 @@ class SuccessfulTest(Exception): pass
def inject_chan_into_gossipdb(*, channel_db: ChannelDB, graph: Graph, node1name: str, node2name: str) -> None:
+ print(f"injecting channel {node1name} -> {node2name} into channel_db")
chan_ann_raw = graph.channels[(node1name, node2name)].construct_channel_announcement_without_sigs()[0]
chan_ann_dict = decode_msg(chan_ann_raw)[1]
channel_db.add_channel_announcements(chan_ann_dict, trusted=True)
diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py
index a4be179..63e5803 100644
--- a/tests/test_onion_message.py
+++ b/tests/test_onion_message.py
@@ -5,8 +5,7 @@ import time
import dataclasses
import logging
from functools import partial
-from unittest.mock import Mock
-from types import MappingProxyType
+from unittest.mock import patch
from aiorpcx import NetAddress
import electrum_ecc as ecc
@@ -17,18 +16,19 @@ from electrum.lnmsg import decode_msg, OnionWireSerializer
from electrum.lnonion import (
OnionHopsDataSingle, OnionPacket, process_onion_packet, get_bolt04_onion_key, encrypt_onionmsg_data_tlv,
get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize,
- encrypt_hops_recipient_data, blinding_privkey)
+ encrypt_hops_recipient_data, blinding_privkey, decrypt_onionmsg_data_tlv)
from electrum.crypto import get_ecdh, privkey_to_pubkey
from electrum.lntransport import LNPeerAddr
from electrum.lnutil import LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE
from electrum.onion_message import (
- create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, get_blinded_paths_to_me,
+ create_blinded_path, OnionMessageManager, NoRouteFound, Timeout,
+ create_route_to_introduction_point, get_blinded_paths_to_me
)
from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop
from electrum.logging import console_stderr_handler
from . import ElectrumTestCase
-from .test_lnpeer import TestPeer
+from .test_lnpeer import TestPeer, inject_chan_into_gossipdb
TIME_STEP = 0.01 # run tests 100 x faster
@@ -531,3 +531,67 @@ class TestOnionMessageUtils(TestPeer):
self.assertEqual(blinded_path['num_hops'], len(blinded_path['path']).to_bytes(length=1, byteorder='big'))
self.assertIn('blinded_node_id', blinded_path['path'][0])
self.assertIn('encrypted_recipient_data', blinded_path['path'][0])
+
+ async def test_create_route_to_introduction_point(self):
+ # A -- B -- C -- D -- E
+ # Alice constructs route to Edward as introduction point to some blinded path
+ line_graph = self.GRAPH_DEFINITIONS['line_graph']
+ graph = self.prepare_chans_and_peers_in_graph(line_graph)
+ alice, bob, carol, dave, edward = graph.workers.values()
+
+ session_key = os.urandom(32)
+ introduction_point = edward.node_keypair.pubkey
+ first_path_key = ecc.ECPrivkey.generate_random_key().get_public_key_bytes()
+ blinded_path = {
+ 'first_path_key': first_path_key,
+ }
+ with self.assertRaises(NoRouteFound):
+ create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
+
+ for name, definition in line_graph.items():
+ for channel_partner in definition.get('channels', {}):
+ inject_chan_into_gossipdb(
+ channel_db=alice.channel_db,
+ graph=graph,
+ node1name=name,
+ node2name=channel_partner,
+ )
+
+ # patch is_onion_message_node so we don't have to inject node announcements
+ with patch('electrum.onion_message.is_onion_message_node', return_value=True):
+ r = create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
+ peer, path_key, hops_data, blinded_node_ids = r
+ # alice hands the onion over to bob
+ self.assertEqual(peer.pubkey, bob.lnpeermgr.node_keypair.pubkey)
+
+ self.assertEqual(path_key, ecc.ECPrivkey(session_key).get_public_key_bytes())
+ self.assertEqual(len(hops_data), 3)
+ self.assertEqual(len(hops_data), len(blinded_node_ids))
+
+ # bob unwraps the first layer, sees the next peer, next peer should be carol
+ self.assertEqual(hops_data[0].blind_fields['next_node_id']['node_id'], carol.lnpeermgr.node_keypair.pubkey)
+ self.assertEqual(hops_data[1].blind_fields['next_node_id']['node_id'], dave.lnpeermgr.node_keypair.pubkey)
+ self.assertEqual(hops_data[2].blind_fields['next_node_id']['node_id'], edward.lnpeermgr.node_keypair.pubkey)
+ self.assertEqual(hops_data[2].blind_fields['next_path_key_override']['path_key'], first_path_key)
+
+ # verify that the recipient data is encrypted to the correct node
+ hop_shared_secrets, blinded_node_ids = get_shared_secrets_along_route(
+ (bob.node_keypair.pubkey, carol.node_keypair.pubkey, dave.node_keypair.pubkey),
+ session_key)
+ for hop, ss in zip(hops_data, hop_shared_secrets):
+ encrypted_recipient_data = hop.payload['encrypted_recipient_data']['encrypted_recipient_data']
+ decrypt_onionmsg_data_tlv(
+ shared_secret=ss,
+ encrypted_recipient_data=encrypted_recipient_data,
+ )
+
+ # now Bob is IP, Alice is directly connected to IP
+ introduction_point = bob.node_keypair.pubkey
+ r = create_route_to_introduction_point(alice, blinded_path, introduction_point, session_key)
+ peer, path_key, hops_data, blinded_node_ids = r
+ self.assertEqual(path_key, first_path_key)
+ self.assertEqual(len(hops_data), 0)
+ self.assertEqual(len(blinded_node_ids), 0)
+ alice_bob_peer = alice.lnpeermgr.get_peer_by_pubkey(bob.node_keypair.pubkey)
+ self.assertIsNotNone(alice_bob_peer)
+ self.assertEqual(peer, alice_bob_peer)
Why this scored 42/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.