onion_message: let caller specify considered channels for blinded paths. This allows restricting blinded paths to channels that have sufficient receive capacity for payment.
What changed, and why it matters
This commit changes how Electrum builds private Lightning Network routing hints called 'blinded paths.' It lets the wallet choose which channels to include in a blinded path, and when channels are explicitly chosen, it publishes a short channel ID (a public network identifier) instead of just the next node's public key. The commit message itself warns that this 'might have privacy issues, as this can be used to probe channel capacity.' In plain terms, a payment sender could learn more than intended about a receiver's channel balances, which is a known Lightning privacy concern. The change is not a full fix; it is a partial implementation that introduces a new probing risk while trying to solve a payment-reliability problem.
Treat this as a privacy-relevant change requiring follow-up. The author already flagged the probing risk; reviewers should evaluate whether exposing short_channel_id in payment blinded paths is acceptable, whether encrypted_data_tlv padding is implemented to remove length side-channels, and whether callers filter channels in a way that reveals capacity. Users and downstream callers should avoid passing highly selective channel lists until the privacy trade-off is analyzed and documented. No immediate emergency patch is indicated, but the issue should be tracked.
Security signals we found
Commit message explicitly flags possible privacy issue / channel-capacity probing
New API exposes short_channel_id in blinded path encrypted_data where next_node_id was used before
Caller can restrict blinded paths to channels with sufficient receive capacity, potentially leaking capacity information
Padding TODO remains, so encrypted_data length may vary and could leak which branch was taken
Change is defensive/functional but introduces a noted privacy trade-off
Evidence from the diff
The patch modifies electrum/onion_message.py. create_blinded_path() gains an optional ‘channels’ parameter. When provided, non-final hop encrypted data uses ‘short_channel_id’ (taking the remote SCID alias, falling back to the real short_channel_id) instead of ‘next_node_id’. get_blinded_paths_to_me() replaces the old ‘preferred_node_id’ argument with ‘my_channels’, allowing callers to pre-filter channels (e.g., by receive capacity). The commit author notes a possible privacy regression: because the channel identifier is exposed and the caller can restrict paths to channels with enough receive capacity, a payer may be able to infer or probe channel capacity. The patch also removes preferred-node sorting in favor of pure randomization. The TODO about padding encrypted_data_tlv to equal lengths remains, so length side-channels are not addressed here.
Changed components
electrum/onion_message.pyLightning Network blinded path / onion message constructioncreate_blinded_path()get_blinded_paths_to_me()get_blinded_reply_paths()Inspect captured patch +27 / −18
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index 74cc19d..e200aed 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)
Why this scored 43/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.