trampoline: allow trampoline onion packets of arbitrary size
What changed, and why it matters
This commit changes how Electrum handles 'trampoline' routing packets in the Lightning Network. Previously, trampoline onion packets were forced to a fixed 400-byte size. The patch allows them to be variable length and introduces a new maximum of 500 bytes. The change removes several length checks and restructures how the packet is encoded. Without a vendor statement, it is unclear whether this is a routine protocol update or a security fix, but removing fixed-size validation can sometimes hide or enable bugs if not carefully reviewed.
Treat as a protocol-compatibility change requiring careful review. Verify that variable-length trampoline packets cannot be used to trigger buffer over-reads, parsing failures, or denial-of-service conditions in process_onion_packet or downstream handlers. Confirm that the 500-byte maximum is enforced on receipt, not only during construction. Monitor project release notes and issue tracker for any follow-up security advisory.
Security signals we found
Removal of fixed-size length assertions on onion packet serialization/deserialization
Introduction of variable-length trampoline onion packet wire encoding
New maximum trampoline hops data size (500 bytes) used during construction
process_onion_packet now derives trampoline data_size from actual packet length
No explicit security rationale or CVE reference in commit message
Evidence from the diff
The patch removes the fixed TRAMPOLINE_HOPS_DATA_SIZE (400 bytes) constraint on trampoline onion packets. It changes OnionPacket validation/serialization to accept arbitrary hops_data lengths, sets new_onion_packet to use payload_size for trampoline data_size, and process_onion_packet to derive data_size from len(onion_packet.hops_data). The wire format for trampoline_onion_packet is collapsed from separate version/public_key/hops_data/hmac fields into a single variable-length byte field. A new constant TRAMPOLINE_HOPS_MAX_DATA_SIZE = 500 is introduced and used when packing invoice routing info into trampoline hops. The commit is small and focused, but the removal of length assertions and the shift to variable-length parsing increases the importance of correct bounds handling elsewhere.
Changed components
electrum/lnonion.pyelectrum/lnwire/onion_wire.csvelectrum/lnworker.pyelectrum/trampoline.pytests/test_lnpeer.pyInspect captured patch +11 / −31
diff --git a/electrum/lnonion.py b/electrum/lnonion.py
index 7760fd4..10fb229 100644
--- a/electrum/lnonion.py
+++ b/electrum/lnonion.py
@@ -47,7 +47,6 @@ if TYPE_CHECKING:
HOPS_DATA_SIZE = 1300 # also sometimes called routingInfoSize in bolt-04
-TRAMPOLINE_HOPS_DATA_SIZE = 400
PER_HOP_HMAC_SIZE = 32
ONION_MESSAGE_LARGE_SIZE = 32768
@@ -134,7 +133,6 @@ class OnionPacket:
def __post_init__(self):
assert len(self.public_key) == 33
- assert len(self.hops_data) in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]
assert len(self.hmac) == PER_HOP_HMAC_SIZE
if not ecc.ECPubkey.is_pubkey_bytes(self.public_key):
raise InvalidOnionPubkey()
@@ -144,14 +142,10 @@ class OnionPacket:
ret += self.public_key
ret += self.hops_data
ret += self.hmac
- if len(ret) - 66 not in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]:
- raise Exception('unexpected length {}'.format(len(ret)))
return ret
@classmethod
def from_bytes(cls, b: bytes) -> 'OnionPacket':
- if len(b) - 66 not in [HOPS_DATA_SIZE, TRAMPOLINE_HOPS_DATA_SIZE, ONION_MESSAGE_LARGE_SIZE]:
- raise Exception('unexpected length {}'.format(len(b)))
return OnionPacket(
public_key=b[1:34],
hops_data=b[34:-32],
@@ -217,7 +211,7 @@ def new_onion_packet(
# FIXME: serializing here and again below. cache bytes in OnionHopsDataSingle? _raw_bytes_payload?
payload_size += PER_HOP_HMAC_SIZE + len(hops_data[i].to_bytes())
if trampoline:
- data_size = TRAMPOLINE_HOPS_DATA_SIZE
+ data_size = payload_size
elif onion_message:
if payload_size <= HOPS_DATA_SIZE:
data_size = HOPS_DATA_SIZE
@@ -446,7 +440,7 @@ def process_onion_packet(
raise InvalidOnionMac()
# peel an onion layer off
rho_key = get_bolt04_onion_key(b'rho', shared_secret)
- data_size = TRAMPOLINE_HOPS_DATA_SIZE if is_trampoline else HOPS_DATA_SIZE
+ data_size = len(onion_packet.hops_data) if is_trampoline else HOPS_DATA_SIZE
if is_onion_message and len(onion_packet.hops_data) > HOPS_DATA_SIZE:
data_size = ONION_MESSAGE_LARGE_SIZE
stream_bytes = generate_cipher_stream(rho_key, 2 * data_size)
@@ -459,15 +453,8 @@ def process_onion_packet(
if trampoline_onion_packet:
if is_trampoline:
raise Exception("found nested trampoline inside trampoline")
- top_version = trampoline_onion_packet.get('version')
- top_public_key = trampoline_onion_packet.get('public_key')
- top_hops_data = trampoline_onion_packet.get('hops_data')
- top_hops_data_fd = io.BytesIO(top_hops_data)
- top_hmac = trampoline_onion_packet.get('hmac')
- trampoline_onion_packet = OnionPacket(
- public_key=top_public_key,
- hops_data=top_hops_data_fd.read(TRAMPOLINE_HOPS_DATA_SIZE),
- hmac=top_hmac)
+ trampoline_onion_packet = trampoline_onion_packet['trampoline_onion_packet']
+ trampoline_onion_packet = OnionPacket.from_bytes(trampoline_onion_packet)
# calc next ephemeral key
blinding_factor = sha256(onion_packet.public_key + shared_secret)
blinding_factor_int = int.from_bytes(blinding_factor, byteorder="big")
diff --git a/electrum/lnwire/onion_wire.csv b/electrum/lnwire/onion_wire.csv
index 08e4039..06b55da 100644
--- a/electrum/lnwire/onion_wire.csv
+++ b/electrum/lnwire/onion_wire.csv
@@ -22,10 +22,7 @@ tlvdata,payload,outgoing_node_id,outgoing_node_id,byte,33
tlvtype,payload,invoice_routing_info,66099
tlvdata,payload,invoice_routing_info,invoice_routing_info,byte,...
tlvtype,payload,trampoline_onion_packet,66100
-tlvdata,payload,trampoline_onion_packet,version,byte,1
-tlvdata,payload,trampoline_onion_packet,public_key,byte,33
-tlvdata,payload,trampoline_onion_packet,hops_data,byte,400
-tlvdata,payload,trampoline_onion_packet,hmac,byte,32
+tlvdata,payload,trampoline_onion_packet,trampoline_onion_packet,byte,...
tlvtype,encrypted_data_tlv,padding,1
tlvdata,encrypted_data_tlv,padding,padding,byte,...
tlvtype,encrypted_data_tlv,short_channel_id,2
diff --git a/electrum/lnworker.py b/electrum/lnworker.py
index 9727eca..bbd63b6 100644
--- a/electrum/lnworker.py
+++ b/electrum/lnworker.py
@@ -4094,10 +4094,7 @@ class LNWallet(Logger):
self.logger.info(f'adding trampoline onion to final payload')
trampoline_payload = dict(hops_data[-1].payload)
trampoline_payload["trampoline_onion_packet"] = {
- "version": trampoline_onion.version,
- "public_key": trampoline_onion.public_key,
- "hops_data": trampoline_onion.hops_data,
- "hmac": trampoline_onion.hmac
+ "trampoline_onion_packet": trampoline_onion.to_bytes()
}
hops_data[-1] = dataclasses.replace(hops_data[-1], payload=trampoline_payload)
if t_hops_data := trampoline_onion._debug_hops_data: # None if trampoline-forwarding
diff --git a/electrum/trampoline.py b/electrum/trampoline.py
index 53365fc..78f13c5 100644
--- a/electrum/trampoline.py
+++ b/electrum/trampoline.py
@@ -7,7 +7,7 @@ from types import MappingProxyType
from .lnutil import LnFeatures, PaymentFeeBudget, FeeBudgetExceeded
from .lnonion import (
- calc_hops_data_for_payment, new_onion_packet, OnionPacket, TRAMPOLINE_HOPS_DATA_SIZE, PER_HOP_HMAC_SIZE
+ calc_hops_data_for_payment, new_onion_packet, OnionPacket, PER_HOP_HMAC_SIZE
)
from .lnrouter import TrampolineEdge, is_route_within_budget, LNPaymentTRoute
from .lnutil import NoPathFound
@@ -40,6 +40,8 @@ TRAMPOLINE_NODES_SIGNET = {
_TRAMPOLINE_NODES_UNITTESTS = {} # used in unit tests
+TRAMPOLINE_HOPS_MAX_DATA_SIZE = 500
+
def hardcoded_trampoline_nodes() -> Mapping[str, LNPeerAddr]:
if _TRAMPOLINE_NODES_UNITTESTS:
@@ -332,7 +334,7 @@ def create_trampoline_onion(
payload = dict(hops_data[index].payload)
# try different r_tag order on each attempt
invoice_routing_info = random_shuffled_copy(route[index].invoice_routing_info)
- remaining_payload_space = TRAMPOLINE_HOPS_DATA_SIZE \
+ remaining_payload_space = TRAMPOLINE_HOPS_MAX_DATA_SIZE \
- sum(len(hop.to_bytes()) + PER_HOP_HMAC_SIZE for hop in hops_data)
routing_info_to_use = []
for encoded_r_tag in invoice_routing_info:
diff --git a/tests/test_lnpeer.py b/tests/test_lnpeer.py
index 6fdb6c0..8669931 100644
--- a/tests/test_lnpeer.py
+++ b/tests/test_lnpeer.py
@@ -2742,10 +2742,7 @@ class TestPeerForwarding(TestPeer):
assert len(hops_data) == 1
new_payload = dict(hops_data[0].payload)
new_payload['trampoline_onion_packet'] = {
- "version": modified_trampoline_onion.version,
- "public_key": modified_trampoline_onion.public_key,
- "hops_data": modified_trampoline_onion.hops_data,
- "hmac": modified_trampoline_onion.hmac,
+ "trampoline_onion_packet": modified_trampoline_onion.to_bytes()
}
hops_data[0] = dataclasses.replace(hops_data[0], payload=MappingProxyType(new_payload))
modified_trampoline_onion = None
Why this scored 47/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.