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.
What changed, and why it matters
This commit refactors how Electrum builds private (blinded) communication and payment paths through the Lightning network. It splits a reply-path helper into a more general helper that can also build payment paths, and adds the routing/fee details required by the Lightning protocol for payment blinded paths. There is no direct evidence in the commit that this fixes a security bug; it appears to be feature/protocol-completion work.
Treat as routine feature/protocol work. Review in normal development context; no urgent security action indicated by this commit alone.
Security signals we found
Refactor of blinded-path construction for Lightning onion messages/payments
Adds payment_relay and payment_constraints per BOLT requirements
No explicit security bug, CVE reference, or vulnerability description in commit
No input validation, memory safety, or cryptographic hardening changes observed
Evidence from the diff
The change refactors get_blinded_reply_paths into get_blinded_paths_to_me, which can construct blinded paths both for onion messages and for payments. For non-onion-message paths it now attaches per-hop payment_relay and payment_constraints TLVs and returns a payinfo dict containing aggregated fee, CLTV, and HTLC-limit data. It also adds encode_blinded_path serialization and updates imports. The diff is additive/refactoring; no vulnerability, bounds check, or input-sanitization fix is visible.
Changed components
electrum/onion_message.pyLightning blinded path / onion-message handlingInspect captured patch +105 / −21
diff --git a/electrum/onion_message.py b/electrum/onion_message.py
index 1fdb3bb..74cc19d 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):
+ local_height = lnwallet.network.get_local_height()
+
+ if len(my_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)
+ rchans = sorted(my_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)
+ 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 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)
-
- return result
+ 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
Why this scored 11/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.