Skip to content

Commit bbfc694

Browse files
Merge pull request #3726 from shaavan/dummy
Improve privacy for Blinded Message Paths using Dummy Hops
2 parents 2150f24 + fdf352b commit bbfc694

19 files changed

+379
-143
lines changed

fuzz/src/chanmon_consistency.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ impl NodeSigner for KeyProvider {
332332
Ok(SharedSecret::new(other_key, &node_secret))
333333
}
334334

335-
fn get_inbound_payment_key(&self) -> ExpandedKey {
335+
fn get_expanded_key(&self) -> ExpandedKey {
336336
#[rustfmt::skip]
337337
let random_bytes = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, self.node_secret[31]];
338338
ExpandedKey::new(random_bytes)

fuzz/src/full_stack.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,9 @@ use bitcoin::secp256k1::{self, Message, PublicKey, Scalar, Secp256k1, SecretKey}
8080

8181
use lightning::util::dyn_signer::DynSigner;
8282

83-
use std::collections::VecDeque;
8483
use std::cell::RefCell;
8584
use std::cmp;
85+
use std::collections::VecDeque;
8686
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
8787
use std::sync::{Arc, Mutex};
8888

@@ -406,7 +406,7 @@ impl NodeSigner for KeyProvider {
406406
Ok(SharedSecret::new(other_key, &node_secret))
407407
}
408408

409-
fn get_inbound_payment_key(&self) -> ExpandedKey {
409+
fn get_expanded_key(&self) -> ExpandedKey {
410410
self.inbound_payment_key
411411
}
412412

fuzz/src/onion_message.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ impl NodeSigner for KeyProvider {
255255
Ok(SharedSecret::new(other_key, &node_secret))
256256
}
257257

258-
fn get_inbound_payment_key(&self) -> ExpandedKey {
258+
fn get_expanded_key(&self) -> ExpandedKey {
259259
unreachable!()
260260
}
261261

lightning/src/blinded_path/message.rs

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,9 @@ use crate::types::payment::PaymentHash;
3131
use crate::util::scid_utils;
3232
use crate::util::ser::{FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer};
3333

34-
use core::mem;
3534
use core::ops::Deref;
3635
use core::time::Duration;
36+
use core::{cmp, mem};
3737

3838
/// A blinded path to be used for sending or receiving a message, hiding the identity of the
3939
/// recipient.
@@ -74,6 +74,29 @@ impl BlindedMessagePath {
7474
local_node_receive_key: ReceiveAuthKey, context: MessageContext, entropy_source: ES,
7575
secp_ctx: &Secp256k1<T>,
7676
) -> Result<Self, ()>
77+
where
78+
ES::Target: EntropySource,
79+
{
80+
BlindedMessagePath::new_with_dummy_hops(
81+
intermediate_nodes,
82+
recipient_node_id,
83+
0,
84+
local_node_receive_key,
85+
context,
86+
entropy_source,
87+
secp_ctx,
88+
)
89+
}
90+
91+
/// Same as [`BlindedMessagePath::new`], but allows specifying a number of dummy hops.
92+
///
93+
/// Note:
94+
/// At most [`MAX_DUMMY_HOPS_COUNT`] dummy hops can be added to the blinded path.
95+
pub fn new_with_dummy_hops<ES: Deref, T: secp256k1::Signing + secp256k1::Verification>(
96+
intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey,
97+
dummy_hop_count: usize, local_node_receive_key: ReceiveAuthKey, context: MessageContext,
98+
entropy_source: ES, secp_ctx: &Secp256k1<T>,
99+
) -> Result<Self, ()>
77100
where
78101
ES::Target: EntropySource,
79102
{
@@ -91,6 +114,7 @@ impl BlindedMessagePath {
91114
secp_ctx,
92115
intermediate_nodes,
93116
recipient_node_id,
117+
dummy_hop_count,
94118
context,
95119
&blinding_secret,
96120
local_node_receive_key,
@@ -266,6 +290,23 @@ pub(crate) struct ForwardTlvs {
266290
pub(crate) next_blinding_override: Option<PublicKey>,
267291
}
268292

293+
/// Represents the dummy TLV encoded immediately before the actual [`ReceiveTlvs`] in a blinded path.
294+
/// These TLVs are intended for the final node and are recursively authenticated until the real
295+
/// [`ReceiveTlvs`] is reached.
296+
///
297+
/// Their purpose is to arbitrarily extend the path length, obscuring the receiver's position in the
298+
/// route and thereby enhancing privacy.
299+
pub(crate) struct DummyTlv;
300+
301+
impl Writeable for DummyTlv {
302+
fn write<W: Writer>(&self, writer: &mut W) -> Result<(), io::Error> {
303+
encode_tlv_stream!(writer, {
304+
(65539, (), required),
305+
});
306+
Ok(())
307+
}
308+
}
309+
269310
/// Similar to [`ForwardTlvs`], but these TLVs are for the final node.
270311
pub(crate) struct ReceiveTlvs {
271312
/// If `context` is `Some`, it is used to identify the blinded path that this onion message is
@@ -618,15 +659,24 @@ impl_writeable_tlv_based!(DNSResolverContext, {
618659
/// to pad message blinded path's [`BlindedHop`]
619660
pub(crate) const MESSAGE_PADDING_ROUND_OFF: usize = 100;
620661

662+
/// The maximum number of dummy hops that can be added to a blinded path.
663+
/// This is to prevent paths from becoming too long and potentially causing
664+
/// issues with message processing or routing.
665+
pub const MAX_DUMMY_HOPS_COUNT: usize = 10;
666+
621667
/// Construct blinded onion message hops for the given `intermediate_nodes` and `recipient_node_id`.
622668
pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>(
623669
secp_ctx: &Secp256k1<T>, intermediate_nodes: &[MessageForwardNode],
624-
recipient_node_id: PublicKey, context: MessageContext, session_priv: &SecretKey,
625-
local_node_receive_key: ReceiveAuthKey,
670+
recipient_node_id: PublicKey, dummy_hop_count: usize, context: MessageContext,
671+
session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey,
626672
) -> Result<Vec<BlindedHop>, secp256k1::Error> {
673+
let dummy_count = cmp::min(dummy_hop_count, MAX_DUMMY_HOPS_COUNT);
627674
let pks = intermediate_nodes
628675
.iter()
629676
.map(|node| (node.node_id, None))
677+
.chain(
678+
core::iter::repeat((recipient_node_id, Some(local_node_receive_key))).take(dummy_count),
679+
)
630680
.chain(core::iter::once((recipient_node_id, Some(local_node_receive_key))));
631681
let is_compact = intermediate_nodes.iter().any(|node| node.short_channel_id.is_some());
632682

@@ -641,6 +691,7 @@ pub(super) fn blinded_hops<T: secp256k1::Signing + secp256k1::Verification>(
641691
.map(|next_hop| {
642692
ControlTlvs::Forward(ForwardTlvs { next_hop, next_blinding_override: None })
643693
})
694+
.chain((0..dummy_count).map(|_| ControlTlvs::Dummy))
644695
.chain(core::iter::once(ControlTlvs::Receive(ReceiveTlvs { context: Some(context) })));
645696

646697
if is_compact {

lightning/src/blinded_path/utils.rs

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -276,16 +276,43 @@ impl<T: Writeable> Writeable for BlindedPathWithPadding<T> {
276276
}
277277

278278
#[cfg(test)]
279-
/// Checks if all the packets in the blinded path are properly padded.
279+
/// Verifies whether all hops in the blinded path follow the expected padding scheme.
280+
///
281+
/// In the padded encoding scheme, each hop's encrypted payload is expected to be of the form:
282+
/// `n * padding_round_off + extra`, where:
283+
/// - `padding_round_off` is the fixed block size to which unencrypted payloads are padded.
284+
/// - `n` is a positive integer (n ≥ 1).
285+
/// - `extra` is the fixed overhead added during encryption (assumed uniform across hops).
286+
///
287+
/// This function infers the `extra` from the first hop, and checks that all other hops conform
288+
/// to the same pattern.
289+
///
290+
/// # Returns
291+
/// - `true` if all hop payloads are padded correctly.
292+
/// - `false` if padding is incorrectly applied or intentionally absent (e.g., in compact paths).
280293
pub fn is_padded(hops: &[BlindedHop], padding_round_off: usize) -> bool {
281294
let first_hop = hops.first().expect("BlindedPath must have at least one hop");
282-
let first_payload_size = first_hop.encrypted_payload.len();
283-
284-
// The unencrypted payload data is padded before getting encrypted.
285-
// Assuming the first payload is padded properly, get the extra data length.
286-
let extra_length = first_payload_size % padding_round_off;
287-
hops.iter().all(|hop| {
288-
// Check that every packet is padded to the round off length subtracting the extra length.
289-
(hop.encrypted_payload.len() - extra_length) % padding_round_off == 0
290-
})
295+
let first_len = first_hop.encrypted_payload.len();
296+
297+
// Early rejection: if the first hop is too small, it can't be correctly padded.
298+
if first_len <= padding_round_off {
299+
return false;
300+
}
301+
302+
let extra = first_len % padding_round_off;
303+
304+
// Helper to check if a hop follows the padding pattern
305+
let is_hop_padded = |hop: &BlindedHop| {
306+
let len = hop.encrypted_payload.len();
307+
len > extra && (len - extra) % padding_round_off == 0
308+
};
309+
310+
// All hops must follow the same padding structure AND
311+
// all hops except the final one must have the same length as the first
312+
// to ensure proper masking.
313+
hops.iter().all(is_hop_padded)
314+
&& hops
315+
.iter()
316+
.take(hops.len().saturating_sub(1))
317+
.all(|hop| hop.encrypted_payload.len() == first_len)
291318
}

lightning/src/ln/async_payments_tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ fn create_static_invoice_builder<'a>(
255255

256256
let created_at = recipient.node.duration_since_epoch();
257257
let payment_secret = inbound_payment::create_for_spontaneous_payment(
258-
&recipient.keys_manager.get_inbound_payment_key(),
258+
&recipient.keys_manager.get_expanded_key(),
259259
amount_msat,
260260
relative_expiry_secs,
261261
created_at.as_secs(),
@@ -982,7 +982,7 @@ fn amount_doesnt_match_invreq() {
982982
valid_invreq = Some(invoice_request.clone());
983983
*invoice_request = offer
984984
.request_invoice(
985-
&nodes[0].keys_manager.get_inbound_payment_key(),
985+
&nodes[0].keys_manager.get_expanded_key(),
986986
Nonce::from_entropy_source(nodes[0].keys_manager),
987987
&secp_ctx,
988988
payment_id,

lightning/src/ln/blinded_payment_tests.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ pub fn blinded_payment_path(
8787
};
8888

8989
let nonce = Nonce([42u8; 16]);
90-
let expanded_key = keys_manager.get_inbound_payment_key();
90+
let expanded_key = keys_manager.get_expanded_key();
9191
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
9292

9393
let mut secp_ctx = Secp256k1::new();
@@ -172,7 +172,7 @@ fn do_one_hop_blinded_path(success: bool) {
172172
payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
173173
};
174174
let nonce = Nonce([42u8; 16]);
175-
let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key();
175+
let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key();
176176
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
177177

178178
let mut secp_ctx = Secp256k1::new();
@@ -226,7 +226,7 @@ fn mpp_to_one_hop_blinded_path() {
226226
payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
227227
};
228228
let nonce = Nonce([42u8; 16]);
229-
let expanded_key = chanmon_cfgs[3].keys_manager.get_inbound_payment_key();
229+
let expanded_key = chanmon_cfgs[3].keys_manager.get_expanded_key();
230230
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
231231
let blinded_path = BlindedPaymentPath::new(
232232
&[], nodes[3].node.get_our_node_id(), payee_tlvs, u64::MAX, TEST_FINAL_CLTV as u16,
@@ -1336,7 +1336,7 @@ fn custom_tlvs_to_blinded_path() {
13361336
payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
13371337
};
13381338
let nonce = Nonce([42u8; 16]);
1339-
let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key();
1339+
let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key();
13401340
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
13411341
let mut secp_ctx = Secp256k1::new();
13421342
let blinded_path = BlindedPaymentPath::new(
@@ -1390,7 +1390,7 @@ fn fails_receive_tlvs_authentication() {
13901390
payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}),
13911391
};
13921392
let nonce = Nonce([42u8; 16]);
1393-
let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key();
1393+
let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key();
13941394
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
13951395

13961396
let mut secp_ctx = Secp256k1::new();
@@ -1622,7 +1622,7 @@ fn route_blinding_spec_test_vector() {
16221622
}
16231623
Ok(SharedSecret::new(other_key, &node_secret))
16241624
}
1625-
fn get_inbound_payment_key(&self) -> ExpandedKey { unreachable!() }
1625+
fn get_expanded_key(&self) -> ExpandedKey { unreachable!() }
16261626
fn get_node_id(&self, _recipient: Recipient) -> Result<PublicKey, ()> { unreachable!() }
16271627
fn sign_invoice(
16281628
&self, _invoice: &RawBolt11Invoice, _recipient: Recipient,
@@ -1935,7 +1935,7 @@ fn test_trampoline_inbound_payment_decoding() {
19351935
}
19361936
Ok(SharedSecret::new(other_key, &node_secret))
19371937
}
1938-
fn get_inbound_payment_key(&self) -> ExpandedKey { unreachable!() }
1938+
fn get_expanded_key(&self) -> ExpandedKey { unreachable!() }
19391939
fn get_node_id(&self, _recipient: Recipient) -> Result<PublicKey, ()> { unreachable!() }
19401940
fn sign_invoice(
19411941
&self, _invoice: &RawBolt11Invoice, _recipient: Recipient,
@@ -2023,7 +2023,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) {
20232023
};
20242024

20252025
let nonce = Nonce([42u8; 16]);
2026-
let expanded_key = nodes[2].keys_manager.get_inbound_payment_key();
2026+
let expanded_key = nodes[2].keys_manager.get_expanded_key();
20272027
let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key);
20282028
let carol_unblinded_tlvs = payee_tlvs.encode();
20292029

lightning/src/ln/channelmanager.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,7 @@ pub enum PendingHTLCRouting {
315315
requires_blinded_error: bool,
316316
/// Set if we are receiving a keysend to a blinded path, meaning we created the
317317
/// [`PaymentSecret`] and should verify it using our
318-
/// [`NodeSigner::get_inbound_payment_key`].
318+
/// [`NodeSigner::get_expanded_key`].
319319
has_recipient_created_payment_secret: bool,
320320
/// The [`InvoiceRequest`] associated with the [`Offer`] corresponding to this payment.
321321
invoice_request: Option<InvoiceRequest>,
@@ -3732,7 +3732,7 @@ where
37323732
let mut secp_ctx = Secp256k1::new();
37333733
secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes());
37343734

3735-
let expanded_inbound_key = node_signer.get_inbound_payment_key();
3735+
let expanded_inbound_key = node_signer.get_expanded_key();
37363736
let our_network_pubkey = node_signer.get_node_id(Recipient::Node).unwrap();
37373737

37383738
let flow = OffersMessageFlow::new(
@@ -16767,7 +16767,7 @@ where
1676716767
}
1676816768
}
1676916769

16770-
let expanded_inbound_key = args.node_signer.get_inbound_payment_key();
16770+
let expanded_inbound_key = args.node_signer.get_expanded_key();
1677116771

1677216772
let mut claimable_payments = hash_map_with_capacity(claimable_htlcs_list.len());
1677316773
if let Some(purposes) = claimable_htlc_purposes {

lightning/src/ln/inbound_payment.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ const AMT_MSAT_LEN: usize = 8;
3737
// retrieve said payment type bits.
3838
const METHOD_TYPE_OFFSET: usize = 5;
3939

40-
/// A set of keys that were HKDF-expanded. Returned by [`NodeSigner::get_inbound_payment_key`].
40+
/// A set of keys that were HKDF-expanded. Returned by [`NodeSigner::get_expanded_key`].
4141
///
42-
/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key
42+
/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key
4343
#[derive(Hash, Copy, Clone, PartialEq, Eq, Debug)]
4444
pub struct ExpandedKey {
4545
/// The key used to encrypt the bytes containing the payment metadata (i.e. the amount and
@@ -133,7 +133,7 @@ fn min_final_cltv_expiry_delta_from_metadata(bytes: [u8; METADATA_LEN]) -> u16 {
133133
/// `ChannelManager` is required. Useful for generating invoices for [phantom node payments] without
134134
/// a `ChannelManager`.
135135
///
136-
/// `keys` is generated by calling [`NodeSigner::get_inbound_payment_key`]. It is recommended to
136+
/// `keys` is generated by calling [`NodeSigner::get_expanded_key`]. It is recommended to
137137
/// cache this value and not regenerate it for each new inbound payment.
138138
///
139139
/// `current_time` is a Unix timestamp representing the current time.
@@ -142,7 +142,7 @@ fn min_final_cltv_expiry_delta_from_metadata(bytes: [u8; METADATA_LEN]) -> u16 {
142142
/// on versions of LDK prior to 0.0.114.
143143
///
144144
/// [phantom node payments]: crate::sign::PhantomKeysManager
145-
/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key
145+
/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key
146146
pub fn create<ES: Deref>(
147147
keys: &ExpandedKey, min_value_msat: Option<u64>, invoice_expiry_delta_secs: u32,
148148
entropy_source: &ES, current_time: u64, min_final_cltv_expiry_delta: Option<u16>,
@@ -321,7 +321,7 @@ fn construct_payment_secret(
321321
/// For payments including a custom `min_final_cltv_expiry_delta`, the metadata is constructed as:
322322
/// payment method (3 bits) || payment amount (8 bytes - 3 bits) || min_final_cltv_expiry_delta (2 bytes) || expiry (6 bytes)
323323
///
324-
/// In both cases the result is then encrypted using a key derived from [`NodeSigner::get_inbound_payment_key`].
324+
/// In both cases the result is then encrypted using a key derived from [`NodeSigner::get_expanded_key`].
325325
///
326326
/// Then on payment receipt, we verify in this method that the payment preimage and payment secret
327327
/// match what was constructed.
@@ -342,7 +342,7 @@ fn construct_payment_secret(
342342
///
343343
/// See [`ExpandedKey`] docs for more info on the individual keys used.
344344
///
345-
/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key
345+
/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key
346346
/// [`create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment
347347
/// [`create_inbound_payment_for_hash`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment_for_hash
348348
pub(super) fn verify<L: Deref>(

lightning/src/ln/invoice_utils.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ where
195195
},
196196
};
197197

198-
let keys = node_signer.get_inbound_payment_key();
198+
let keys = node_signer.get_expanded_key();
199199
let (payment_hash, payment_secret) = if let Some(payment_hash) = payment_hash {
200200
let payment_secret = create_from_hash(
201201
&keys,

0 commit comments

Comments
 (0)