Fix trampoline error packet encryption using trampoline shared secret
What changed, and why it matters
This commit fixes a bug in the Lightning Dev Kit where failure messages for trampoline-routed payments were encrypted with the wrong secret. As a result, the original sender could not read error messages when a payment failed inside a trampoline route. The fix makes the code use the trampoline-specific shared secret for encrypting those failure messages, falling back to the phantom secret only when no trampoline secret is available.
Review whether any additional failure paths (e.g., trampoline combined with phantom/MPP edge cases) also need the trampoline shared secret, and consider adding regression tests that verify failure packets are decryptable by trampoline senders. The TODO in the commit suggests the trampoline+phantom double-wrap case is not yet handled.
Security signals we found
Wrong shared secret used for failure-packet encryption in trampoline routing
Trampoline senders unable to decrypt failure messages
New secret field threaded through HTLC state and serialization
Prioritization of trampoline secret over phantom secret when both present
TODO comment notes incomplete handling of trampoline+phantom combined case
Evidence from the diff
In rust-lightning, when an HTLC fails, the node encrypts the failure packet so that the previous hop (and ultimately the sender) can decrypt it. For trampoline payments, the correct shared secret for this encryption is the trampoline_shared_secret, but the code was ignoring it and using the incoming_packet_shared_secret plus an optional phantom_shared_secret. The patch threads trampoline_shared_secret through PendingHTLCRouting::Receive, HTLCPreviousHopData, and the failure-handling path in ChannelManager, then selects trampoline_shared_secret.or(phantom_shared_secret) when calling get_encrypted_failure_packet. The change also removes #[allow(unused)] annotations from the TrampolineReceive/TrampolineBlindedReceive variants in onion_utils.rs because the field is now used.
Changed components
lightning/src/ln/channelmanager.rslightning/src/ln/onion_payment.rslightning/src/ln/onion_utils.rsInspect captured patch +33 / −9
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 4d3a3ea..6c4fec4 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -277,6 +277,9 @@ pub enum PendingHTLCRouting {
/// provide the onion shared secret used to decrypt the next level of forwarding
/// instructions.
phantom_shared_secret: Option<[u8; 32]>,
+ /// If the onion had trampoline forwarding instruction to our node.
+ /// This will provice the onion shared secret to encrypt error packets to the sender.
+ trampoline_shared_secret: Option<[u8; 32]>,
/// Custom TLVs which were set by the sender.
///
/// For HTLCs received by LDK, this will ultimately be exposed in
@@ -466,6 +469,13 @@ impl PendingAddHTLCInfo {
PendingHTLCRouting::Receive { phantom_shared_secret, .. } => phantom_shared_secret,
_ => None,
};
+ let trampoline_shared_secret = match self.forward_info.routing {
+ PendingHTLCRouting::Receive { trampoline_shared_secret, .. } => {
+ trampoline_shared_secret
+ },
+ _ => None,
+ };
+
HTLCPreviousHopData {
short_channel_id: self.prev_short_channel_id,
user_channel_id: Some(self.prev_user_channel_id),
@@ -475,6 +485,7 @@ impl PendingAddHTLCInfo {
htlc_id: self.prev_htlc_id,
incoming_packet_shared_secret: self.forward_info.incoming_shared_secret,
phantom_shared_secret,
+ trampoline_shared_secret,
blinded_failure: self.forward_info.routing.blinded_failure(),
cltv_expiry: self.forward_info.routing.incoming_cltv_expiry(),
}
@@ -798,6 +809,7 @@ mod fuzzy_channelmanager {
pub htlc_id: u64,
pub incoming_packet_shared_secret: [u8; 32],
pub phantom_shared_secret: Option<[u8; 32]>,
+ pub trampoline_shared_secret: Option<[u8; 32]>,
pub blinded_failure: Option<BlindedFailure>,
pub channel_id: ChannelId,
@@ -7276,6 +7288,7 @@ where
mut onion_fields,
has_recipient_created_payment_secret,
invoice_request_opt,
+ trampoline_shared_secret,
) = match routing {
PendingHTLCRouting::Receive {
payment_data,
@@ -7283,6 +7296,7 @@ where
payment_context,
incoming_cltv_expiry,
phantom_shared_secret,
+ trampoline_shared_secret,
custom_tlvs,
requires_blinded_error: _,
} => {
@@ -7301,6 +7315,7 @@ where
onion_fields,
true,
None,
+ trampoline_shared_secret,
)
},
PendingHTLCRouting::ReceiveKeysend {
@@ -7330,6 +7345,7 @@ where
onion_fields,
has_recipient_created_payment_secret,
invoice_request,
+ None,
)
},
_ => {
@@ -7377,6 +7393,7 @@ where
htlc_id: $htlc.prev_hop.htlc_id,
incoming_packet_shared_secret,
phantom_shared_secret,
+ trampoline_shared_secret,
blinded_failure,
cltv_expiry: Some(cltv_expiry),
}),
@@ -8176,6 +8193,7 @@ where
ref htlc_id,
ref incoming_packet_shared_secret,
ref phantom_shared_secret,
+ ref trampoline_shared_secret,
outpoint: _,
ref blinded_failure,
ref channel_id,
@@ -8188,6 +8206,9 @@ where
&payment_hash,
onion_error
);
+ // In case of trampoline + phantom we prioritize the trampoline failure over the phantom failure.
+ // TODO: Correctly wrap the error packet twice if failing back a trampoline + phantom HTLC.
+ let secondary_shared_secret = trampoline_shared_secret.or(*phantom_shared_secret);
let failure = match blinded_failure {
Some(BlindedFailure::FromIntroductionNode) => {
let blinded_onion_error = HTLCFailReason::reason(
@@ -8196,7 +8217,7 @@ where
);
let err_packet = blinded_onion_error.get_encrypted_failure_packet(
incoming_packet_shared_secret,
- phantom_shared_secret,
+ &secondary_shared_secret,
);
HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet }
},
@@ -8208,7 +8229,7 @@ where
None => {
let err_packet = onion_error.get_encrypted_failure_packet(
incoming_packet_shared_secret,
- phantom_shared_secret,
+ &secondary_shared_secret,
);
HTLCForwardInfo::FailHTLC { htlc_id: *htlc_id, err_packet }
},
@@ -15173,6 +15194,7 @@ impl_writeable_tlv_based_enum!(PendingHTLCRouting,
(5, custom_tlvs, optional_vec),
(7, requires_blinded_error, (default_value, false)),
(9, payment_context, option),
+ (11, trampoline_shared_secret, option),
},
(2, ReceiveKeysend) => {
(0, payment_preimage, required),
@@ -15301,6 +15323,7 @@ impl_writeable_tlv_based!(HTLCPreviousHopData, {
// filled in, so we can safely unwrap it here.
(9, channel_id, (default_value, ChannelId::v1_from_funding_outpoint(outpoint.0.unwrap()))),
(11, counterparty_node_id, option),
+ (13, trampoline_shared_secret, option),
});
impl Writeable for ClaimableHTLC {
diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs
index 0934c6c..63896bb 100644
--- a/lightning/src/ln/onion_payment.rs
+++ b/lightning/src/ln/onion_payment.rs
@@ -253,14 +253,14 @@ pub(super) fn create_recv_pending_htlc_info(
let (
payment_data, keysend_preimage, custom_tlvs, onion_amt_msat, onion_cltv_expiry,
payment_metadata, payment_context, requires_blinded_error, has_recipient_created_payment_secret,
- invoice_request
+ invoice_request, trampoline_shared_secret,
) = match hop_data {
onion_utils::Hop::Receive { hop_data: msgs::InboundOnionReceivePayload {
payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat,
cltv_expiry_height, payment_metadata, ..
}, .. } =>
(payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat,
- cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None),
+ cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None, None),
onion_utils::Hop::BlindedReceive { hop_data: msgs::InboundOnionBlindedReceivePayload {
sender_intended_htlc_amt_msat, total_msat, cltv_expiry_height, payment_secret,
intro_node_blinding_point, payment_constraints, payment_context, keysend_preimage,
@@ -279,17 +279,19 @@ pub(super) fn create_recv_pending_htlc_info(
let payment_data = msgs::FinalOnionHopData { payment_secret, total_msat };
(Some(payment_data), keysend_preimage, custom_tlvs,
sender_intended_htlc_amt_msat, cltv_expiry_height, None, Some(payment_context),
- intro_node_blinding_point.is_none(), true, invoice_request)
+ intro_node_blinding_point.is_none(), true, invoice_request, None)
}
onion_utils::Hop::TrampolineReceive {
+ trampoline_shared_secret,
trampoline_hop_data: msgs::InboundOnionReceivePayload {
payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat,
cltv_expiry_height, payment_metadata, ..
}, ..
} =>
(payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat,
- cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None),
+ cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None, Some(trampoline_shared_secret.secret_bytes())),
onion_utils::Hop::TrampolineBlindedReceive {
+ trampoline_shared_secret,
trampoline_hop_data: msgs::InboundOnionBlindedReceivePayload {
sender_intended_htlc_amt_msat, total_msat, cltv_expiry_height, payment_secret,
intro_node_blinding_point, payment_constraints, payment_context, keysend_preimage,
@@ -309,7 +311,7 @@ pub(super) fn create_recv_pending_htlc_info(
let payment_data = msgs::FinalOnionHopData { payment_secret, total_msat };
(Some(payment_data), keysend_preimage, custom_tlvs,
sender_intended_htlc_amt_msat, cltv_expiry_height, None, Some(payment_context),
- intro_node_blinding_point.is_none(), true, invoice_request)
+ intro_node_blinding_point.is_none(), true, invoice_request, Some(trampoline_shared_secret.secret_bytes()))
},
onion_utils::Hop::Forward { .. } => {
return Err(InboundHTLCErr {
@@ -398,6 +400,7 @@ pub(super) fn create_recv_pending_htlc_info(
payment_context,
incoming_cltv_expiry: onion_cltv_expiry,
phantom_shared_secret,
+ trampoline_shared_secret,
custom_tlvs,
requires_blinded_error,
}
diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs
index 46404c0..abd2243 100644
--- a/lightning/src/ln/onion_utils.rs
+++ b/lightning/src/ln/onion_utils.rs
@@ -2195,7 +2195,6 @@ pub(crate) enum Hop {
outer_hop_data: msgs::InboundTrampolineEntrypointPayload,
outer_shared_secret: SharedSecret,
trampoline_hop_data: msgs::InboundOnionReceivePayload,
- #[allow(unused)]
trampoline_shared_secret: SharedSecret,
},
/// This onion payload was for us, not for forwarding to a next-hop, and it was sent to us via
@@ -2205,7 +2204,6 @@ pub(crate) enum Hop {
outer_hop_data: msgs::InboundTrampolineEntrypointPayload,
outer_shared_secret: SharedSecret,
trampoline_hop_data: msgs::InboundOnionBlindedReceivePayload,
- #[allow(unused)]
trampoline_shared_secret: SharedSecret,
},
}
Why this scored 57/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.