From 65fb739584a290c8e8dff35b59def997b5cf7d4d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 28 May 2024 12:00:58 +0200 Subject: [PATCH 01/10] segwit_addr: bech32 decode without checksum option --- electrum/segwit_addr.py | 17 +++++++++++------ tests/test_bitcoin.py | 7 +++++++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/electrum/segwit_addr.py b/electrum/segwit_addr.py index 224947086..d94f6277c 100644 --- a/electrum/segwit_addr.py +++ b/electrum/segwit_addr.py @@ -43,6 +43,9 @@ class DecodedBech32(NamedTuple): data: Optional[Sequence[int]] # 5-bit ints +INVALID_BECH32 = DecodedBech32(None, None, None) + + def bech32_polymod(values): """Internal function that computes the Bech32 checksum.""" generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] @@ -85,26 +88,28 @@ def bech32_encode(encoding: Encoding, hrp: str, data: List[int]) -> str: return hrp + '1' + ''.join([CHARSET[d] for d in combined]) -def bech32_decode(bech: str, *, ignore_long_length=False) -> DecodedBech32: +def bech32_decode(bech: str, *, ignore_long_length=False, with_checksum=True) -> DecodedBech32: """Validate a Bech32/Bech32m string, and determine HRP and data.""" bech_lower = bech.lower() if bech_lower != bech and bech.upper() != bech: - return DecodedBech32(None, None, None) + return INVALID_BECH32 pos = bech.rfind('1') if pos < 1 or pos + 7 > len(bech) or (not ignore_long_length and len(bech) > 90): - return DecodedBech32(None, None, None) + return INVALID_BECH32 # check that HRP only consists of sane ASCII chars if any(ord(x) < 33 or ord(x) > 126 for x in bech[:pos+1]): - return DecodedBech32(None, None, None) + return INVALID_BECH32 bech = bech_lower hrp = bech[:pos] try: data = [CHARSET_INVERSE[x] for x in bech[pos + 1:]] except KeyError: - return DecodedBech32(None, None, None) + return INVALID_BECH32 + if not with_checksum: + return DecodedBech32(encoding=None, hrp=hrp, data=data) encoding = bech32_verify_checksum(hrp, data) if encoding is None: - return DecodedBech32(None, None, None) + return INVALID_BECH32 return DecodedBech32(encoding=encoding, hrp=hrp, data=data[:-6]) diff --git a/tests/test_bitcoin.py b/tests/test_bitcoin.py index 5673dc2fb..78a6b523d 100644 --- a/tests/test_bitcoin.py +++ b/tests/test_bitcoin.py @@ -660,6 +660,13 @@ class Test_bitcoin(ElectrumTestCase): self.assertEqual(DecodedBech32(None, None, None), segwit_addr.bech32_decode('1p2gdwpf')) + # without checksum + bolt12_str = 'lno1pqps7sjqpgtyzm3qv4uxzmtsd3jjqer9wd3hy6tsw35k7msjzfpy7nz5yqcnygrfdej82um5wf5k2uckyypwa3eyt44h6txtxquqh7lz5djge4afgfjn7k4rgrkuag0jsd5xvxg' + self.assertEqual(DecodedBech32(None, None, None), + segwit_addr.bech32_decode(bolt12_str, with_checksum=True, ignore_long_length=True)) + self.assertEqual(DecodedBech32(None, 'lno', [1, 0, 1, 16, 30, 16, 18, 0, 1, 8, 11, 4, 2, 27, 17, 0, 12, 21, 28, 6, 2, 27, 11, 16, 13, 17, 18, 18, 0, 25, 3, 5, 14, 13, 17, 23, 4, 26, 11, 16, 14, 17, 20, 22, 30, 27, 16, 18, 2, 9, 1, 4, 30, 19, 2, 20, 4, 0, 24, 19, 4, 8, 3, 9, 13, 25, 18, 7, 10, 28, 27, 20, 14, 9, 20, 22, 10, 28, 24, 22, 4, 4, 1, 14, 29, 17, 25, 4, 11, 21, 21, 23, 26, 11, 6, 11, 6, 0, 28, 0, 23, 30, 31, 2, 20, 13, 18, 8, 25, 21, 29, 9, 8, 9, 18, 19, 30, 22, 21, 3, 8, 3, 22, 28, 29, 8, 15, 18, 16, 13, 20, 6, 12, 6, 8]), + segwit_addr.bech32_decode(bolt12_str, with_checksum=False, ignore_long_length=True)) + class Test_xprv_xpub(ElectrumTestCase): From b7a512845ff9b945867190d849ccdea4efb69625 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Wed, 8 Oct 2025 15:34:16 +0200 Subject: [PATCH 02/10] onion_message: factor out get_blinded_paths_to_me from get_blinded_reply_paths. the former also calculates payinfo information for payment scenarios. include payment_relay struct for payment blinded_paths. --- electrum/onion_message.py | 130 +++++++++++++++++++++++++++++++------- 1 file changed, 107 insertions(+), 23 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 1fdb3bb90..74cc19d46 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -31,10 +31,11 @@ import dataclasses from random import random from types import MappingProxyType -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, List +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple import electrum_ecc as ecc +from electrum.channel_db import get_mychannel_policy from electrum.lnrouter import PathEdge from electrum.logging import get_logger, Logger from electrum.crypto import sha256, get_ecdh @@ -42,7 +43,7 @@ from electrum.lnmsg import OnionWireSerializer from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data) -from electrum.lnutil import LnFeatures +from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED from electrum.util import OldTaskGroup, log_exceptions @@ -61,6 +62,7 @@ logger = get_logger(__name__) REQUEST_REPLY_PATHS_MAX = 3 +PAYMENT_PATHS_MAX = 3 class NoRouteFound(Exception): @@ -93,6 +95,7 @@ def create_blinded_path( is_non_final_node = i < len(path) - 1 if is_non_final_node: + # spec: alt: short_channel_id instead of next_node_id recipient_data = { # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length 'next_node_id': {'node_id': path[i+1]} @@ -122,6 +125,16 @@ def create_blinded_path( return blinded_path +def encode_blinded_path(blinded_path: dict): + with io.BytesIO() as blinded_path_fd: + OnionWireSerializer.write_field( + fd=blinded_path_fd, + field_type='blinded_path', + count=1, + value=blinded_path) + return blinded_path_fd.getvalue() + + def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: shared_secret = get_ecdh(privkey, blinding) b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) @@ -371,35 +384,106 @@ def get_blinded_reply_paths( max_paths: int = REQUEST_REPLY_PATHS_MAX, preferred_node_id: bytes = None ) -> Sequence[dict]: - """construct a list of blinded reply_paths. + """construct a list of blinded reply-paths for onion message. + """ + mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path + paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, + preferred_node_id=preferred_node_id, onion_message=True) + return paths + + +def get_blinded_paths_to_me( + lnwallet: 'LNWallet', + final_recipient_data: dict, + *, + max_paths: int = PAYMENT_PATHS_MAX, + preferred_node_id: bytes = None, + onion_message: bool = False +) -> Tuple[Sequence[dict], Sequence[dict]]: + """construct a list of blinded paths. current logic: - - uses current onion_message capable channel peers if exist - - otherwise, uses current onion_message capable peers + - uses channels peers if not onion_message + - uses current onion_message capable channel peers if exist and if onion_message + - otherwise, uses current onion_message capable peers if onion_message - prefers preferred_node_id if given - - reply_path introduction points are direct peers only (TODO: longer reply paths)""" + - reply_path introduction points are direct peers only (TODO: longer paths)""" # TODO: build longer paths and/or add dummy hops to increase privacy my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_onionmsg_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and - lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] - my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + my_channels = my_active_channels + if onion_message: + my_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and + lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] result = [] + payinfo = [] mynodeid = lnwallet.node_keypair.pubkey - mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path - if len(my_onionmsg_channels): - # randomize list, but prefer preferred_node_id - rchans = sorted(my_onionmsg_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) - for chan in rchans[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], mydata) - result.append(blinded_path) - elif len(my_onionmsg_peers): - # randomize list, but prefer preferred_node_id - rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) - for peer in rpeers[:max_paths]: - blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], mydata) - result.append(blinded_path) + local_height = lnwallet.network.get_local_height() - return result + if len(my_channels): + # randomize list, but prefer preferred_node_id + rchans = sorted(my_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) + for chan in rchans[:max_paths]: + hop_extras = None + if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) + # get policy + cp = get_mychannel_policy(chan.short_channel_id, chan.node_id, {chan.short_channel_id: chan}) + + dest_max_cltv_expiry = local_height + MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED + + # TODO: for longer paths (>2), reverse traverse and calculate max_cltv_expiry at each intermediate hop + # and determine the cltv delta sums and fee sums of the hops for the payinfo struct. + # current assumption is len(blinded_path) == 2 (us and peer) + sum_cltv_expiry_delta = cp.cltv_delta + sum_fee_base_msat = cp.fee_base_msat + sum_fee_proportional_millionths = cp.fee_proportional_millionths + # path htlc limits + blinded_path_min_htlc_msat = cp.htlc_minimum_msat + blinded_path_max_htlc_msat = cp.htlc_maximum_msat + + hop_extras = [{ + # spec: MUST include encrypted_data_tlv.payment_relay for each non-final node. + 'payment_relay': { + 'cltv_expiry_delta': cp.cltv_delta, + 'fee_base_msat': cp.fee_base_msat, + 'fee_proportional_millionths': cp.fee_proportional_millionths, + }, + # spec: MUST set encrypted_data_tlv.payment_constraints for each non-final node and MAY set it for the final node: + # + # max_cltv_expiry to the largest block height at which the route is allowed to be used, starting + # from the final node's chosen max_cltv_expiry height at which the route should expire, adding + # the final node's min_final_cltv_expiry_delta and then adding + # encrypted_data_tlv.payment_relay.cltv_expiry_delta at each hop. + # + # htlc_minimum_msat to the largest minimum HTLC value the nodes will allow. + 'payment_constraints': { + 'max_cltv_expiry': dest_max_cltv_expiry + cp.cltv_delta, + 'htlc_minimum_msat': blinded_path_min_htlc_msat + } + }] + payinfo.append({ + 'fee_base_msat': sum_fee_base_msat, + 'fee_proportional_millionths': sum_fee_proportional_millionths, + 'cltv_expiry_delta': sum_cltv_expiry_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, + 'htlc_minimum_msat': blinded_path_min_htlc_msat, + 'htlc_maximum_msat': blinded_path_max_htlc_msat, + 'flen': 0, + 'features': bytes(0) + }) + blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data, + hop_extras=hop_extras) + result.append(blinded_path) + elif onion_message: + # we can use peers even without channels for onion messages + my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if + peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] + if len(my_onionmsg_peers): + # randomize list, but prefer preferred_node_id + rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) + for peer in rpeers[:max_paths]: + blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) + result.append(blinded_path) + + return result, payinfo class Timeout(Exception): pass From 8d4affa29302e08ae84f5a1fe30db2255097586f Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 27 Mar 2026 17:29:54 +0100 Subject: [PATCH 03/10] test_onion_message: test get_blinded_paths_to_me Add unittest to test the payment path of get_blinded_paths_to_me --- tests/test_onion_message.py | 45 +++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 43908ea18..25fcba43c 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -17,14 +17,15 @@ from electrum.lnonion import ( get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize, encrypt_hops_recipient_data) from electrum.crypto import get_ecdh, privkey_to_pubkey -from electrum.lnutil import LnFeatures, Keypair +from electrum.lnutil import LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED, REMOTE from electrum.onion_message import ( - blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout + blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout, 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 TIME_STEP = 0.01 # run tests 100 x faster @@ -461,3 +462,43 @@ class TestOnionMessageManager(ElectrumTestCase): self.logger.debug('stopping manager') await t.stop() await lnw.stop() + + +class TestOnionMessageUtils(TestPeer): + + async def test_get_blinded_paths_to_me_payment(self): + # A <- B (alice generates blinded path from bob to herself) + graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) + alice, bob = graph.workers.values() + + # store bobs channel_update in alice + alice_chan = graph.channels[('alice', 'bob')] + bob_chan = graph.channels[('bob', 'alice')] + bob_update_raw = bob_chan.get_outgoing_gossip_channel_update() + bob_update = decode_msg(bob_update_raw)[1] + bob_update['raw'] = bob_update_raw + alice_chan.set_remote_update(bob_update) + + final_recipient_data = {'path_id': {'data': os.urandom(32)}} + paths, payinfos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False) + + self.assertEqual(len(paths), 1) + self.assertEqual(len(payinfos), 1) + + self.assertEqual(payinfos[0], { + 'fee_base_msat': bob_chan.forwarding_fee_base_msat, + 'fee_proportional_millionths': bob_chan.forwarding_fee_proportional_millionths, + 'cltv_expiry_delta': bob_chan.forwarding_cltv_delta + MIN_FINAL_CLTV_DELTA_ACCEPTED, + 'htlc_minimum_msat': bob_chan.config[REMOTE].htlc_minimum_msat, + 'htlc_maximum_msat': min(bob_chan.config[REMOTE].max_htlc_value_in_flight_msat, 1000 * bob_chan.constraints.capacity), + 'flen': 0, + 'features': bytes(0), + }) + + blinded_path = paths[0] + self.assertEqual(len(blinded_path['path']), 2) + self.assertEqual(blinded_path['first_node_id'], bob.node_keypair.pubkey) + self.assertEqual(len(blinded_path['first_path_key']), 33) + 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]) From 5c4fc2d713d50d6834fab8d445f43fa8ece93c28 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 13 Nov 2025 17:08:37 +0100 Subject: [PATCH 04/10] onion_message: verify LNPeerAddr returned as hint in NoRouteFound --- tests/test_onion_message.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 25fcba43c..bddfa4de4 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -5,7 +5,9 @@ import time import dataclasses import logging from functools import partial +from unittest.mock import Mock from types import MappingProxyType +from aiorpcx import NetAddress import electrum_ecc as ecc from electrum_ecc import ECPrivkey @@ -17,6 +19,7 @@ from electrum.lnonion import ( get_shared_secrets_along_route, new_onion_packet, ONION_MESSAGE_LARGE_SIZE, HOPS_DATA_SIZE, InvalidPayloadSize, encrypt_hops_recipient_data) 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 ( blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout, get_blinded_paths_to_me, @@ -351,8 +354,10 @@ class TestOnionMessageManager(ElectrumTestCase): payload={'message': {'text': 'no_peer'.encode('utf-8')}}, node_id_or_blinded_path=self.eve.pubkey) - with self.assertRaises(NoRouteFound): + # will not find route to eve, but has eve's address, but we are configured to not direct connect + with self.assertRaises(NoRouteFound) as c: await t5 + self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) async def test_request_and_reply(self): n = MockNetwork() @@ -375,6 +380,7 @@ class TestOnionMessageManager(ElectrumTestCase): lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) + lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} t = OnionMessageManager(lnw) t.start_network(network=n) From 2b6ad681453744817c9528d246bf51ddc820668f Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 11:57:21 +0100 Subject: [PATCH 05/10] tests: test_onion_message: mock LNWallet._add_peer Mock LNWallet._add_peer so direct connection fallbacks don't cause an exception. --- tests/test_onion_message.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index bddfa4de4..dd2be43df 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -363,6 +363,13 @@ class TestOnionMessageManager(ElectrumTestCase): n = MockNetwork() lnw = self.create_mock_lnwallet(name='test_request_and_reply', has_anchors=False) + # mock add_peer for direct connection fallback + async def mock__add_peer(host, port, node_id): + mock_peer = MockPeer(pubkey=node_id) + # lnw.lnpeermgr._peers[node_id] = mock_peer + return mock_peer + lnw.lnpeermgr._add_peer = mock__add_peer + def slow(*args, **kwargs): time.sleep(2*TIME_STEP) From 3e3bffa4a24e6480220f42ed9408dd405d8c1c2b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 18 Nov 2025 16:43:20 +0100 Subject: [PATCH 06/10] onion_message: let caller specify considered channels for blinded paths. This allows restricting blinded paths to channels that have sufficient receive capacity for payment. NOTE: this might have privacy issues, as this can be used to probe channel capacity. Maybe randomize leeway? @f321x: changed to use scid alias in create_blinded_path --- electrum/onion_message.py | 45 +++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 18 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 74cc19d46..e200aed64 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -56,6 +56,7 @@ if TYPE_CHECKING: from electrum.network import Network from electrum.lnrouter import NodeInfo from electrum.lntransport import LNPeerAddr + from electrum.lnchannel import Channel from asyncio import Task logger = get_logger(__name__) @@ -77,7 +78,8 @@ def create_blinded_path( final_recipient_data: dict, *, hop_extras: Optional[Sequence[dict]] = None, - dummy_hops: Optional[int] = 0 + dummy_hops: Optional[int] = 0, + channels: Optional[Sequence['Channel']] = None, ) -> dict: # dummy hops could be inserted anywhere in the path, but for compatibility just add them at the end # because blinded paths are usually constructed towards ourselves, and we know we can handle dummy hops. @@ -96,10 +98,18 @@ def create_blinded_path( if is_non_final_node: # spec: alt: short_channel_id instead of next_node_id - recipient_data = { - # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length - 'next_node_id': {'node_id': path[i+1]} - } + if channels: # use short_channel_id for payments + scid = channels[i].get_remote_scid_alias() or channels[i].short_channel_id + recipient_data = { + # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length + 'short_channel_id': {'short_channel_id': scid} + } + else: + recipient_data = { + # TODO: SHOULD add padding data to ensure all encrypted_data_tlv(i) have the same length + 'next_node_id': {'node_id': path[i+1]} + } + if hop_extras and i < len(hop_extras): # extra hop data for debugging for now recipient_data.update(hop_extras[i]) else: @@ -382,13 +392,11 @@ def get_blinded_reply_paths( path_id: bytes, *, max_paths: int = REQUEST_REPLY_PATHS_MAX, - preferred_node_id: bytes = None ) -> Sequence[dict]: """construct a list of blinded reply-paths for onion message. """ mydata = {'path_id': {'data': path_id}} # same path_id used in every reply path - paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, - preferred_node_id=preferred_node_id, onion_message=True) + paths, payinfo = get_blinded_paths_to_me(lnwallet, mydata, max_paths=max_paths, onion_message=True) return paths @@ -397,7 +405,7 @@ def get_blinded_paths_to_me( final_recipient_data: dict, *, max_paths: int = PAYMENT_PATHS_MAX, - preferred_node_id: bytes = None, + my_channels: Optional[Sequence['Channel']] = None, onion_message: bool = False ) -> Tuple[Sequence[dict], Sequence[dict]]: """construct a list of blinded paths. @@ -405,13 +413,14 @@ def get_blinded_paths_to_me( - uses channels peers if not onion_message - uses current onion_message capable channel peers if exist and if onion_message - otherwise, uses current onion_message capable peers if onion_message - - prefers preferred_node_id if given - reply_path introduction points are direct peers only (TODO: longer paths)""" # TODO: build longer paths and/or add dummy hops to increase privacy - my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] - my_channels = my_active_channels + if not my_channels: + my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()] + my_channels = my_active_channels + if onion_message: - my_channels = [chan for chan in my_active_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and + my_channels = [chan for chan in my_channels if lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id) and lnwallet.lnpeermgr.get_peer_by_pubkey(chan.node_id).their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] result = [] @@ -420,8 +429,8 @@ def get_blinded_paths_to_me( local_height = lnwallet.network.get_local_height() if len(my_channels): - # randomize list, but prefer preferred_node_id - rchans = sorted(my_channels, key=lambda x: random() if x.node_id != preferred_node_id else 0) + # randomize list + rchans = sorted(my_channels, key=lambda x: random()) for chan in rchans[:max_paths]: hop_extras = None if not onion_message: # add hop_extras and payinfo, assumption: len(blinded_path) == 2 (us and peer) @@ -470,15 +479,15 @@ def get_blinded_paths_to_me( 'features': bytes(0) }) blinded_path = create_blinded_path(os.urandom(32), [chan.node_id, mynodeid], final_recipient_data, - hop_extras=hop_extras) + hop_extras=hop_extras, channels=[chan] if not onion_message else None) result.append(blinded_path) elif onion_message: # we can use peers even without channels for onion messages my_onionmsg_peers = [peer for peer in lnwallet.lnpeermgr.peers.values() if peer.their_features.supports(LnFeatures.OPTION_ONION_MESSAGE_OPT)] if len(my_onionmsg_peers): - # randomize list, but prefer preferred_node_id - rpeers = sorted(my_onionmsg_peers, key=lambda x: random() if x.pubkey != preferred_node_id else 0) + # randomize list + rpeers = sorted(my_onionmsg_peers, key=lambda x: random()) for peer in rpeers[:max_paths]: blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data) result.append(blinded_path) From 9bcbbdd3eba9c7120ca3d79a73fcdbcc985fb089 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 1 Dec 2025 12:55:09 +0100 Subject: [PATCH 07/10] move blinding_privkey from onion_message to lnonion --- electrum/lnonion.py | 11 +++++++++++ electrum/onion_message.py | 14 +------------- tests/test_onion_message.py | 4 ++-- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 10fb2291f..a334fee6a 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -193,6 +193,17 @@ def get_blinded_node_id(node_id: bytes, shared_secret: bytes): return blinded_node_id.get_public_key_bytes() +def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: + shared_secret = get_ecdh(privkey, blinding) + b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) + b_hmac_int = int.from_bytes(b_hmac, byteorder="big") + + our_privkey_int = int.from_bytes(privkey, byteorder="big") + our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER + our_privkey = our_privkey_int.to_bytes(32, byteorder="big") + return our_privkey + + def new_onion_packet( payment_path_pubkeys: Sequence[bytes], session_key: bytes, diff --git a/electrum/onion_message.py b/electrum/onion_message.py index e200aed64..8e4baffc8 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -40,7 +40,7 @@ from electrum.lnrouter import PathEdge from electrum.logging import get_logger, Logger from electrum.crypto import sha256, get_ecdh from electrum.lnmsg import OnionWireSerializer -from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, +from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey, OnionHopsDataSingle, decrypt_onionmsg_data_tlv, encrypt_onionmsg_data_tlv, get_shared_secrets_along_route, new_onion_packet, encrypt_hops_recipient_data) from electrum.lnutil import LnFeatures, MIN_FINAL_CLTV_DELTA_ACCEPTED, MAXIMUM_REMOTE_TO_SELF_DELAY_ACCEPTED @@ -145,18 +145,6 @@ def encode_blinded_path(blinded_path: dict): return blinded_path_fd.getvalue() -def blinding_privkey(privkey: bytes, blinding: bytes) -> bytes: - shared_secret = get_ecdh(privkey, blinding) - b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) - b_hmac_int = int.from_bytes(b_hmac, byteorder="big") - - our_privkey_int = int.from_bytes(privkey, byteorder="big") - our_privkey_int = our_privkey_int * b_hmac_int % ecc.CURVE_ORDER - our_privkey = our_privkey_int.to_bytes(32, byteorder="big") - - return our_privkey - - def is_onion_message_node(node_id: bytes, node_info: Optional['NodeInfo']) -> bool: if not node_info: return False diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index dd2be43df..d261bbe6d 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -17,12 +17,12 @@ 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) + encrypt_hops_recipient_data, blinding_privkey) 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 ( - blinding_privkey, create_blinded_path,OnionMessageManager, NoRouteFound, Timeout, get_blinded_paths_to_me, + create_blinded_path, OnionMessageManager, NoRouteFound, Timeout, 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 2e0f26326969bc389fbeaad9283bcb4f91c76ebf Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Thu, 20 Nov 2025 14:28:07 +0100 Subject: [PATCH 08/10] onion_message: iterate blinded paths for onion message requests --- electrum/onion_message.py | 43 +++++++++++++++++++++---------------- tests/test_onion_message.py | 26 +++++++++++++++++----- 2 files changed, 45 insertions(+), 24 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 8e4baffc8..e94632937 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -31,7 +31,7 @@ import dataclasses from random import random from types import MappingProxyType -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union import electrum_ecc as ecc @@ -496,8 +496,7 @@ class OnionMessageManager(Logger): - forwards are best-effort. They should not need retrying, but a queue is used to limit the pacing of forwarding, and limiting the number of outstanding forwards. Any onion message forwards arriving when the forward queue is full will be dropped. - - TODO: iterate through routes for each request""" + """ SLEEP_DELAY = 1 REQUEST_REPLY_TIMEOUT = 30 @@ -506,10 +505,12 @@ class OnionMessageManager(Logger): FORWARD_RETRY_DELAY = 2 FORWARD_MAX_QUEUE = 3 - class Request(NamedTuple): - future: asyncio.Future - payload: dict - node_id_or_blinded_path: bytes + class Request: + def __init__(self, *, payload: dict, node_id_or_blinded_paths: Union[bytes, Sequence[bytes]]): + self.future = asyncio.Future() + self.payload = payload + self.node_id_or_blinded_paths = node_id_or_blinded_paths + self.current_index: int = 0 def __init__(self, lnwallet: 'LNWallet'): Logger.__init__(self) @@ -623,8 +624,8 @@ class OnionMessageManager(Logger): def submit_send( self, *, payload: dict, - node_id_or_blinded_path: bytes, - key: bytes = None) -> 'Task': + node_id_or_blinded_paths: Union[bytes, Sequence[bytes]], + key: Optional[bytes] = None) -> 'Task': """Add onion message to queue for sending. Queued onion message payloads are supplied with a path_id and a reply_path to determine which request corresponds with arriving replies. @@ -636,13 +637,9 @@ class OnionMessageManager(Logger): key = os.urandom(8) assert type(key) is bytes and len(key) >= 8 - self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_path=}') + self.logger.debug(f'submit_send {key=} {payload=} {node_id_or_blinded_paths=}') - req = OnionMessageManager.Request( - future=asyncio.Future(), - payload=payload, - node_id_or_blinded_path=node_id_or_blinded_path - ) + req = OnionMessageManager.Request(payload=payload, node_id_or_blinded_paths=node_id_or_blinded_paths) with self.pending_lock: if key in self.pending: raise Exception(f'{key=} already exists!') @@ -665,8 +662,15 @@ class OnionMessageManager(Logger): """adds reply_path to payload""" req = self.pending.get(key) payload = req.payload - node_id_or_blinded_path = req.node_id_or_blinded_path - self.logger.debug(f'send_pending_message {key=} {payload=} {node_id_or_blinded_path=}') + + # get next path (round robin) + dests = req.node_id_or_blinded_paths + if isinstance(req.node_id_or_blinded_paths, bytes): + dests = [req.node_id_or_blinded_paths] + dest = dests[req.current_index] + req.current_index = (req.current_index + 1) % len(dests) + + self.logger.debug(f'send_pending_message {key=} {payload=} {dest=}') final_payload = copy.deepcopy(payload) @@ -679,9 +683,10 @@ class OnionMessageManager(Logger): final_payload['reply_path'] = {'path': reply_paths} - # TODO: we should try alternate paths when retrying, this is currently not done. + # NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route) + # when retrying, this is currently not done. # (send_onion_message_to decides path, without knowledge of prev attempts) - send_onion_message_to(self.lnwallet, node_id_or_blinded_path, final_payload) + send_onion_message_to(self.lnwallet, dest, final_payload) def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index d261bbe6d..a4be179b7 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -314,11 +314,12 @@ class TestOnionMessageManager(ElectrumTestCase): self.carol = keypair(ECPrivkey(privkey_bytes=b'\x43'*32)) self.dave = keypair(ECPrivkey(privkey_bytes=b'\x44'*32)) self.eve = keypair(ECPrivkey(privkey_bytes=b'\x45'*32)) + self.fred = keypair(ECPrivkey(privkey_bytes=b'\x46'*32)) async def run_test1(self, t): t1 = t.submit_send( payload={'message': {'text': 'alice_timeout'.encode('utf-8')}}, - node_id_or_blinded_path=self.alice.pubkey) + node_id_or_blinded_paths=self.alice.pubkey) with self.assertRaises(Timeout): await t1 @@ -326,7 +327,7 @@ class TestOnionMessageManager(ElectrumTestCase): async def run_test2(self, t): t2 = t.submit_send( payload={'message': {'text': 'bob_slow_timeout'.encode('utf-8')}}, - node_id_or_blinded_path=self.bob.pubkey) + node_id_or_blinded_paths=self.bob.pubkey) with self.assertRaises(Timeout): await t2 @@ -334,7 +335,7 @@ class TestOnionMessageManager(ElectrumTestCase): async def run_test3(self, t, rkey): t3 = t.submit_send( payload={'message': {'text': 'carol_with_immediate_reply'.encode('utf-8')}}, - node_id_or_blinded_path=self.carol.pubkey, + node_id_or_blinded_paths=self.carol.pubkey, key=rkey) t3_result = await t3 @@ -343,7 +344,7 @@ class TestOnionMessageManager(ElectrumTestCase): async def run_test4(self, t, rkey): t4 = t.submit_send( payload={'message': {'text': 'dave_with_slow_reply'.encode('utf-8')}}, - node_id_or_blinded_path=self.dave.pubkey, + node_id_or_blinded_paths=self.dave.pubkey, key=rkey) t4_result = await t4 @@ -352,13 +353,24 @@ class TestOnionMessageManager(ElectrumTestCase): async def run_test5(self, t): t5 = t.submit_send( payload={'message': {'text': 'no_peer'.encode('utf-8')}}, - node_id_or_blinded_path=self.eve.pubkey) + node_id_or_blinded_paths=self.eve.pubkey) # will not find route to eve, but has eve's address, but we are configured to not direct connect with self.assertRaises(NoRouteFound) as c: await t5 self.assertEqual(c.exception.peer_address, LNPeerAddr('localhost', 1234, self.eve.pubkey)) + async def run_test6(self, t, rkey): + # bob will not reply, fred will + t6 = t.submit_send( + payload={'message': {'text': 'send_dest_roundrobin'.encode('utf-8')}}, + node_id_or_blinded_paths=[self.bob.pubkey, self.fred.pubkey], + key=rkey + ) + + t6_result = await t6 + self.assertEqual(t6_result, ({'path_id': {'data': b'electrum' + rkey}}, {})) + async def test_request_and_reply(self): n = MockNetwork() lnw = self.create_mock_lnwallet(name='test_request_and_reply', has_anchors=False) @@ -382,12 +394,14 @@ class TestOnionMessageManager(ElectrumTestCase): rkey1 = bfh('0102030405060708') rkey2 = bfh('0102030405060709') + rkey3 = bfh('010203040506070a') lnw.lnpeermgr._peers[self.alice.pubkey] = MockPeer(self.alice.pubkey) lnw.lnpeermgr._peers[self.bob.pubkey] = MockPeer(self.bob.pubkey, on_send_message=slow) lnw.lnpeermgr._peers[self.carol.pubkey] = MockPeer(self.carol.pubkey, on_send_message=partial(withreply, rkey1)) lnw.lnpeermgr._peers[self.dave.pubkey] = MockPeer(self.dave.pubkey, on_send_message=partial(slowwithreply, rkey2)) lnw.channel_db._addresses[self.eve.pubkey] = {NetAddress('localhost', '1234'): int(time.time())} + lnw.lnpeermgr._peers[self.fred.pubkey] = MockPeer(self.fred.pubkey, on_send_message=partial(withreply, rkey3)) t = OnionMessageManager(lnw) t.start_network(network=n) @@ -399,6 +413,7 @@ class TestOnionMessageManager(ElectrumTestCase): await self.run_test3(t, rkey1) await self.run_test4(t, rkey2) await self.run_test5(t) + await self.run_test6(t, rkey3) self.logger.debug('tests in parallel') async with OldTaskGroup() as group: await group.spawn(self.run_test1(t)) @@ -406,6 +421,7 @@ class TestOnionMessageManager(ElectrumTestCase): await group.spawn(self.run_test3(t, rkey1)) await group.spawn(self.run_test4(t, rkey2)) await group.spawn(self.run_test5(t)) + await group.spawn(self.run_test6(t, rkey3)) finally: await asyncio.sleep(TIME_STEP) From 4134dc7b250ebc0adca049dfec664c580a7c0cd5 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 14:52:07 +0100 Subject: [PATCH 09/10] onion_message: split send_onion_message_to Factor out code from `send_onion_message_to` into a separate function `_create_route_to_introduction_point` to make it easier to reason about it and more testable. --- electrum/onion_message.py | 139 ++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 59 deletions(-) diff --git a/electrum/onion_message.py b/electrum/onion_message.py index e94632937..f3af0ef38 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -170,7 +170,11 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque invoice_amount_msat=10000, # TODO: do this without amount constraints node_filter=lambda x, y: True if x == lnwallet.node_keypair.pubkey else is_onion_message_node(x, y), my_sending_channels=my_sending_channels - ): return path + ): + # first edge must be to our peer + peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node) + assert peer, 'first hop not a peer' + return path # alt: dest is existing peer? if lnwallet.lnpeermgr.get_peer_by_pubkey(node_id): @@ -184,6 +188,75 @@ def create_onion_message_route_to(lnwallet: 'LNWallet', node_id: bytes) -> Seque raise NoRouteFound('no path found') +def create_route_to_introduction_point( + lnwallet: 'LNWallet', + blinded_path: dict, + introduction_point: bytes, + session_key: bytes +): + hops_data = [] + blinded_node_ids = [] + + peer = lnwallet.lnpeermgr.get_peer_by_pubkey(introduction_point) + # if blinded path introduction point is our direct peer, no need to route-find + if peer: + # start of blinded path is our peer + path_key = blinded_path['first_path_key'] + return peer, path_key, hops_data, blinded_node_ids + + path = create_onion_message_route_to(lnwallet, 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' + + peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node) + assert peer, "first hop is not a peer" + + 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( + 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() + + return peer, path_key, hops_data, blinded_node_ids + + def send_onion_message_to( lnwallet: 'LNWallet', node_id_or_blinded_path: bytes, @@ -213,10 +286,10 @@ def send_onion_message_to( # https://github.com/lightning/bolts/blob/master/04-onion-routing.md # "MUST set first_node_id to N0" - hops_data = [] - blinded_node_ids = [] - if lnwallet.node_keypair.pubkey == introduction_point: + hops_data = [] + blinded_node_ids = [] + # blinded path introduction point is me our_blinding = blinded_path['first_path_key'] our_payload = blinded_path['path'][0] @@ -248,65 +321,13 @@ def send_onion_message_to( else: # we need a route to introduction point + r = create_route_to_introduction_point(lnwallet, blinded_path, introduction_point, session_key) + peer, path_key, hops_data, blinded_node_ids = r + remaining_blinded_path = blinded_path['path'] if not isinstance(remaining_blinded_path, list): # doesn't return list when num items == 1 remaining_blinded_path = [remaining_blinded_path] - peer = lnwallet.lnpeermgr.get_peer_by_pubkey(introduction_point) - # if blinded path introduction point is our direct peer, no need to route-find - if peer: - # start of blinded path is our peer - path_key = blinded_path['first_path_key'] - else: - path = create_onion_message_route_to(lnwallet, introduction_point) - - # first edge must be to our peer - peer = lnwallet.lnpeermgr.get_peer_by_pubkey(path[0].end_node) - assert peer, 'first hop not a peer' - - # 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' - - path = path[:-1] - - if len(path) == 0: - path_key = blinded_path['first_path_key'] - else: - 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) - - hops_data = [ - OnionHopsDataSingle( - tlv_stream_name='onionmsg_tlv', - blind_fields={'next_node_id': {'node_id': x.end_node}}, - ) for x in path[:-1] - ] - - # 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 in range(len(hops_data)): - encrypted_recipient_data = encrypt_onionmsg_data_tlv( - shared_secret=hop_shared_secrets[i], - **hops_data[i].blind_fields) - payload = dict(hops_data[i].payload) - payload['encrypted_recipient_data'] = { - 'encrypted_recipient_data': encrypted_recipient_data - } - hops_data[i] = dataclasses.replace(hops_data[i], payload=payload) - - path_key = ecc.ECPrivkey(session_key).get_public_key_bytes() - # append (remaining) blinded path and payload blinded_path_blinded_ids = [] for i, onionmsg_hop in enumerate(remaining_blinded_path): From 4254c9a05101d5c48733f413b403a936a5718a83 Mon Sep 17 00:00:00 2001 From: f321x Date: Thu, 19 Feb 2026 15:42:04 +0100 Subject: [PATCH 10/10] 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)