Introduce DummyTlv for blinded path privacy
What changed, and why it matters
This commit adds a privacy feature, not a security fix. It introduces a 'DummyTlv' mechanism that lets senders insert fake, empty hops at the end of a blinded onion route. The goal is to make it harder for an outside observer to guess where the real recipient is in the path, similar to adding decoy stops before your real destination. The change is defensive in nature and does not appear to patch an active vulnerability.
No security action required. Treat as a normal privacy-enhancing feature review. If auditing, verify that Dummy payloads cannot be confused with Forward payloads and that the new match arms correctly reject invalid combinations (e.g., dummy flag combined with next_hop or next_blinding_override).
Security signals we found
Privacy-hardening feature: adds dummy hops to obscure recipient position in blinded onion routes
No vulnerability pattern present: no memory safety issue, no authentication bypass, no cryptographic flaw, no input validation bug
Parsing logic was refactored to include a new Dummy variant; existing Forward/Receive validation remains intact
Commit message frames change as a privacy improvement, not a security bug fix
Evidence from the diff
The patch adds a new DummyTlv variant to the ControlTlvs enum for blinded onion messages. DummyTlv is encoded as an empty TLV with type 65539 and is authenticated and peeled like a real hop, but carries no forwarding or receiving data. The parsing logic is updated so that a ControlTlvs is classified as Dummy when next_hop is None, next_blinding_override is None, and the dummy flag is present. This allows a sender to pad the tail of a blinded path with recursively authenticated dummy hops before the actual ReceiveTlvs, reducing the ability of a path-length observer to identify the recipient’s position.
Changed components
lightning/src/blinded_path/message.rslightning/src/onion_message/packet.rsInspect captured patch +44 / −13
diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs
index 954247d..5f62cf8 100644
--- a/lightning/src/blinded_path/message.rs
+++ b/lightning/src/blinded_path/message.rs
@@ -266,6 +266,23 @@ pub(crate) struct ForwardTlvs {
pub(crate) next_blinding_override: Option<PublicKey>,
}
+/// Represents the dummy TLV encoded immediately before the actual [`ReceiveTlvs`] in a blinded path.
+/// These TLVs are intended for the final node and are recursively authenticated until the real
+/// [`ReceiveTlvs`] is reached.
+///
+/// Their purpose is to arbitrarily extend the path length, obscuring the receiver's position in the
+/// route and thereby enhancing privacy.
+pub(crate) struct DummyTlv;
+
+impl Writeable for DummyTlv {
+ fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
+ encode_tlv_stream!(writer, {
+ (65539, (), required),
+ });
+ Ok(())
+ }
+}
+
/// Similar to [`ForwardTlvs`], but these TLVs are for the final node.
pub(crate) struct ReceiveTlvs {
/// If `context` is `Some`, it is used to identify the blinded path that this onion message is
diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs
index ee41ee9..2e0ccaf 100644
--- a/lightning/src/onion_message/packet.rs
+++ b/lightning/src/onion_message/packet.rs
@@ -16,7 +16,9 @@ use super::async_payments::AsyncPaymentsMessage;
use super::dns_resolution::DNSResolverMessage;
use super::messenger::CustomOnionMessageHandler;
use super::offers::OffersMessage;
-use crate::blinded_path::message::{BlindedMessagePath, ForwardTlvs, NextMessageHop, ReceiveTlvs};
+use crate::blinded_path::message::{
+ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs,
+};
use crate::crypto::streams::{ChaChaDualPolyReadAdapter, ChaChaPolyWriteAdapter};
use crate::ln::msgs::DecodeError;
use crate::ln::onion_utils;
@@ -111,6 +113,12 @@ impl LengthReadable for Packet {
pub(super) enum Payload<T: OnionMessageContents> {
/// This payload is for an intermediate hop.
Forward(ForwardControlTlvs),
+ /// This payload is a dummy hop, and is intended to be peeled.
+ Dummy {
+ /// The payload was authenticated with the additional key that was
+ /// provided to [`ReadableArgs::read`].
+ control_tlvs_authenticated: bool,
+ },
/// This payload is for the final hop.
Receive {
/// The [`ReceiveControlTlvs`] were authenticated with the additional key which was
@@ -237,6 +245,10 @@ impl<T: OnionMessageContents> Writeable for (Payload<T>, [u8; 32]) {
let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &control_tlvs);
_encode_varint_length_prefixed_tlv!(w, { (4, write_adapter, required) })
},
+ Payload::Dummy { control_tlvs_authenticated: _ } => {
+ let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &DummyTlv);
+ _encode_varint_length_prefixed_tlv!(w, { (4, write_adapter, required) })
+ },
Payload::Receive {
control_tlvs: ReceiveControlTlvs::Unblinded(control_tlvs),
reply_path,
@@ -316,6 +328,9 @@ impl<H: CustomOnionMessageHandler + ?Sized, L: Logger + ?Sized>
}
Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs)))
},
+ Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Dummy, used_aad }) => {
+ Ok(Payload::Dummy { control_tlvs_authenticated: used_aad })
+ },
Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), used_aad }) => {
Ok(Payload::Receive {
control_tlvs: ReceiveControlTlvs::Unblinded(tlvs),
@@ -335,6 +350,8 @@ impl<H: CustomOnionMessageHandler + ?Sized, L: Logger + ?Sized>
pub(crate) enum ControlTlvs {
/// This onion message is intended to be forwarded.
Forward(ForwardTlvs),
+ /// This onion message is a dummy, and is intended to be peeled by the final recipient.
+ Dummy,
/// This onion message is intended to be received.
Receive(ReceiveTlvs),
}
@@ -350,6 +367,7 @@ impl Readable for ControlTlvs {
(4, next_node_id, option),
(8, next_blinding_override, option),
(65537, context, option),
+ (65539, is_dummy, option),
});
let next_hop = match (short_channel_id, next_node_id) {
@@ -359,18 +377,13 @@ impl Readable for ControlTlvs {
(None, None) => None,
};
- let valid_fwd_fmt = next_hop.is_some();
- let valid_recv_fmt = next_hop.is_none() && next_blinding_override.is_none();
-
- let payload_fmt = if valid_fwd_fmt {
- ControlTlvs::Forward(ForwardTlvs {
- next_hop: next_hop.unwrap(),
- next_blinding_override,
- })
- } else if valid_recv_fmt {
- ControlTlvs::Receive(ReceiveTlvs { context })
- } else {
- return Err(DecodeError::InvalidValue);
+ let payload_fmt = match (next_hop, next_blinding_override, is_dummy) {
+ (Some(hop), _, None) => {
+ ControlTlvs::Forward(ForwardTlvs { next_hop: hop, next_blinding_override })
+ },
+ (None, None, Some(())) => ControlTlvs::Dummy,
+ (None, None, None) => ControlTlvs::Receive(ReceiveTlvs { context }),
+ _ => return Err(DecodeError::InvalidValue),
};
Ok(payload_fmt)
@@ -381,6 +394,7 @@ impl Writeable for ControlTlvs {
fn write<W: Writer>(&self, w: &mut W) -> Result<(), io::Error> {
match self {
Self::Forward(tlvs) => tlvs.write(w),
+ Self::Dummy => DummyTlv.write(w),
Self::Receive(tlvs) => tlvs.write(w),
}
}
Why this scored 12/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.