From 2e87f305e1c6dfec4c6156a72a771cc11186f552 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 1/4] lnonion: support payment path blinding move decryption of recipient_data to process_onion_packet, add handling of blinding in peer msgs, handle properties in ProcessedOnionPacket in case of path blinding, move repeated next blinding key derivation to func lnonion.next_blinding_from_shared_secret() Co-Authored-By: f321x --- electrum/lnonion.py | 74 ++++++++++++++++++++++++++++++++--- electrum/lnpeer.py | 27 ++++++++++--- electrum/lnutil.py | 8 +++- electrum/lnwire/peer_wire.csv | 5 ++- electrum/lnworker.py | 5 ++- electrum/onion_message.py | 54 ++++++++++--------------- electrum/wallet_db.py | 14 ++++++- tests/test_onion_message.py | 16 ++------ 8 files changed, 141 insertions(+), 62 deletions(-) diff --git a/electrum/lnonion.py b/electrum/lnonion.py index 043213a82d21..43102d66be69 100644 --- a/electrum/lnonion.py +++ b/electrum/lnonion.py @@ -54,6 +54,7 @@ class UnsupportedOnionPacketVersion(Exception): pass class InvalidOnionMac(Exception): pass class InvalidOnionPubkey(Exception): pass class InvalidPayloadSize(Exception): pass +class InvalidBlindedOnion(Exception): pass @dataclass(frozen=True, kw_only=True) @@ -193,8 +194,7 @@ 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) +def blinding_privkey(privkey: bytes, shared_secret: bytes) -> bytes: b_hmac = get_bolt04_onion_key(b'blinded_node_id', shared_secret) b_hmac_int = int.from_bytes(b_hmac, byteorder="big") @@ -389,7 +389,9 @@ class ProcessedOnionPacket(NamedTuple): are_we_final: bool hop_data: OnionHopsDataSingle next_packet: OnionPacket - trampoline_onion_packet: OnionPacket + trampoline_onion_packet: Optional[OnionPacket] + blinded_path_recipient_data: Optional[MappingProxyType] = None + next_path_key: Optional[bytes] = None @property def amt_to_forward(self) -> Optional[int]: @@ -404,19 +406,39 @@ def outgoing_cltv_value(self) -> Optional[int]: @property def next_chan_scid(self) -> Optional[ShortChannelID]: k1 = k2 = 'short_channel_id' + if self.blinded_path_recipient_data is not None: + return self.get_from_recipient_data(k1, k2, ShortChannelID) return self._get_from_payload(k1, k2, ShortChannelID) + @property + def next_node_id(self) -> Optional[bytes]: + if self.blinded_path_recipient_data is not None: + return self.get_from_recipient_data('next_node_id', 'node_id', bytes) + return None + @property def total_msat(self) -> Optional[int]: + if self.blinded_path_recipient_data is not None: + return self._get_from_payload('total_amount_msat', 'total_msat', int) return self._get_from_payload('payment_data', 'total_msat', int) @property def payment_secret(self) -> Optional[bytes]: + if self.blinded_path_recipient_data is not None: + return None return self._get_from_payload('payment_data', 'payment_secret', bytes) def _get_from_payload(self, k1: str, k2: str, res_type: type): + return self._get_from(self.hop_data.payload, k1, k2, res_type) + + def get_from_recipient_data(self, k1: str, k2: str, res_type: type): + assert self.blinded_path_recipient_data is not None + return self._get_from(self.blinded_path_recipient_data, k1, k2, res_type) + + @staticmethod + def _get_from(payload: Mapping, k1: str, k2: str, res_type: type): try: - result = self.hop_data.payload[k1][k2] + result = payload[k1][k2] return res_type(result) except Exception: return None @@ -429,6 +451,7 @@ def process_onion_packet( *, associated_data: bytes = b'', is_trampoline=False, + current_path_key: Optional[bytes] = None, tlv_stream_name='payload') -> ProcessedOnionPacket: # TODO: check Onion features ( PERM|NODE|3 (required_node_feature_missing ) if onion_packet.version != 0: @@ -436,6 +459,11 @@ def process_onion_packet( if not ecc.ECPubkey.is_pubkey_bytes(onion_packet.public_key): raise InvalidOnionPubkey() is_onion_message = tlv_stream_name == 'onionmsg_tlv' + recipient_data_shared_secret = None + if current_path_key: + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + # the onion is encrypted to our blinded node id + our_onion_private_key = blinding_privkey(our_onion_private_key, recipient_data_shared_secret) shared_secret = get_ecdh(our_onion_private_key, onion_packet.public_key) # check message integrity mu_key = get_bolt04_onion_key(b'mu', shared_secret) @@ -454,6 +482,28 @@ def process_onion_packet( next_hops_data = xor_bytes(padded_header, stream_bytes) next_hops_data_fd = io.BytesIO(next_hops_data) hop_data = OnionHopsDataSingle.from_fd(next_hops_data_fd, tlv_stream_name=tlv_stream_name) + + blinded_path_recipient_data = {} + initial_path_key = hop_data.payload.get('current_path_key', {}).get('path_key') + encrypted_recipient_data = hop_data.payload.get('encrypted_recipient_data', {}).get('encrypted_recipient_data') + if encrypted_recipient_data is not None: + # we are part of a blinded path + if bool(initial_path_key) == bool(current_path_key): + raise InvalidBlindedOnion("need exactly one path key") + if not current_path_key: # we are the introduction point + current_path_key = initial_path_key + recipient_data_shared_secret = get_ecdh(our_onion_private_key, current_path_key) + assert recipient_data_shared_secret + try: + blinded_path_recipient_data = decrypt_onionmsg_data_tlv( + shared_secret=recipient_data_shared_secret, + encrypted_recipient_data=encrypted_recipient_data, + ) + except Exception as e: + raise InvalidBlindedOnion from e + elif current_path_key or initial_path_key: + raise InvalidBlindedOnion("got path key without encrypted_recipient_data") + # trampoline trampoline_onion_packet = hop_data.payload.get('trampoline_onion_packet') if trampoline_onion_packet: @@ -473,7 +523,20 @@ def process_onion_packet( else: # we are an intermediate node; forwarding are_we_final = False - return ProcessedOnionPacket(are_we_final, hop_data, next_onion_packet, trampoline_onion_packet) + + next_path_key = blinded_path_recipient_data.get('next_path_key_override', {}).get('path_key') + if not are_we_final and current_path_key and not next_path_key: + assert recipient_data_shared_secret + next_path_key = next_blinding_from_shared_secret(current_path_key, recipient_data_shared_secret) + + return ProcessedOnionPacket( + are_we_final, + hop_data, + next_onion_packet, + trampoline_onion_packet, + util.make_object_immutable(blinded_path_recipient_data) if current_path_key else None, + next_path_key, + ) def compare_trampoline_onions( @@ -689,6 +752,7 @@ class OnionFailureCode(IntEnum): EXPIRY_TOO_FAR = 21 INVALID_ONION_PAYLOAD = PERM | 22 MPP_TIMEOUT = 23 + INVALID_ONION_BLINDING = BADONION | PERM | 24 TRAMPOLINE_FEE_INSUFFICIENT = NODE | 51 TRAMPOLINE_EXPIRY_TOO_SOON = NODE | 52 diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index 4b88f87d6e7d..e2d463308c67 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -32,7 +32,7 @@ from .transaction import PartialTxOutput, match_script_against_template, Sighash from .logging import Logger from . import lnonion -from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, +from .lnonion import (OnionFailureCode, OnionPacket, obfuscate_onion_error, InvalidBlindedOnion, OnionRoutingFailure, ProcessedOnionPacket, UnsupportedOnionPacketVersion, InvalidOnionMac, InvalidOnionPubkey, OnionFailureCodeMetaFlag, OnionParsingError) @@ -1959,6 +1959,7 @@ def send_htlc( cltv_abs: int, onion: OnionPacket, session_key: Optional[bytes] = None, + next_path_key: Optional[bytes] = None ) -> UpdateAddHtlc: assert chan.can_send_update_add_htlc(), f"cannot send updates: {chan.short_channel_id}" htlc = UpdateAddHtlc(amount_msat=amount_msat, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time())) @@ -1966,6 +1967,10 @@ def send_htlc( if session_key: chan.set_onion_key(htlc.htlc_id, session_key) # should it be the outer onion secret? self.logger.info(f"starting payment. htlc: {htlc}") + extra = {} + if next_path_key: + extra = {'update_add_htlc_tlvs': {'blinded_path': {'path_key': next_path_key}}} + self.send_message( "update_add_htlc", channel_id=chan.channel_id, @@ -1973,7 +1978,9 @@ def send_htlc( cltv_expiry=htlc.cltv_abs, amount_msat=htlc.amount_msat, payment_hash=htlc.payment_hash, - onion_routing_packet=onion.to_bytes()) + onion_routing_packet=onion.to_bytes(), + **extra, + ) self.maybe_send_commitment(chan) return htlc @@ -2080,12 +2087,14 @@ def on_update_add_htlc(self, chan: Channel, payload): cltv_abs = payload["cltv_expiry"] amount_msat_htlc = payload["amount_msat"] onion_packet = payload["onion_routing_packet"] + path_key = payload.get("update_add_htlc_tlvs", {}).get("blinded_path", {}).get("path_key") htlc = UpdateAddHtlc( amount_msat=amount_msat_htlc, payment_hash=payment_hash, cltv_abs=cltv_abs, timestamp=int(time.time()), - htlc_id=htlc_id) + htlc_id=htlc_id, + path_key=path_key) self.logger.info(f"on_update_add_htlc. chan {chan.short_channel_id}. htlc={str(htlc)}") if chan.get_state() != ChannelState.OPEN: raise RemoteMisbehaving(f"received update_add_htlc while chan.get_state() != OPEN. state was {chan.get_state()!r}") @@ -2348,6 +2357,7 @@ def _fail_htlc_set( processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) if raw_error: @@ -2869,6 +2879,7 @@ def _run_htlc_switch_iteration(self): processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, + current_path_key=htlc.path_key, is_trampoline=False, ) payment_key: str = self._check_unfulfilled_htlc( @@ -2976,6 +2987,7 @@ def log_fail_reason(reason: str): processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=mpp_htlc.htlc.payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) onion_payload = processed_onion.hop_data.payload @@ -3028,6 +3040,7 @@ def _check_unfulfilled_htlc_set( processed_onion = self._process_incoming_onion_packet( onion_packet=self._parse_onion_packet(mpp_htlc.unprocessed_onion), payment_hash=payment_hash, + current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, # this is always the outer onion ) processed_onions[mpp_htlc] = (processed_onion, None) @@ -3284,9 +3297,10 @@ def _process_incoming_onion_packet( self, onion_packet: OnionPacket, *, payment_hash: bytes, + current_path_key: Optional[bytes] = None, is_trampoline: bool = False) -> ProcessedOnionPacket: onion_hash = onion_packet.onion_hash - cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline])) # type: ignore + cache_key = sha256(onion_hash + payment_hash + bytes([is_trampoline]) + (current_path_key or b'')) # type: ignore if cached_onion := self._processed_onion_cache.get(cache_key): return cached_onion try: @@ -3294,7 +3308,8 @@ def _process_incoming_onion_packet( onion_packet, our_onion_private_key=self.privkey, associated_data=payment_hash, - is_trampoline=is_trampoline) + is_trampoline=is_trampoline, + current_path_key=current_path_key) self._processed_onion_cache[cache_key] = processed_onion except UnsupportedOnionPacketVersion: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_VERSION, data=onion_hash) @@ -3302,6 +3317,8 @@ def _process_incoming_onion_packet( raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_KEY, data=onion_hash) except InvalidOnionMac: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_HMAC, data=onion_hash) + except InvalidBlindedOnion: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=onion_hash) except Exception as e: self.logger.warning(f"error processing onion packet: {e!r}") raise OnionParsingError(data=onion_hash) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 8baff6182aed..06b8a5d24919 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1931,16 +1931,19 @@ class UpdateAddHtlc: cltv_abs: int htlc_id: Optional[int] = dataclasses.field(default=None) timestamp: int = dataclasses.field(default_factory=lambda: int(time.time())) + path_key: Optional[bytes] = None @staticmethod @stored_at('/channels/*/log/*/adds/*', tuple) - def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp) -> 'UpdateAddHtlc': + def from_tuple(amount_msat, rhash, cltv_abs, htlc_id, timestamp, path_key = None) -> 'UpdateAddHtlc': return UpdateAddHtlc( amount_msat=amount_msat, payment_hash=bytes.fromhex(rhash), cltv_abs=cltv_abs, htlc_id=htlc_id, - timestamp=timestamp) + timestamp=timestamp, + path_key=None if not path_key else bytes.fromhex(path_key), + ) def to_json(self): self._validate() @@ -1952,6 +1955,7 @@ def _validate(self): assert isinstance(self.cltv_abs, int) and self.cltv_abs <= NLOCKTIME_BLOCKHEIGHT_MAX, self.cltv_abs assert isinstance(self.htlc_id, int) or self.htlc_id is None, self.htlc_id assert isinstance(self.timestamp, int), self.timestamp + assert self.path_key is None or (isinstance(self.path_key, bytes) and len(self.path_key) == 33) def __post_init__(self): self._validate() diff --git a/electrum/lnwire/peer_wire.csv b/electrum/lnwire/peer_wire.csv index 2dafaede3ed3..e5d6c7baaa97 100644 --- a/electrum/lnwire/peer_wire.csv +++ b/electrum/lnwire/peer_wire.csv @@ -116,8 +116,9 @@ msgdata,update_add_htlc,amount_msat,u64, msgdata,update_add_htlc,payment_hash,sha256, msgdata,update_add_htlc,cltv_expiry,u32, msgdata,update_add_htlc,onion_routing_packet,byte,1366 -tlvtype,update_add_htlc_tlvs,blinding_point,0 -tlvdata,update_add_htlc_tlvs,blinding_point,blinding,point, +msgdata,update_add_htlc,tlvs,update_add_htlc_tlvs, +tlvtype,update_add_htlc_tlvs,blinded_path,0 +tlvdata,update_add_htlc_tlvs,blinded_path,path_key,point, msgtype,update_fulfill_htlc,130 msgdata,update_fulfill_htlc,channel_id,channel_id, msgdata,update_fulfill_htlc,id,u64, diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 874c74987f8f..a732521079e2 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -1493,6 +1493,7 @@ async def open_channel_just_in_time( next_cltv_abs: int, payment_hash: bytes, next_onion: OnionPacket, + next_path_key: Optional[bytes] = None, ) -> str: assert self.config.OPEN_ZEROCONF_CHANNELS # if an exception is raised during negotiation, we raise an OnionRoutingFailure. @@ -1536,7 +1537,8 @@ async def wait_for_channel(): payment_hash=payment_hash, amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, - onion=next_onion) + onion=next_onion, + next_path_key=next_path_key) async def wait_for_preimage(): while self.get_preimage(payment_hash) is None: await asyncio.sleep(1) @@ -4064,6 +4066,7 @@ def log_fail_reason(reason: str): amount_msat=next_amount_msat_htlc, cltv_abs=next_cltv_abs, onion=processed_onion.next_packet, + next_path_key=processed_onion.next_path_key, ) except BaseException as e: log_fail_reason(f"error sending message to next_peer={next_chan.node_id.hex()}") diff --git a/electrum/onion_message.py b/electrum/onion_message.py index 6aa1050eca4c..8b5cad0bf908 100644 --- a/electrum/onion_message.py +++ b/electrum/onion_message.py @@ -29,16 +29,16 @@ import time import random -from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union +from typing import TYPE_CHECKING, Optional, Sequence, NamedTuple, Tuple, Union, Mapping import electrum_ecc as ecc from electrum.channel_db import get_mychannel_policy from electrum.lnrouter import PathEdge, NoChannelPolicy from electrum.logging import get_logger, Logger -from electrum.crypto import sha256, get_ecdh +from electrum.crypto import get_ecdh from electrum.lnmsg import OnionWireSerializer -from electrum.lnonion import (get_bolt04_onion_key, OnionPacket, process_onion_packet, blinding_privkey, +from electrum.lnonion import (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, next_blinding_from_shared_secret) @@ -732,7 +732,7 @@ def _path_id_from_payload_and_key(self, payload: dict, key: bytes) -> bytes: # TODO: use payload to determine prefix? return b'electrum' + key - def _get_request_for_path_id(self, recipient_data: dict) -> Optional[Request]: + def _get_request_for_path_id(self, recipient_data: Mapping) -> Optional[Request]: path_id = recipient_data.get('path_id', {}).get('data') if not path_id: return None @@ -745,7 +745,7 @@ def _get_request_for_path_id(self, recipient_data: dict) -> Optional[Request]: self.logger.warning('not a reply to our request (unknown request)') return req - def on_onion_message_received(self, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received(self, recipient_data: Mapping, payload: Mapping) -> None: # we are destination, sanity checks # - if `encrypted_data_tlv` contains `allowed_features`: # - MUST ignore the message if: @@ -766,11 +766,11 @@ def on_onion_message_received(self, recipient_data: dict, payload: dict) -> None else: self.on_onion_message_received_reply(req, recipient_data, payload) - def on_onion_message_received_reply(self, request: Request, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received_reply(self, request: Request, recipient_data: Mapping, payload: Mapping) -> None: assert request is not None, 'Request is mandatory' request.future.set_result((recipient_data, payload)) - def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: dict) -> None: + def on_onion_message_received_unsolicited(self, recipient_data: Mapping, payload: Mapping) -> None: self.logger.debug('unsolicited onion_message received') self.logger.debug(f'payload: {payload!r}') @@ -801,10 +801,9 @@ def on_onion_message_received_unsolicited(self, recipient_data: dict, payload: d def on_onion_message_forward( self, - recipient_data: dict, + recipient_data: Mapping, onion_packet: OnionPacket, - blinding: bytes, - shared_secret: bytes + next_path_key: bytes, ) -> None: if recipient_data.get('path_id'): self.logger.error('cannot forward onion_message, path_id in encrypted_data_tlv') @@ -832,17 +831,6 @@ def on_onion_message_forward( self.logger.info(f'next node {next_node_id.hex()} not a peer, dropping message') return - # blinding override? - next_path_key_override = recipient_data.get('next_path_key_override') - if next_path_key_override: - next_path_key = next_path_key_override.get('path_key') - else: - # E_i+1=SHA256(E_i||ss_i) * E_i - blinding_factor = sha256(blinding + shared_secret) - blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big") - next_public_key_int = ecc.ECPubkey(blinding) * blinding_factor_int - next_path_key = next_public_key_int.get_public_key_bytes() - if is_dummy_hop: self.process_onion_message_packet(next_path_key, onion_packet) return @@ -865,28 +853,26 @@ def on_onion_message(self, payload: dict) -> None: onion_packet = OnionPacket.from_bytes(packet) self.process_onion_message_packet(path_key, onion_packet) - def process_onion_message_packet(self, blinding: bytes, onion_packet: OnionPacket) -> None: - our_privkey = blinding_privkey(self.lnwallet.node_keypair.privkey, blinding) - processed_onion_packet = process_onion_packet(onion_packet, our_privkey, tlv_stream_name='onionmsg_tlv') + def process_onion_message_packet(self, path_key: bytes, onion_packet: OnionPacket) -> None: + processed_onion_packet = process_onion_packet( + onion_packet, + self.lnwallet.node_keypair.privkey, + current_path_key=path_key, + tlv_stream_name='onionmsg_tlv', + ) payload = processed_onion_packet.hop_data.payload - self.logger.debug(f'onion peeled: {processed_onion_packet!r}') if not processed_onion_packet.are_we_final: if any([x not in ['encrypted_recipient_data'] for x in payload.keys()]): - self.logger.error('unexpected data in payload') # non-final nodes only encrypted_recipient_data + self.logger.error(f'unexpected data in {payload.keys()=}') # non-final nodes only encrypted_recipient_data return - # decrypt - shared_secret = get_ecdh(self.lnwallet.node_keypair.privkey, blinding) - recipient_data = decrypt_onionmsg_data_tlv( - shared_secret=shared_secret, - encrypted_recipient_data=payload['encrypted_recipient_data']['encrypted_recipient_data'] - ) - + recipient_data = processed_onion_packet.blinded_path_recipient_data self.logger.debug(f'parsed recipient_data: {recipient_data!r}') if processed_onion_packet.are_we_final: self.on_onion_message_received(recipient_data, payload) else: - self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, blinding, shared_secret) + assert isinstance(processed_onion_packet.next_path_key, bytes), processed_onion_packet + self.on_onion_message_forward(recipient_data, processed_onion_packet.next_packet, processed_onion_packet.next_path_key) diff --git a/electrum/wallet_db.py b/electrum/wallet_db.py index c20180a6d595..15a33aba32d0 100644 --- a/electrum/wallet_db.py +++ b/electrum/wallet_db.py @@ -71,7 +71,7 @@ def __init__(self, wallet_db: 'WalletDB'): # seed_version is now used for the version of the wallet file OLD_SEED_VERSION = 4 # electrum versions < 2.0 NEW_SEED_VERSION = 11 # electrum versions >= 2.0 -FINAL_SEED_VERSION = 71 # electrum >= 2.7 will set this to prevent +FINAL_SEED_VERSION = 72 # electrum >= 2.7 will set this to prevent # old versions from overwriting new format @@ -259,6 +259,7 @@ def upgrade(self): self._convert_version_69() self._convert_version_70() self._convert_version_71() + self._convert_version_72() self.put('seed_version', FINAL_SEED_VERSION) # just to be sure def _convert_wallet_type(self): @@ -1435,6 +1436,17 @@ def _convert_version_71(self): self.data['genesis_blockhash'] = constants.net.GENESIS self.data['seed_version'] = 71 + def _convert_version_72(self): + """ + Addition of `path_key` field to lnutil.UpdateAddHtlc. + Doesn't need any specific upgrade as UpdateAddHtlc.from_tuple() has None default arg for `path_key`. + This just bumps the seed_version to prevent cryptic errors when the user tries to open a db with blinded + htlcs in an older Electrum version. + """ + if not self._is_upgrade_method_needed(71, 71): + return + self.data['seed_version'] = 72 + def _convert_imported(self): if not self._is_upgrade_method_needed(0, 13): return diff --git a/tests/test_onion_message.py b/tests/test_onion_message.py index 8595fae36a3c..c26dcf2bcf50 100644 --- a/tests/test_onion_message.py +++ b/tests/test_onion_message.py @@ -164,17 +164,9 @@ def test_decode_onion_message(self): def test_decrypt_onion_message(self): o = OnionPacket.from_bytes(ONION_MESSAGE_PACKET) our_privkey = bfh(test_vectors['decrypt']['hops'][0]['privkey']) - blinding = bfh(test_vectors['route']['first_path_key']) + path_key = bfh(test_vectors['route']['first_path_key']) - shared_secret = get_ecdh(our_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(our_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") - - p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv') + p = process_onion_packet(o, our_privkey, tlv_stream_name='onionmsg_tlv', current_path_key=path_key) self.assertEqual(p.hop_data.blind_fields, {}) self.assertEqual(p.hop_data.hmac, bfh('a5296325ba478ba1e1a9d1f30a2d5052b2e2889bbd64f72c72bc71d8817288a2')) @@ -191,8 +183,8 @@ def test_decrypt_onion_message(self): }) def test_blinding_privkey(self): - a = blinding_privkey(bfh('4141414141414141414141414141414141414141414141414141414141414141'), - bfh('031b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f')) + a = blinding_privkey(bfh('4141414141414141414141414141414141414141414141414141414141414141'), # privkey + bfh('16a5d6594dbaa363305952ed9354fa9c38ddda718d218c5da6be19f0eaa11613')) # shared secret self.assertEqual(a, bfh('7e959bf6bdd3a98caf26cbbee7b69678381d5fa2882c6c12eb2042c2367264b0')) def test_create_blinded_path(self): From 2ec4f8d971ede94c86101cb42fc307df214f523d Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Tue, 11 Nov 2025 16:38:54 +0100 Subject: [PATCH 2/4] lnpeer/lnworker: validate and forward blinded path htlcs check encrypted_recipient_data constraints, fail invalid blinded htlcs with INVALID_ONION_BLINDING, reject being the final recipient of a blinded payment for now, maybe_forward_htlc: derive forwarding parameters from payment_relay data, allow next hop lookup by node_id, validate_features: reject unknown odd feature bits in blinded path context Co-Authored-By: f321x --- electrum/lnpeer.py | 62 +++++++++++++++++++++++++- electrum/lnrouter.py | 8 ++++ electrum/lnutil.py | 9 +++- electrum/lnworker.py | 101 ++++++++++++++++++++++++++++++------------- 4 files changed, 146 insertions(+), 34 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index e2d463308c67..ac6de3583c00 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -2110,6 +2110,46 @@ def on_update_add_htlc(self, chan: Channel, payload): chan.receive_htlc(htlc, onion_packet) util.trigger_callback('htlc_added', chan, htlc, RECEIVED) + @staticmethod + def _check_encrypted_recipient_data( + htlc: UpdateAddHtlc, + processed_onion: ProcessedOnionPacket, + log_fail_reason: Callable[[str], None], + ): + """Check constraints inside the blinded path recipient data.""" + exc_invalid_onion_blinding = OnionRoutingFailure(OnionFailureCode.INVALID_ONION_BLINDING, bytes(32)) + if not processed_onion.blinded_path_recipient_data: + log_fail_reason("empty encrypted_recipient_data for blinded htlc") + raise exc_invalid_onion_blinding + + # bolt-04 payment_constraint check for blinded payments + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L306-L309 + if 'payment_constraints' in processed_onion.blinded_path_recipient_data: + max_cltv_expiry = processed_onion.get_from_recipient_data('payment_constraints', 'max_cltv_expiry', int) + htlc_minimum_msat = processed_onion.get_from_recipient_data('payment_constraints', 'htlc_minimum_msat', int) + if max_cltv_expiry is None or htlc_minimum_msat is None \ + or htlc.cltv_abs > max_cltv_expiry or htlc.amount_msat < htlc_minimum_msat: + log_fail_reason(f"htlc exceeds blinded path payment_constraints: {max_cltv_expiry=} {htlc_minimum_msat=}") + raise exc_invalid_onion_blinding + + if not processed_onion.are_we_final and 'path_id' in processed_onion.blinded_path_recipient_data: + log_fail_reason("path_id field in non-final recipient data") + raise exc_invalid_onion_blinding + + # allowed_features check, in practice they should be empty + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L310-L315 + feature_bytes = processed_onion.get_from_recipient_data('allowed_features', 'features', bytes) or b'' + allowed_features = LnFeatures(int.from_bytes(feature_bytes, byteorder="big", signed=False)) + try: + validate_features(allowed_features, context=LnFeatureContexts.BLINDED_PATH) + except IncompatibleOrInsaneFeatures as e: + log_fail_reason(f"unsupported features in blinded path recipient data: {repr(e)} {allowed_features.get_names()=}") + raise exc_invalid_onion_blinding + + if processed_onion.next_node_id and processed_onion.next_chan_scid: + log_fail_reason(f"{processed_onion.blinded_path_recipient_data=} contains both node_id and next_chan_scid") + raise exc_invalid_onion_blinding + @staticmethod def _check_accepted_final_htlc( *, chan: Channel, @@ -2136,9 +2176,26 @@ def _check_accepted_final_htlc( code=OnionFailureCode.FINAL_INCORRECT_CLTV_EXPIRY, data=htlc.cltv_abs.to_bytes(4, byteorder="big")) + exc_invalid_onion_blinding = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) exc_incorrect_or_unknown_pd = OnionRoutingFailure( code=OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS, - data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + data=amt_to_forward.to_bytes(8, byteorder="big")) # height will be added later + + if processed_onion.blinded_path_recipient_data is not None: # payment over blinded path + # spec: MUST return an error if the payload contains other tlv fields than allowed_payload_keys + allowed_payload_fields = ('encrypted_recipient_data', 'current_path_key', 'amt_to_forward', 'outgoing_cltv_value', 'total_amount_msat') + if any(f not in allowed_payload_fields for f in processed_onion.hop_data.payload): + log_fail_reason(f"unknown key in blinded payload: {processed_onion.hop_data.payload.keys()=}") + raise exc_invalid_onion_blinding + + path_id = processed_onion.get_from_recipient_data('path_id', 'data', bytes) + if not path_id: + log_fail_reason(f"'path_id' missing in recipient_data") + raise exc_invalid_onion_blinding + + log_fail_reason('we cannot receive blinded payments yet.') + raise exc_invalid_onion_blinding + if (total_msat := processed_onion.total_msat) is None: log_fail_reason(f"'total_msat' missing from onion") raise exc_incorrect_or_unknown_pd @@ -2187,6 +2244,9 @@ def _check_unfulfilled_htlc( _log_fail_reason(f"our chain tip is stale: {local_height=}") raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + if processed_onion.blinded_path_recipient_data is not None: + self._check_encrypted_recipient_data(htlc=htlc, processed_onion=processed_onion, log_fail_reason=_log_fail_reason) + payment_hash = htlc.payment_hash if not processed_onion.are_we_final: if outer_onion_payment_secret: diff --git a/electrum/lnrouter.py b/electrum/lnrouter.py index 790303e77c28..181f1adb1b5a 100644 --- a/electrum/lnrouter.py +++ b/electrum/lnrouter.py @@ -61,6 +61,14 @@ def fee_for_edge_msat(forwarded_amount_msat: int, fee_base_msat: int, fee_propor + (forwarded_amount_msat * fee_proportional_millionths // 1_000_000) +def amount_after_fee_msat(*, incoming_amount_msat: int, fee_base_msat: int, fee_proportional_millionths: int) -> int: + """Amount to forward after subtracting our fee from the incoming amount, as specified in bolt 04. + https://github.com/lightning/bolts/blob/ca82e0c909437123e80486fde5829bb9fd605dd0/04-onion-routing.md?plain=1#L339-L340 + """ + return ((incoming_amount_msat - fee_base_msat) * 1_000_000 + 1_000_000 + fee_proportional_millionths - 1) \ + // (1_000_000 + fee_proportional_millionths) + + @attr.s(slots=True) class PathEdge: start_node = attr.ib(type=bytes, kw_only=True, repr=lambda val: val.hex()) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index 06b8a5d24919..d5176f6e9ad3 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1846,8 +1846,13 @@ def validate_features(features: int, *, context: LnFeatureContexts) -> LnFeature features = LnFeatures(features) enabled_features = list_enabled_bits(features) for fbit in enabled_features: - if (1 << fbit) & LN_FEATURES_IMPLEMENTED == 0 and fbit % 2 == 0: - raise UnknownEvenFeatureBits(fbit) + if (1 << fbit) & LN_FEATURES_IMPLEMENTED == 0: + if fbit % 2 == 0: + raise UnknownEvenFeatureBits(fbit) + if context == LnFeatureContexts.BLINDED_PATH: + # bolt 04 requires unknown odd features to be failed :/ + # https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/04-onion-routing.md?plain=1#L313 + raise IncompatibleOrInsaneFeatures(f"unknown odd features in blinded path context: {features.get_names()=}") if not features.validate_transitive_dependencies(context=context): raise IncompatibleOrInsaneFeatures(f"not all transitive dependencies are set. " f"features={features}") diff --git a/electrum/lnworker.py b/electrum/lnworker.py index a732521079e2..3d8599debcb3 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -82,7 +82,7 @@ from .lnmsg import decode_msg from .lnrouter import ( RouteEdge, LNPaymentRoute, LNPaymentPath, is_route_within_budget, NoChannelPolicy, - LNPathInconsistent, fee_for_edge_msat, + LNPathInconsistent, fee_for_edge_msat, amount_after_fee_msat, ) from .lnwatcher import LNWatcher from .submarine_swaps import SwapManager @@ -3987,39 +3987,72 @@ def log_fail_reason(reason: str): chain = self.network.blockchain() if chain.is_tip_stale(): raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - if (next_chan_scid := processed_onion.next_chan_scid) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: + # a blinded payment may reference the next hop by node id or scid + next_chan_scid = processed_onion.next_chan_scid + next_node_id = processed_onion.next_node_id + if next_chan_scid is None and next_node_id is None: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') - next_chan = self.get_channel_by_short_id(next_chan_scid) + if processed_onion.blinded_path_recipient_data is None: + if (next_amount_msat_htlc := processed_onion.amt_to_forward) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + if (next_cltv_abs := processed_onion.outgoing_cltv_value) is None: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_PAYLOAD, data=b'\x00\x00\x00') + else: + # blinded path, calculate values from payment_relay constraints. + allowed_payload_fields = ('encrypted_recipient_data', 'current_path_key') + if any(f not in allowed_payload_fields for f in processed_onion.hop_data.payload): + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + + cltv_expiry_delta = processed_onion.get_from_recipient_data('payment_relay', 'cltv_expiry_delta', int) + fee_base_msat = processed_onion.get_from_recipient_data('payment_relay', 'fee_base_msat', int) + fee_proportional_millionths = processed_onion.get_from_recipient_data('payment_relay', 'fee_proportional_millionths', int) + if not all(v is not None and v >= 0 for v in (cltv_expiry_delta, fee_base_msat, fee_proportional_millionths)): + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + + next_cltv_abs = htlc.cltv_abs - cltv_expiry_delta + next_amount_msat_htlc = amount_after_fee_msat( + incoming_amount_msat=htlc.amount_msat, + fee_base_msat=fee_base_msat, + fee_proportional_millionths=fee_proportional_millionths, + ) + if next_cltv_abs <= 0 or next_amount_msat_htlc <= 0: + raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) - if self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): + next_chan = self.get_channel_by_short_id(next_chan_scid) if next_chan_scid else None + next_peer = self.lnpeermgr.get_peer_by_pubkey(next_node_id) if next_node_id else None + if next_chan_scid and not next_peer and self.features.supports(LnFeatures.OPTION_ZEROCONF_OPT): next_peer = self.get_peer_by_static_jit_scid_alias(next_chan_scid) - else: - next_peer = None - if not next_chan and next_peer and next_peer.accepts_zeroconf(): - # check if an already existing channel can be used. - # todo: split the payment - for next_chan in next_peer.channels.values(): - if next_chan.can_pay(next_amount_msat_htlc): + # search for a fitting channel if: + # 1. blinded forwarding requested 'next_node_id' + # 2. next_peer is a zeroconf peer (next_chan_scid is a zeroconf peer alias) + # If 'next_chan_scid' is given we don't fall back to another channel + if next_peer and (not next_chan_scid or next_peer.accepts_zeroconf()): + # check if an existing channel can be used. + for _next_chan in next_peer.channels.values(): + # todo: split the payment + if _next_chan.can_pay(next_amount_msat_htlc): + next_chan = _next_chan break - else: - return await self.open_channel_just_in_time( - next_peer=next_peer, - next_amount_msat_htlc=next_amount_msat_htlc, - next_cltv_abs=next_cltv_abs, - payment_hash=htlc.payment_hash, - next_onion=processed_onion.next_packet) + + if not next_chan and next_peer and next_peer.accepts_zeroconf(): + return await self.open_channel_just_in_time( + next_peer=next_peer, + next_amount_msat_htlc=next_amount_msat_htlc, + next_cltv_abs=next_cltv_abs, + payment_hash=htlc.payment_hash, + next_onion=processed_onion.next_packet, + next_path_key=processed_onion.next_path_key) local_height = chain.height() if next_chan is None: - log_fail_reason(f"cannot find next_chan {next_chan_scid}") + log_fail_reason(f"cannot find next_chan {(next_chan_scid or b'').hex()=} {(next_node_id or b'').hex()=}") raise OnionRoutingFailure(code=OnionFailureCode.UNKNOWN_NEXT_PEER, data=b'') - outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] + # send no channel_update for blinded payments, it will be overridden with INVALID_ONION_BLINDING later, and bolt04 says: + # TODO: ...'the channel_update field is no longer mandatory and nodes are expected to transition away from including it'... + outgoing_chan_upd = next_chan.get_outgoing_gossip_channel_update(scid=next_chan_scid)[2:] \ + if processed_onion.blinded_path_recipient_data is None else b'' outgoing_chan_upd_len = len(outgoing_chan_upd).to_bytes(2, byteorder="big") outgoing_chan_upd_message = outgoing_chan_upd_len + outgoing_chan_upd if not next_chan.can_send_update_add_htlc(): @@ -4041,11 +4074,17 @@ def log_fail_reason(reason: str): raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_SOON, data=outgoing_chan_upd_message) if max(htlc.cltv_abs, next_cltv_abs) > local_height + lnutil.NBLOCK_CLTV_DELTA_TOO_FAR_INTO_FUTURE: raise OnionRoutingFailure(code=OnionFailureCode.EXPIRY_TOO_FAR, data=b'') - forwarding_fees = fee_for_edge_msat( - forwarded_amount_msat=next_amount_msat_htlc, - fee_base_msat=next_chan.forwarding_fee_base_msat, - fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) - if htlc.amount_msat - next_amount_msat_htlc < forwarding_fees: + if processed_onion.blinded_path_recipient_data is not None: + max_amt_to_forward = amount_after_fee_msat( + incoming_amount_msat=htlc.amount_msat, + fee_base_msat=next_chan.forwarding_fee_base_msat, + fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) + else: + max_amt_to_forward = htlc.amount_msat - fee_for_edge_msat( + forwarded_amount_msat=next_amount_msat_htlc, + fee_base_msat=next_chan.forwarding_fee_base_msat, + fee_proportional_millionths=next_chan.forwarding_fee_proportional_millionths) + if next_amount_msat_htlc > max_amt_to_forward: data = next_amount_msat_htlc.to_bytes(8, byteorder="big") + outgoing_chan_upd_message raise OnionRoutingFailure(code=OnionFailureCode.FEE_INSUFFICIENT, data=data) if self._maybe_refuse_to_forward_htlc_that_corresponds_to_payreq_we_created(htlc.payment_hash): @@ -4055,10 +4094,10 @@ def log_fail_reason(reason: str): f"maybe_forward_htlc. will forward HTLC: inc_chan={incoming_chan.short_channel_id}. inc_htlc={str(htlc)}. " f"next_chan={next_chan.get_id_for_log()}.") - next_peer = self.lnpeermgr.get_peer_by_pubkey(next_chan.node_id) - if next_peer is None: + if next_peer is None and (next_peer := self.lnpeermgr.get_peer_by_pubkey(next_chan.node_id)) is None: log_fail_reason(f"next_peer offline ({next_chan.node_id.hex()})") raise OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_CHANNEL_FAILURE, data=outgoing_chan_upd_message) + assert next_peer.pubkey == next_chan.node_id try: next_htlc = next_peer.send_htlc( chan=next_chan, From 935cd27ecc0123fc1aaacc4903daaf333c20259b Mon Sep 17 00:00:00 2001 From: f321x Date: Fri, 3 Jul 2026 13:32:13 +0200 Subject: [PATCH 3/4] lnpeer: use INVALID_ONION_BLINDING for errors inside path Minor refactor of htlc failure handling to override routing errors with `INVALID_ONION_BLINDING` if they are part of a blinded payment. Also implements random delay at introduction point against probing. --- electrum/lnpeer.py | 191 ++++++++++++++++++++++++++++++++----------- tests/test_lnpeer.py | 35 ++++++++ 2 files changed, 177 insertions(+), 49 deletions(-) diff --git a/electrum/lnpeer.py b/electrum/lnpeer.py index ac6de3583c00..696af30d8f1a 100644 --- a/electrum/lnpeer.py +++ b/electrum/lnpeer.py @@ -7,9 +7,11 @@ from collections import OrderedDict, defaultdict import asyncio import os +import random import time from typing import Tuple, Dict, TYPE_CHECKING, Optional, Union, Set, Callable, Coroutine, List, Any from datetime import datetime +import enum import functools from functools import partial import inspect @@ -66,6 +68,12 @@ LN_P2P_NETWORK_TIMEOUT = 20 +class HtlcContext(enum.Enum): + DEFAULT = enum.auto() # not part of a blinded path (or we are both introduction node and final recipient) + INTRO_NODE = enum.auto() # we are the introduction point of a blinded path + INSIDE_PATH = enum.auto() # we are a non-introduction node inside a blinded path + + class Peer(Logger, EventListener): # note: in general this class is NOT thread-safe. Most methods are assumed to be running on asyncio thread. @@ -129,6 +137,7 @@ def __init__( self.taskgroup = OldTaskGroup() # HTLCs offered by REMOTE, that we started removing but are still active: self.received_htlcs_pending_removal = set() # type: Set[Tuple[Channel, int]] + self._deferred_fail_htlcs = LRUCache(maxsize=1000) # type: LRUCache[Tuple[Channel, int], float] self.received_htlc_removed_event = asyncio.Event() self._htlc_switch_iterstart_event = asyncio.Event() self._htlc_switch_iterdone_event = asyncio.Event() @@ -2396,7 +2405,6 @@ def _fail_htlc_set( assert htlc_set.resolution in (RecvMPPResolution.FAILED, RecvMPPResolution.EXPIRED) raw_error, error_code, error_data = error_tuple - local_height = self.network.blockchain().height() payment_hash = htlc_set.get_payment_hash() assert payment_hash is not None, "Empty htlc set?" for mpp_htlc in list(htlc_set.htlcs): @@ -2413,6 +2421,8 @@ def _fail_htlc_set( self.logger.debug(f"{mpp_htlc=} was already failed before, dropping it.") htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) continue + if not self._blinded_intro_fail_delay_elapsed(chan, htlc_id): + continue # failure already decided, don't re-process until the delay has elapsed onion_packet = self._parse_onion_packet(mpp_htlc.unprocessed_onion) processed_onion_packet = self._process_incoming_onion_packet( onion_packet, @@ -2420,9 +2430,9 @@ def _fail_htlc_set( current_path_key=mpp_htlc.htlc.path_key, is_trampoline=False, ) - if raw_error: - error_bytes = obfuscate_onion_error(raw_error, onion_packet.public_key, self.privkey) - else: + + error = raw_error + if not error: assert isinstance(error_code, (OnionFailureCode, int)) if error_code == OnionFailureCode.INCORRECT_OR_UNKNOWN_PAYMENT_DETAILS: amount_to_forward = processed_onion_packet.amt_to_forward @@ -2435,20 +2445,71 @@ def _fail_htlc_set( is_trampoline=True, ) amount_to_forward = processed_trampoline_onion_packet.amt_to_forward - error_data = amount_to_forward.to_bytes(8, byteorder="big") - e = OnionRoutingFailure(code=error_code, data=error_data or b'') - error_bytes = e.to_wire_msg(onion_packet, self.privkey, local_height) - self.fail_htlc( + if amount_to_forward is not None: + error_data = amount_to_forward.to_bytes(8, byteorder="big") + error = OnionRoutingFailure(code=error_code, data=error_data or b'') + + if self._fail_incoming_htlc( chan=chan, - htlc_id=htlc_id, - error_bytes=error_bytes, - ) - htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) + htlc=mpp_htlc.htlc, + htlc_context=self._get_htlc_context(htlc=mpp_htlc.htlc, processed_onion=processed_onion_packet), + error=error, + onion_packet=onion_packet, + ): + htlc_set = htlc_set._replace(htlcs=htlc_set.htlcs - {mpp_htlc}) self.lnworker.received_mpp_htlcs[payment_key] = htlc_set # save updated set - def fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): - self.logger.info(f"fail_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") + def _fail_incoming_htlc( + self, *, + chan: Channel, + htlc: UpdateAddHtlc, + htlc_context: HtlcContext, + error: Union[bytes, OnionRoutingFailure], + onion_packet: Optional[OnionPacket], + can_defer: bool = True, + ) -> bool: + """ + Decides: + * error to return: `error` is what we intended to send. Inside a blinded path we might + override errors with `INVALID_ONION_BLINDING`. + * message type: update_fail_malformed_htlc (onion parsing errors and errors inside + a blinded path) or update_fail_htlc (everything else). + * timing: as blinded path introduction node the failure is deferred until a random + delay has elapsed (unless `can_defer` is False). + Returns False if failing was deferred, in which case the htlc must not be deleted and failed again + in the next switch iteration. + """ + assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" + send_malformed = isinstance(error, OnionParsingError) + if htlc_context in (HtlcContext.INTRO_NODE, HtlcContext.INSIDE_PATH): + # override any local or downstream error, within a blinded path only invalid_onion_blinding may be returned + if onion_packet is not None: + error_data = onion_packet.onion_hash + else: # we couldn't parse the onion, the parsing error carries the hash of the raw payload + assert isinstance(error, OnionParsingError), f"unparsed onion but got {error!r}" + error_data = error.data + error = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=error_data) + send_malformed = htlc_context == HtlcContext.INSIDE_PATH + if htlc_context == HtlcContext.INTRO_NODE and can_defer \ + and self._maybe_defer_blinded_intro_fail(chan, htlc): + return False # don't fail yet, wait for delay + + if send_malformed: + assert isinstance(error, OnionRoutingFailure), repr(error) + self._send_update_fail_malformed_htlc(chan=chan, htlc_id=htlc.htlc_id, reason=error) + else: + assert onion_packet is not None, "cannot construct an onion error without the onion" + if isinstance(error, bytes): # pre-encrypted error from a downstream node, just wrap it + error_bytes = obfuscate_onion_error(error, onion_packet.public_key, self.privkey) + else: + error_bytes = error.to_wire_msg(onion_packet, self.privkey, self.network.get_local_height()) + self._send_update_fail_htlc(chan=chan, htlc_id=htlc.htlc_id, error_bytes=error_bytes) + self._deferred_fail_htlcs.pop((chan, htlc.htlc_id), None) + return True + + def _send_update_fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): + self.logger.info(f"_send_update_fail_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" self.received_htlcs_pending_removal.add((chan, htlc_id)) chan.fail_htlc(htlc_id) @@ -2460,8 +2521,8 @@ def fail_htlc(self, *, chan: Channel, htlc_id: int, error_bytes: bytes): reason=error_bytes) self.maybe_send_commitment(chan) - def fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionParsingError): - self.logger.info(f"fail_malformed_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") + def _send_update_fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionRoutingFailure): + self.logger.info(f"_send_update_fail_malformed_htlc. chan {chan.short_channel_id}. htlc_id {htlc_id}.") assert chan.can_update_ctx(proposer=LOCAL), f"cannot send updates: {chan.short_channel_id}" if not (reason.code & OnionFailureCodeMetaFlag.BADONION and len(reason.data) == 32): raise Exception(f"unexpected reason when sending 'update_fail_malformed_htlc': {reason!r}") @@ -2475,6 +2536,39 @@ def fail_malformed_htlc(self, *, chan: Channel, htlc_id: int, reason: OnionParsi failure_code=reason.code) self.maybe_send_commitment(chan) + @staticmethod + def _get_htlc_context(*, htlc: UpdateAddHtlc, processed_onion: Optional[ProcessedOnionPacket]) -> HtlcContext: + """ + Return the context of the htlc (see `HtlcContext`) to decide on how we should fail given htlc. + https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/02-peer-protocol.md?plain=1#L2972-L2984 + """ + if htlc.path_key: # we are got a `path_key` in `update_add_htlc` and are therefore inside a blinded path + return HtlcContext.INSIDE_PATH + if processed_onion is not None and processed_onion.blinded_path_recipient_data is not None \ + and not processed_onion.are_we_final: + return HtlcContext.INTRO_NODE + # if we are introduction node and final, the sender knows we are the recipient, so it seems fine + # to send a regular error (instead of overriding with INVALID_ONION_BLINDING) + return HtlcContext.DEFAULT + + def _maybe_defer_blinded_intro_fail(self, chan: Channel, htlc: UpdateAddHtlc) -> bool: + """ + As introduction point we should delay the failure by a random delay to prevent senders + from probing positions inside the blinded path. + https://github.com/lightning/bolts/blob/94eb038c42e664dd7862faeec6508ccd25f63ff8/02-peer-protocol.md?plain=1#L2979 + """ + if (chan, htlc.htlc_id) not in self._deferred_fail_htlcs: + delay = random.uniform(1.5, 3.0) + self._deferred_fail_htlcs[(chan, htlc.htlc_id)] = min(time.time() + delay, htlc.timestamp + 10) + return not self._blinded_intro_fail_delay_elapsed(chan, htlc.htlc_id) + + def _blinded_intro_fail_delay_elapsed(self, chan: Channel, htlc_id: int) -> bool: + """True if the htlc is ready to be failed (delay elapsed or no delay).""" + if self.lnworker.lnpeermgr.stopping_soon: + return True + deadline = self._deferred_fail_htlcs.get((chan, htlc_id)) + return deadline is None or time.time() >= deadline + def on_revoke_and_ack(self, chan: Channel, payload) -> None: self.logger.info(f'on_revoke_and_ack. chan {chan.short_channel_id}. ctn: {chan.get_oldest_unrevoked_ctn(REMOTE)}') if not chan.can_update_ctx(proposer=REMOTE): @@ -2924,18 +3018,16 @@ def _run_htlc_switch_iteration(self): continue htlc = chan.hm.get_htlc_by_id(REMOTE, htlc_id) + if not self._blinded_intro_fail_delay_elapsed(chan, htlc_id): + continue # failure already decided, don't re-process until the delay has elapsed + + onion_packet = None # type: Optional[OnionPacket] + processed_onion_packet = None # type: Optional[ProcessedOnionPacket] + error = None # type: Optional[OnionRoutingFailure] + unexpected_exc = None # type: Optional[Exception] + htlc_context = None # type: Optional[HtlcContext] try: onion_packet = self._parse_onion_packet(onion_packet_hex) - except OnionParsingError as e: - self.fail_malformed_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - reason=e, - ) - del unfulfilled[htlc_id] - continue - - try: processed_onion_packet = self._process_incoming_onion_packet( onion_packet, payment_hash=htlc.payment_hash, @@ -2953,31 +3045,32 @@ def _run_htlc_switch_iteration(self): htlc=htlc, unprocessed_onion_packet=onion_packet_hex, # outer onion if trampoline ) - except OnionParsingError as e: # could be raised when parsing the inner trampoline onion - self.fail_malformed_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - reason=e, - ) - except Exception as e: + except InvalidBlindedOnion: + error = OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=bytes(32)) + htlc_context = HtlcContext.INTRO_NODE if htlc.path_key is None else HtlcContext.INSIDE_PATH + except OnionRoutingFailure as e: # Fail the htlc directly if it fails to pass these tests, it will not get added to a htlc set. # https://github.com/lightning/bolts/blob/14272b1bd9361750cfdb3e5d35740889a6b510b5/04-onion-routing.md?plain=1#L388 - reraise = False - if isinstance(e, OnionRoutingFailure): - orf = e - else: - orf = OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') - reraise = True # propagate this out, as this might suggest a bug - error_bytes = orf.to_wire_msg(onion_packet, self.privkey, self.network.get_local_height()) - self.fail_htlc( - chan=chan, - htlc_id=htlc.htlc_id, - error_bytes=error_bytes, - ) - if reraise: - raise - finally: + error = e + except Exception as e: + error = OnionRoutingFailure(code=OnionFailureCode.TEMPORARY_NODE_FAILURE, data=b'') + unexpected_exc = e # propagate this out after failing the htlc, as this might suggest a bug + + if error is None: + del unfulfilled[htlc_id] + continue + htlc_context = htlc_context or self._get_htlc_context(htlc=htlc, processed_onion=processed_onion_packet) + if self._fail_incoming_htlc( + chan=chan, + htlc=htlc, + htlc_context=htlc_context, + error=error, + onion_packet=onion_packet, + can_defer=unexpected_exc is None, + ): del unfulfilled[htlc_id] + if unexpected_exc is not None: + raise unexpected_exc # 2. Step: Acting on sets of htlcs. # Doing further checks that have to be done on sets of htlcs (e.g. total amount checks) @@ -3378,7 +3471,7 @@ def _process_incoming_onion_packet( except InvalidOnionMac: raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_HMAC, data=onion_hash) except InvalidBlindedOnion: - raise OnionRoutingFailure(code=OnionFailureCode.INVALID_ONION_BLINDING, data=onion_hash) + raise # similar to OnionParsingError, except that we know this was a blinded payment except Exception as e: self.logger.warning(f"error processing onion packet: {e!r}") raise OnionParsingError(data=onion_hash) diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py index 19e023115521..254145695acc 100644 --- a/tests/test_lnpeer.py +++ b/tests/test_lnpeer.py @@ -782,6 +782,41 @@ async def test_simple_payment_failure_with_hold_invoice(self): with self.assertRaises(PaymentFailure): await self._test_simple_payment(test_trampoline=test_trampoline, test_hold_invoice=True, test_failure=True) + async def test_blinded_htlc_fails_with_invalid_onion_blinding(self): + """ + Alice -> Bob. Alice sets a path_key in update_add_htlc, Bob should fail with invalid_onion_blinding. + """ + graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) + p1, p2 = graph.peers.values() + w1, w2 = graph.workers.values() + path_key = ECPrivkey.generate_random_key().get_public_key_bytes() + orig_send_htlc = p1.send_htlc + def send_htlc_with_path_key(**kwargs): + return orig_send_htlc(**{**kwargs, 'next_path_key': path_key}) + + async def pay(pay_req): + result, log = await w1.pay_invoice(pay_req) + self.assertFalse(result) + self.assertEqual(OnionFailureCode.INVALID_ONION_BLINDING, log[0].failure_msg.code) + raise PaymentFailure() + + async def f(): + async with OldTaskGroup() as group: + for peer in (p1, p2): + await group.spawn(peer._message_loop()) + await group.spawn(peer.htlc_switch()) + for peer in (p1, p2): + await peer.initialized + lnaddr, pay_req = self.prepare_invoice(w2) + await group.spawn(pay(pay_req)) + + with mock.patch.object(p1, 'send_htlc', send_htlc_with_path_key): + with self.assertLogs('electrum', level='INFO') as logs: + with self.assertRaises(PaymentFailure): + await f() + self.assertTrue(any('bob->alice' in msg and 'fail_malformed_htlc' in msg for msg in logs.output)) + self.assertTrue(any('alice->bob' in msg and 'on_update_fail_malformed_htlc' in msg for msg in logs.output)) + async def test_check_invoice_before_payment(self): graph = self.prepare_chans_and_peers_in_graph(self.GRAPH_DEFINITIONS['single_chan']) p1, p2 = graph.peers.values() From bdec56d93a7e24fabfeb2aa645fed214b51e232e Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 5 Dec 2025 17:58:35 +0100 Subject: [PATCH 4/4] lnworker: add ROUTE_BLINDING features to LNWALLET_FEATURES ... and lnutil.LN_FEATURES_IMPLEMENTED --- electrum/lnutil.py | 1 + electrum/lnworker.py | 1 + 2 files changed, 2 insertions(+) diff --git a/electrum/lnutil.py b/electrum/lnutil.py index d5176f6e9ad3..80ac7c2772cc 100644 --- a/electrum/lnutil.py +++ b/electrum/lnutil.py @@ -1713,6 +1713,7 @@ def name_minimal(self): | LnFeatures.OPTION_ANCHORS_OPT | LnFeatures.OPTION_ANCHORS_REQ | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_OPT | LnFeatures.OPTION_UPFRONT_SHUTDOWN_SCRIPT_REQ | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT | LnFeatures.OPTION_ROUTE_BLINDING_REQ ) diff --git a/electrum/lnworker.py b/electrum/lnworker.py index 3d8599debcb3..87ccc5081825 100644 --- a/electrum/lnworker.py +++ b/electrum/lnworker.py @@ -210,6 +210,7 @@ class ErrorAddingPeer(Exception): pass | LnFeatures.OPTION_SCID_ALIAS_OPT | LnFeatures.OPTION_SUPPORT_LARGE_CHANNEL_OPT | LnFeatures.OPTION_CHANNEL_TYPE_REQ + | LnFeatures.OPTION_ROUTE_BLINDING_OPT ) LNGOSSIP_FEATURES = (