From 4254c9a05101d5c48733f413b403a936a5718a83 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 15:42:04 +0100 Subject: [PATCH] onion_message: fix route construction to ip Don't include first hop of the path, this is the hop from us to the first node and we don't need a payload for ourselves. Also adds unittest checking this. --- electrum/onion_message.py | 69 +++++++++++++++++----------------- tests/test_lnpeer.py | 1 + tests/test_onion_message.py | 74 ++++++++++++++++++++++++++++++++++--- 3 files changed, 104 insertions(+), 40 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index f3af0ef38..a2f53606e 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) + # 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( + # 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) + hops_data.append(hop) - # 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) + # 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) - path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() + # 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() 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 8669931c2..72b6f0982 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 a4be179b7..63e580323 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)