onion_messages: filter correct feature when creating paths
What changed, and why it matters
This commit fixes a bug in Electrum's Lightning code where the program sometimes picked the wrong type of peer when building private 'blinded paths' for messages or payments. Before the fix, a path meant for an onion message could accidentally require a payment-related feature, and a path meant for a payment could accidentally require a message-related feature. The patch makes the feature check match the actual purpose, and raises clear errors when no suitable peer exists instead of silently returning an empty path. It is a correctness fix that reduces the chance of failed or misrouted private routes, but the commit itself does not describe it as a security fix and no exploit is demonstrated.
Review and merge if part of normal maintenance; monitor for related Lightning protocol correctness issues. No urgent security response is indicated by the commit alone, but users relying on blinded payments or onion messages should run a version containing this fix to avoid route-construction failures.
Security signals we found
Logic bug in feature-bit filtering for blinded path construction
Potential mismatch between path purpose and required peer feature support
Silent empty-path return replaced with explicit exceptions
No vendor security disclosure or CVE referenced in commit
Evidence from the diff
In electrum/onion_message.py, get_blinded_paths_to_me() previously filtered channels/peers using LnFeatures.OPTION_ONION_MESSAGE_OPT whenever onion_message was true, but the non-onion-message branch did not explicitly filter for route blinding support. The patch introduces a required_features variable set to OPTION_ONION_MESSAGE_OPT or OPTION_ROUTE_BLINDING_OPT based on context, applies it uniformly, and adds explicit NoOnionMessagePeers / NoRouteBlindingChannelPeers exceptions when no candidate is found. It also removes a now-redundant ‘if not reply_paths’ guard in OnionMessageManager because get_blinded_paths_to_me now guarantees an exception on failure. Tests are updated to mark the mock peer as route-blinding capable for payment-path tests.
Changed components
electrum/onion_message.pytests/test_onion_message.pyLightning onion-message/blinded-path constructionInspect captured patch +33 / −22
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index b6499f2..6aa1050 100644
--- a/electrum/onion_message.py
+++ b/electrum/onion_message.py
@@ -66,6 +66,10 @@ REQUEST_REPLY_PATHS_MAX = 3
PAYMENT_PATHS_MAX = 3
+class NoOnionMessagePeers(Exception): pass
+class NoRouteBlindingChannelPeers(Exception): pass
+
+
class NoRouteFound(Exception):
def __init__(self, *args, peer_address: 'LNPeerAddr' = None):
Exception.__init__(self, *args)
@@ -413,23 +417,25 @@ def get_blinded_paths_to_me(
) -> Tuple[Sequence[dict], Sequence[dict]]:
"""construct a list of blinded paths.
current logic:
- - 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
- - reply_path introduction points are direct peers only (TODO: longer paths)"""
+ - uses active channel peers if my_channels not provided
+ - if onion_message, filters channels for onion_message feature
+ - if not onion_message, filters channels for route_blinding feature
+ - if onion_message and no suitable channel peers, tries onion_message capable peers
+ - raises if no blinded path could be generated
+ - reply_path introduction points are direct peers only (TODO: longer paths)
+ """
# TODO: build longer paths and/or add dummy hops to increase privacy
if not my_channels:
- my_active_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()]
- my_channels = my_active_channels
+ my_channels = [chan for chan in lnwallet.channels.values() if chan.is_active()]
- if onion_message:
- 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)]
+ required_features = LnFeatures.OPTION_ONION_MESSAGE_OPT if onion_message else LnFeatures.OPTION_ROUTE_BLINDING_OPT
+ 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(required_features)]
result = []
payinfos = []
mynodeid = lnwallet.node_keypair.pubkey
- if len(my_channels):
+ if my_channels:
rchans = random_shuffled_copy(my_channels)
for chan in rchans[:max_paths]:
hop_extras = None
@@ -448,16 +454,22 @@ def get_blinded_paths_to_me(
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):
+
+ if not result:
+ if not onion_message:
+ raise NoRouteBlindingChannelPeers('no OPTION_ROUTE_BLINDING capable channel peers')
+ else:
+ # fall back to peers 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 not my_onionmsg_peers:
+ raise NoOnionMessagePeers('no ONION_MESSAGE capable peers')
rpeers = random_shuffled_copy(my_onionmsg_peers)
for peer in rpeers[:max_paths]:
blinded_path = create_blinded_path(os.urandom(32), [peer.pubkey, mynodeid], final_recipient_data)
result.append(blinded_path)
+ assert result
return result, payinfos
@@ -709,9 +721,6 @@ class OnionMessageManager(Logger):
# unless explicitly set in payload, generate reply_path here
path_id = self._path_id_from_payload_and_key(payload, key)
reply_paths = get_blinded_reply_paths(self.lnwallet, path_id, max_paths=1)
- if not reply_paths:
- raise Exception(f'Could not create a reply_path for {key=}. No active peers?')
-
final_payload['reply_path'] = {'path': reply_paths}
# NOTE: we could also try alternate paths to introduction point (the non-blinded part of the route)
diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py
index 8b7d355..8595fae 100644
--- a/tests/test_onion_message.py
+++ b/tests/test_onion_message.py
@@ -23,7 +23,7 @@ from electrum.lnutil import (LnFeatures, Keypair, MIN_FINAL_CLTV_DELTA_ACCEPTED,
MIN_FINAL_CLTV_DELTA_BUFFER_INVOICE)
from electrum.onion_message import (
create_blinded_path, OnionMessageManager, NoRouteFound, Timeout,
- create_route_to_introduction_point, get_blinded_paths_to_me
+ create_route_to_introduction_point, get_blinded_paths_to_me, NoOnionMessagePeers
)
from electrum.util import bfh, read_json_file, OldTaskGroup, get_asyncio_loop
from electrum.logging import console_stderr_handler
@@ -272,12 +272,13 @@ class MockNetwork:
self.config.EXPERIMENTAL_LN_FORWARD_PAYMENTS = True
-class MockPeer:
- their_features = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT)
+ONION_MESSAGE_CAPABLE_PEER_FEATURES = LnFeatures(LnFeatures.OPTION_ONION_MESSAGE_OPT)
- def __init__(self, pubkey, on_send_message=None):
+class MockPeer:
+ def __init__(self, pubkey, on_send_message=None, their_features=ONION_MESSAGE_CAPABLE_PEER_FEATURES):
self.pubkey = pubkey
self.on_send_message = on_send_message
+ self.their_features = their_features
async def wait_one_htlc_switch_iteration(self, *args):
pass
@@ -502,6 +503,7 @@ class TestOnionMessageUtils(TestPeer):
bob_update = decode_msg(bob_update_raw)[1]
bob_update['raw'] = bob_update_raw
alice_chan.set_remote_update(bob_update)
+ alice.lnpeermgr.get_peer_by_pubkey(bob.node_keypair.pubkey).their_features |= LnFeatures.OPTION_ROUTE_BLINDING_OPT
final_recipient_data = {'path_id': {'data': os.urandom(32)}}
paths, payinfos = get_blinded_paths_to_me(alice, final_recipient_data, onion_message=False)
Why this scored 46/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.