Introduce Payment Dummy Hop parsing mechanism
What changed, and why it matters
This commit adds support for 'dummy hops' in Lightning payment routes. These are fake routing steps used to pad and hide the real path of a payment. The change lets a node strip off one of these padding layers locally and continue processing the payment, instead of trying to forward it over a real channel. It is a feature addition, not a clear-cut security fix, but it touches sensitive onion-routing code where mistakes could let an attacker bypass fees, routing checks, or forward payments incorrectly.
Treat this as a high-sensitivity feature commit rather than a confirmed vulnerability. Reviewers should verify that dummy hops cannot be used to skip trampoline or real-channel validation, that re-queued HTLCs preserve all required authentication fields, that amount/CLTV constraints are strictly enforced before peeling, and that the debug_assert! fallbacks in release builds do not allow dummy hops to reach HTLC processing silently. No immediate patch is indicated, but careful code review and targeted fuzzing of the new Dummy path are warranted.
Security signals we found
New network message variant handling (InboundOnionPayload::Dummy / InboundOnionDummyPayload)
Local re-queuing of reconstructed UpdateAddHTLC after peeling a dummy hop
Addition of defensive debug_assert! guards to prevent dummy hops from entering normal HTLC forwarding
Changes to blinded path advancement and onion decode logic
No explicit CVE, advisory, or vendor security statement present in the commit or supplied references
Evidence from the diff
The patch introduces parsing and local peeling of InboundOnionPayload::Dummy hops in rust-lightning. It extends BlindedPaymentPath::advance_path_by_hop to accept Dummy TLVs, adds Hop::Dummy and HopConnector::Dummy variants, and implements onion_utils::peel_dummy_hop_update_add_htlc to reconstruct the next UpdateAddHTLC after removing a dummy layer. Dummy hops are explicitly rejected from normal HTLC forwarding via debug_assert! and InvalidOnionPayload failures, and are re-queued for another local decode iteration. The code relies on existing blinded-forward amount/CLTV constraints and shared-secret derivation.
Changed components
lightning/src/blinded_path/payment.rslightning/src/ln/channelmanager.rslightning/src/ln/onion_payment.rslightning/src/ln/onion_utils.rsInspect captured patch +189 / −29
diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs
index f0bf3f9..b68be81 100644
--- a/lightning/src/blinded_path/payment.rs
+++ b/lightning/src/blinded_path/payment.rs
@@ -33,7 +33,6 @@ use crate::util::ser::{
Writeable, Writer,
};
-use core::mem;
use core::ops::Deref;
#[allow(unused_imports)]
@@ -248,28 +247,31 @@ impl BlindedPaymentPath {
NL::Target: NodeIdLookUp,
T: secp256k1::Signing + secp256k1::Verification,
{
- match self.decrypt_intro_payload::<NS>(node_signer) {
- Ok((
- BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }),
- control_tlvs_ss,
- )) => {
- let next_node_id = match node_id_lookup.next_node_id(short_channel_id) {
- Some(node_id) => node_id,
- None => return Err(()),
- };
- let mut new_blinding_point = onion_utils::next_hop_pubkey(
- secp_ctx,
- self.inner_path.blinding_point,
- control_tlvs_ss.as_ref(),
- )
- .map_err(|_| ())?;
- mem::swap(&mut self.inner_path.blinding_point, &mut new_blinding_point);
- self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id);
- self.inner_path.blinded_hops.remove(0);
- Ok(())
- },
- _ => Err(()),
- }
+ let (next_node_id, control_tlvs_ss) =
+ match self.decrypt_intro_payload::<NS>(node_signer).map_err(|_| ())? {
+ (BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, .. }), ss) => {
+ let node_id = node_id_lookup.next_node_id(short_channel_id).ok_or(())?;
+ (node_id, ss)
+ },
+ (BlindedPaymentTlvs::Dummy(_), ss) => {
+ let node_id = node_signer.get_node_id(Recipient::Node)?;
+ (node_id, ss)
+ },
+ _ => return Err(()),
+ };
+
+ let new_blinding_point = onion_utils::next_hop_pubkey(
+ secp_ctx,
+ self.inner_path.blinding_point,
+ control_tlvs_ss.as_ref(),
+ )
+ .map_err(|_| ())?;
+
+ self.inner_path.blinding_point = new_blinding_point;
+ self.inner_path.introduction_node = IntroductionNode::NodeId(next_node_id);
+ self.inner_path.blinded_hops.remove(0);
+
+ Ok(())
}
pub(crate) fn decrypt_intro_payload<NS: Deref>(
@@ -291,9 +293,9 @@ impl BlindedPaymentPath {
.map_err(|_| ())?;
match (&readable, used_aad) {
- (BlindedPaymentTlvs::Forward(_), false) | (BlindedPaymentTlvs::Receive(_), true) => {
- Ok((readable, control_tlvs_ss))
- },
+ (BlindedPaymentTlvs::Forward(_), false)
+ | (BlindedPaymentTlvs::Dummy(_), true)
+ | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)),
_ => Err(()),
}
}
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index aef57a6..b55b179 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -4974,6 +4974,11 @@ where
) -> Result<(), LocalHTLCFailureReason> {
let outgoing_scid = match next_packet_details.outgoing_connector {
HopConnector::ShortChannelId(scid) => scid,
+ HopConnector::Dummy => {
+ // Dummy hops are only used for path padding and must not reach HTLC processing.
+ debug_assert!(false, "Dummy hop reached HTLC handling.");
+ return Err(LocalHTLCFailureReason::InvalidOnionPayload);
+ }
HopConnector::Trampoline(_) => {
return Err(LocalHTLCFailureReason::InvalidTrampolineForward);
}
@@ -6878,6 +6883,7 @@ where
fn process_pending_update_add_htlcs(&self) -> bool {
let mut should_persist = false;
let mut decode_update_add_htlcs = new_hash_map();
+ let mut dummy_update_add_htlcs = new_hash_map();
mem::swap(&mut decode_update_add_htlcs, &mut self.decode_update_add_htlcs.lock().unwrap());
let get_htlc_failure_type = |outgoing_scid_opt: Option<u64>, payment_hash: PaymentHash| {
@@ -6941,7 +6947,36 @@ where
&*self.logger,
&self.secp_ctx,
) {
- Ok(decoded_onion) => decoded_onion,
+ Ok(decoded_onion) => match decoded_onion {
+ (
+ onion_utils::Hop::Dummy {
+ dummy_hop_data,
+ next_hop_hmac,
+ new_packet_bytes,
+ ..
+ },
+ Some(next_packet_details),
+ ) => {
+ let new_update_add_htlc =
+ onion_utils::peel_dummy_hop_update_add_htlc(
+ update_add_htlc,
+ dummy_hop_data,
+ next_hop_hmac,
+ new_packet_bytes,
+ next_packet_details,
+ &*self.node_signer,
+ &self.secp_ctx,
+ );
+
+ dummy_update_add_htlcs
+ .entry(incoming_scid_alias)
+ .or_insert_with(Vec::new)
+ .push(new_update_add_htlc);
+
+ continue;
+ },
+ _ => decoded_onion,
+ },
Err((htlc_fail, reason)) => {
let failure_type = HTLCHandlingFailureType::InvalidOnion;
@@ -6954,6 +6989,13 @@ where
let outgoing_scid_opt =
next_packet_details_opt.as_ref().and_then(|d| match d.outgoing_connector {
HopConnector::ShortChannelId(scid) => Some(scid),
+ HopConnector::Dummy => {
+ debug_assert!(
+ false,
+ "Dummy hops must never be processed at this stage."
+ );
+ None
+ },
HopConnector::Trampoline(_) => None,
});
let shared_secret = next_hop.shared_secret().secret_bytes();
@@ -7097,6 +7139,19 @@ where
));
}
}
+
+ // Merge peeled dummy HTLCs into the existing decode queue so they can be
+ // processed in the next iteration. We avoid replacing the whole queue
+ // (e.g. via mem::swap) because other threads may have enqueued new HTLCs
+ // meanwhile; merging preserves everything safely.
+ if !dummy_update_add_htlcs.is_empty() {
+ let mut decode_update_add_htlc_source = self.decode_update_add_htlcs.lock().unwrap();
+
+ for (incoming_scid_alias, htlcs) in dummy_update_add_htlcs.into_iter() {
+ decode_update_add_htlc_source.entry(incoming_scid_alias).or_default().extend(htlcs);
+ }
+ }
+
should_persist
}
diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs
index c1d07f7..9e8672a 100644
--- a/lightning/src/ln/onion_payment.rs
+++ b/lightning/src/ln/onion_payment.rs
@@ -494,7 +494,7 @@ where
L::Target: Logger,
{
let (hop, next_packet_details_opt) =
- decode_incoming_update_add_htlc_onion(msg, node_signer, logger, secp_ctx
+ decode_incoming_update_add_htlc_onion(msg, &*node_signer, &*logger, secp_ctx
).map_err(|(msg, failure_reason)| {
let (reason, err_data) = match msg {
HTLCFailureMsg::Malformed(_) => (failure_reason, Vec::new()),
@@ -532,6 +532,29 @@ where
// onion here and check it.
create_fwd_pending_htlc_info(msg, hop, shared_secret.secret_bytes(), Some(next_packet_pubkey))?
},
+ onion_utils::Hop::Dummy { dummy_hop_data, next_hop_hmac, new_packet_bytes, .. } => {
+ let next_packet_details = match next_packet_details_opt {
+ Some(next_packet_details) => next_packet_details,
+ // Dummy Hops should always include the next hop details
+ None => return Err(InboundHTLCErr {
+ msg: "Failed to decode update add htlc onion",
+ reason: LocalHTLCFailureReason::InvalidOnionPayload,
+ err_data: Vec::new(),
+ }),
+ };
+
+ let new_update_add_htlc = onion_utils::peel_dummy_hop_update_add_htlc(
+ msg,
+ dummy_hop_data,
+ next_hop_hmac,
+ new_packet_bytes,
+ next_packet_details,
+ &*node_signer,
+ secp_ctx
+ );
+
+ peel_payment_onion(&new_update_add_htlc, node_signer, logger, secp_ctx, cur_height, allow_skimmed_fees)?
+ },
_ => {
let shared_secret = hop.shared_secret().secret_bytes();
create_recv_pending_htlc_info(
@@ -545,6 +568,8 @@ where
pub(super) enum HopConnector {
// scid-based routing
ShortChannelId(u64),
+ // Dummy hop for path padding
+ Dummy,
// Trampoline-based routing
#[allow(unused)]
Trampoline(PublicKey),
@@ -649,6 +674,22 @@ where
outgoing_cltv_value
})
}
+ onion_utils::Hop::Dummy { dummy_hop_data: msgs::InboundOnionDummyPayload { ref payment_relay, ref payment_constraints, .. }, shared_secret, .. } => {
+ let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward(
+ msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &BlindedHopFeatures::empty()
+ ) {
+ Ok((amt, cltv)) => (amt, cltv),
+ Err(()) => {
+ return encode_relay_error("Underflow calculating outbound amount or cltv value for blinded forward",
+ LocalHTLCFailureReason::InvalidOnionBlinding, shared_secret.secret_bytes(), None, &[0; 32]);
+ }
+ };
+
+ let next_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx,
+ msg.onion_routing_packet.public_key.unwrap(), &shared_secret.secret_bytes());
+
+ Some(NextPacketDetails { next_packet_pubkey, outgoing_connector: HopConnector::Dummy, outgoing_amt_msat: amt_to_forward, outgoing_cltv_value })
+ }
onion_utils::Hop::TrampolineForward { next_trampoline_hop_data: msgs::InboundTrampolineForwardPayload { amt_to_forward, outgoing_cltv_value, next_trampoline }, trampoline_shared_secret, incoming_trampoline_public_key, .. } => {
let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx,
incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes());
diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs
index 7e87954..b82c60a 100644
--- a/lightning/src/ln/onion_utils.rs
+++ b/lightning/src/ln/onion_utils.rs
@@ -14,7 +14,8 @@ use crate::crypto::streams::ChaChaReader;
use crate::events::HTLCHandlingFailureReason;
use crate::ln::channel::TOTAL_BITCOIN_SUPPLY_SATOSHIS;
use crate::ln::channelmanager::{HTLCSource, RecipientOnionFields};
-use crate::ln::msgs::{self, DecodeError};
+use crate::ln::msgs::{self, DecodeError, InboundOnionDummyPayload, OnionPacket, UpdateAddHTLC};
+use crate::ln::onion_payment::{HopConnector, NextPacketDetails};
use crate::offers::invoice_request::InvoiceRequest;
use crate::routing::gossip::NetworkUpdate;
use crate::routing::router::{BlindedTail, Path, RouteHop, RouteParameters, TrampolineHop};
@@ -2356,6 +2357,12 @@ where
new_packet_bytes,
})
},
+ msgs::InboundOnionPayload::Dummy(dummy_hop_data) => Ok(Hop::Dummy {
+ dummy_hop_data,
+ shared_secret,
+ next_hop_hmac,
+ new_packet_bytes,
+ }),
_ => {
if blinding_point.is_some() {
return Err(OnionDecodeErr::Malformed {
@@ -2533,6 +2540,61 @@ where
}
}
+/// Peels a single dummy hop from an inbound `UpdateAddHTLC` by reconstructing the next
+/// onion packet and HTLC state.
+///
+/// This helper is used when processing dummy hops in a blinded path. Dummy hops are not
+/// forwarded on the network; instead, their onion layer is removed locally and a new
+/// `UpdateAddHTLC` is constructed with the next onion packet and updated amount/CLTV
+/// values.
+///
+/// This function performs no validation and does not enqueue or forward the HTLC.
+/// It only reconstructs the next `UpdateAddHTLC` for further local processing.
+pub(super) fn peel_dummy_hop_update_add_htlc<NS: Deref, T: secp256k1::Verification>(
+ msg: &UpdateAddHTLC, dummy_hop_data: InboundOnionDummyPayload, next_hop_hmac: [u8; 32],
+ new_packet_bytes: [u8; ONION_DATA_LEN], next_packet_details: NextPacketDetails,
+ node_signer: NS, secp_ctx: &Secp256k1<T>,
+) -> UpdateAddHTLC
+where
+ NS::Target: NodeSigner,
+{
+ let NextPacketDetails {
+ next_packet_pubkey,
+ outgoing_amt_msat,
+ outgoing_connector,
+ outgoing_cltv_value,
+ } = next_packet_details;
+
+ debug_assert!(
+ matches!(outgoing_connector, HopConnector::Dummy),
+ "Dummy hop must always map to HopConnector::Dummy"
+ );
+
+ let next_blinding_point = dummy_hop_data
+ .intro_node_blinding_point
+ .or(msg.blinding_point)
+ .and_then(|blinding_point| {
+ let ss = node_signer.ecdh(Recipient::Node, &blinding_point, None).ok()?.secret_bytes();
+
+ next_hop_pubkey(secp_ctx, blinding_point, &ss).ok()
+ });
+
+ let new_onion_packet = OnionPacket {
+ version: 0,
+ public_key: next_packet_pubkey,
+ hop_data: new_packet_bytes,
+ hmac: next_hop_hmac,
+ };
+
+ UpdateAddHTLC {
+ onion_routing_packet: new_onion_packet,
+ blinding_point: next_blinding_point,
+ amount_msat: outgoing_amt_msat,
+ cltv_expiry: outgoing_cltv_value,
+ ..msg.clone()
+ }
+}
+
/// Build a payment onion, returning the first hop msat and cltv values as well.
/// `cur_block_height` should be set to the best known block height + 1.
pub fn create_payment_onion<T: secp256k1::Signing>(
Why this scored 34/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.