diff --git a/fuzz/src/chanmon_consistency.rs b/fuzz/src/chanmon_consistency.rs index ff5189f9346..3d7ca378a3f 100644 --- a/fuzz/src/chanmon_consistency.rs +++ b/fuzz/src/chanmon_consistency.rs @@ -332,7 +332,7 @@ impl NodeSigner for KeyProvider { Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { #[rustfmt::skip] 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]]; ExpandedKey::new(random_bytes) diff --git a/fuzz/src/full_stack.rs b/fuzz/src/full_stack.rs index eb9d51d487d..5028a532670 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -412,7 +412,7 @@ impl NodeSigner for KeyProvider { Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { self.inbound_payment_key } diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 85ba6263b2a..d58b44fa7b6 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -255,7 +255,7 @@ impl NodeSigner for KeyProvider { Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 218b2282141..120757ddb04 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -31,9 +31,9 @@ use crate::types::payment::PaymentHash; use crate::util::scid_utils; use crate::util::ser::{FixedLengthReader, LengthReadableArgs, Readable, Writeable, Writer}; -use core::mem; use core::ops::Deref; use core::time::Duration; +use core::{cmp, mem}; /// A blinded path to be used for sending or receiving a message, hiding the identity of the /// recipient. @@ -74,6 +74,26 @@ impl BlindedMessagePath { local_node_receive_key: ReceiveAuthKey, context: MessageContext, entropy_source: ES, secp_ctx: &Secp256k1, ) -> Result + where + ES::Target: EntropySource, + { + BlindedMessagePath::new_with_dummy_hops( + intermediate_nodes, + recipient_node_id, + 0, + local_node_receive_key, + context, + entropy_source, + secp_ctx, + ) + } + + /// Same as [`BlindedMessagePath::new`] but allow specifying a number of dummy hops + pub fn new_with_dummy_hops( + intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey, + dummy_hop_count: usize, local_node_receive_key: ReceiveAuthKey, context: MessageContext, + entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Result where ES::Target: EntropySource, { @@ -91,6 +111,7 @@ impl BlindedMessagePath { secp_ctx, intermediate_nodes, recipient_node_id, + dummy_hop_count, context, &blinding_secret, local_node_receive_key, @@ -266,6 +287,23 @@ pub(crate) struct ForwardTlvs { pub(crate) next_blinding_override: Option, } +/// 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(&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 @@ -621,15 +659,24 @@ impl_writeable_tlv_based!(DNSResolverContext, { /// to pad message blinded path's [`BlindedHop`] pub(crate) const MESSAGE_PADDING_ROUND_OFF: usize = 100; +/// The maximum number of dummy hops that can be added to a blinded path. +/// This is to prevent paths from becoming too long and potentially causing +/// issues with message processing or routing. +pub(crate) const MAX_DUMMY_HOPS_COUNT: usize = 10; + /// Construct blinded onion message hops for the given `intermediate_nodes` and `recipient_node_id`. pub(super) fn blinded_hops( secp_ctx: &Secp256k1, intermediate_nodes: &[MessageForwardNode], - recipient_node_id: PublicKey, context: MessageContext, session_priv: &SecretKey, - local_node_receive_key: ReceiveAuthKey, + recipient_node_id: PublicKey, dummy_hop_count: usize, context: MessageContext, + session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey, ) -> Result, secp256k1::Error> { + let dummy_count = cmp::min(dummy_hop_count, MAX_DUMMY_HOPS_COUNT); let pks = intermediate_nodes .iter() .map(|node| (node.node_id, None)) + .chain( + core::iter::repeat((recipient_node_id, Some(local_node_receive_key))).take(dummy_count), + ) .chain(core::iter::once((recipient_node_id, Some(local_node_receive_key)))); let is_compact = intermediate_nodes.iter().any(|node| node.short_channel_id.is_some()); @@ -644,6 +691,7 @@ pub(super) fn blinded_hops( .map(|next_hop| { ControlTlvs::Forward(ForwardTlvs { next_hop, next_blinding_override: None }) }) + .chain((0..dummy_count).map(|_| ControlTlvs::Dummy(DummyTlv {}))) .chain(core::iter::once(ControlTlvs::Receive(ReceiveTlvs { context: Some(context) }))); if is_compact { diff --git a/lightning/src/blinded_path/utils.rs b/lightning/src/blinded_path/utils.rs index 976c821ad5b..b3b4714054d 100644 --- a/lightning/src/blinded_path/utils.rs +++ b/lightning/src/blinded_path/utils.rs @@ -276,16 +276,37 @@ impl Writeable for BlindedPathWithPadding { } #[cfg(test)] -/// Checks if all the packets in the blinded path are properly padded. +/// Verifies whether all hops in the blinded path follow the expected padding scheme. +/// +/// In the padded encoding scheme, each hop's encrypted payload is expected to be of the form: +/// `n * padding_round_off + extra`, where: +/// - `padding_round_off` is the fixed block size to which unencrypted payloads are padded. +/// - `n` is a positive integer (n ≥ 1). +/// - `extra` is the fixed overhead added during encryption (assumed uniform across hops). +/// +/// This function infers the `extra` from the first hop, and checks that all other hops conform +/// to the same pattern. +/// +/// # Returns +/// - `true` if all hop payloads are padded correctly. +/// - `false` if padding is incorrectly applied or intentionally absent (e.g., in compact paths). pub fn is_padded(hops: &[BlindedHop], padding_round_off: usize) -> bool { let first_hop = hops.first().expect("BlindedPath must have at least one hop"); - let first_payload_size = first_hop.encrypted_payload.len(); + let first_len = first_hop.encrypted_payload.len(); + + // Early rejection: if the first hop is too small, it can't be correctly padded. + if first_len <= padding_round_off { + return false; + } + + // Compute the extra encrypted overhead by taking the remainder. + let extra = first_len % padding_round_off; + + // All hops must follow the same padding structure: + // their length minus `extra` should be a clean multiple of `padding_round_off`. - // The unencrypted payload data is padded before getting encrypted. - // Assuming the first payload is padded properly, get the extra data length. - let extra_length = first_payload_size % padding_round_off; hops.iter().all(|hop| { - // Check that every packet is padded to the round off length subtracting the extra length. - (hop.encrypted_payload.len() - extra_length) % padding_round_off == 0 + let len = hop.encrypted_payload.len(); + len > extra && (len - extra) % padding_round_off == 0 }) } diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 7ee48225f78..4923147248e 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -904,7 +904,7 @@ fn amount_doesnt_match_invreq() { valid_invreq = Some(invoice_request.clone()); *invoice_request = offer .request_invoice( - &nodes[0].keys_manager.get_inbound_payment_key(), + &nodes[0].keys_manager.get_expanded_key(), Nonce::from_entropy_source(nodes[0].keys_manager), &secp_ctx, payment_id, diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 675d628ef2c..8879674ac7b 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -87,7 +87,7 @@ pub fn blinded_payment_path( }; let nonce = Nonce([42u8; 16]); - let expanded_key = keys_manager.get_inbound_payment_key(); + let expanded_key = keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let mut secp_ctx = Secp256k1::new(); @@ -172,7 +172,7 @@ fn do_one_hop_blinded_path(success: bool) { payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; let nonce = Nonce([42u8; 16]); - let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key(); + let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let mut secp_ctx = Secp256k1::new(); @@ -226,7 +226,7 @@ fn mpp_to_one_hop_blinded_path() { payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; let nonce = Nonce([42u8; 16]); - let expanded_key = chanmon_cfgs[3].keys_manager.get_inbound_payment_key(); + let expanded_key = chanmon_cfgs[3].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let blinded_path = BlindedPaymentPath::new( &[], 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() { payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; let nonce = Nonce([42u8; 16]); - let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key(); + let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let mut secp_ctx = Secp256k1::new(); let blinded_path = BlindedPaymentPath::new( @@ -1390,7 +1390,7 @@ fn fails_receive_tlvs_authentication() { payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; let nonce = Nonce([42u8; 16]); - let expanded_key = chanmon_cfgs[1].keys_manager.get_inbound_payment_key(); + let expanded_key = chanmon_cfgs[1].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let mut secp_ctx = Secp256k1::new(); @@ -1622,7 +1622,7 @@ fn route_blinding_spec_test_vector() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, @@ -1935,7 +1935,7 @@ fn test_trampoline_inbound_payment_decoding() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, @@ -2023,7 +2023,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { }; let nonce = Nonce([42u8; 16]); - let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); + let expanded_key = nodes[2].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let carol_unblinded_tlvs = payee_tlvs.encode(); diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 2214676ceb7..56eb073b858 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -321,7 +321,7 @@ pub enum PendingHTLCRouting { requires_blinded_error: bool, /// Set if we are receiving a keysend to a blinded path, meaning we created the /// [`PaymentSecret`] and should verify it using our - /// [`NodeSigner::get_inbound_payment_key`]. + /// [`NodeSigner::get_expanded_key`]. has_recipient_created_payment_secret: bool, /// The [`InvoiceRequest`] associated with the [`Offer`] corresponding to this payment. invoice_request: Option, @@ -3715,7 +3715,7 @@ where let mut secp_ctx = Secp256k1::new(); secp_ctx.seeded_randomize(&entropy_source.get_secure_random_bytes()); - let expanded_inbound_key = node_signer.get_inbound_payment_key(); + let expanded_inbound_key = node_signer.get_expanded_key(); let our_network_pubkey = node_signer.get_node_id(Recipient::Node).unwrap(); let flow = OffersMessageFlow::new( @@ -16513,7 +16513,7 @@ where } } - let expanded_inbound_key = args.node_signer.get_inbound_payment_key(); + let expanded_inbound_key = args.node_signer.get_expanded_key(); let mut claimable_payments = hash_map_with_capacity(claimable_htlcs_list.len()); if let Some(purposes) = claimable_htlc_purposes { diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index a7d45b896a9..2aeba0cdf89 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -37,9 +37,9 @@ const AMT_MSAT_LEN: usize = 8; // retrieve said payment type bits. const METHOD_TYPE_OFFSET: usize = 5; -/// A set of keys that were HKDF-expanded. Returned by [`NodeSigner::get_inbound_payment_key`]. +/// A set of keys that were HKDF-expanded. Returned by [`NodeSigner::get_expanded_key`]. /// -/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key +/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key #[derive(Hash, Copy, Clone, PartialEq, Eq, Debug)] pub struct ExpandedKey { /// 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 { /// `ChannelManager` is required. Useful for generating invoices for [phantom node payments] without /// a `ChannelManager`. /// -/// `keys` is generated by calling [`NodeSigner::get_inbound_payment_key`]. It is recommended to +/// `keys` is generated by calling [`NodeSigner::get_expanded_key`]. It is recommended to /// cache this value and not regenerate it for each new inbound payment. /// /// `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 { /// on versions of LDK prior to 0.0.114. /// /// [phantom node payments]: crate::sign::PhantomKeysManager -/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key +/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key pub fn create( keys: &ExpandedKey, min_value_msat: Option, invoice_expiry_delta_secs: u32, entropy_source: &ES, current_time: u64, min_final_cltv_expiry_delta: Option, @@ -322,7 +322,7 @@ fn construct_payment_secret( /// For payments including a custom `min_final_cltv_expiry_delta`, the metadata is constructed as: /// payment method (3 bits) || payment amount (8 bytes - 3 bits) || min_final_cltv_expiry_delta (2 bytes) || expiry (6 bytes) /// -/// In both cases the result is then encrypted using a key derived from [`NodeSigner::get_inbound_payment_key`]. +/// In both cases the result is then encrypted using a key derived from [`NodeSigner::get_expanded_key`]. /// /// Then on payment receipt, we verify in this method that the payment preimage and payment secret /// match what was constructed. @@ -343,7 +343,7 @@ fn construct_payment_secret( /// /// See [`ExpandedKey`] docs for more info on the individual keys used. /// -/// [`NodeSigner::get_inbound_payment_key`]: crate::sign::NodeSigner::get_inbound_payment_key +/// [`NodeSigner::get_expanded_key`]: crate::sign::NodeSigner::get_expanded_key /// [`create_inbound_payment`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment /// [`create_inbound_payment_for_hash`]: crate::ln::channelmanager::ChannelManager::create_inbound_payment_for_hash pub(super) fn verify( diff --git a/lightning/src/ln/invoice_utils.rs b/lightning/src/ln/invoice_utils.rs index 509cb2e3b7b..c08d4fa14c5 100644 --- a/lightning/src/ln/invoice_utils.rs +++ b/lightning/src/ln/invoice_utils.rs @@ -195,7 +195,7 @@ where }, }; - let keys = node_signer.get_inbound_payment_key(); + let keys = node_signer.get_expanded_key(); let (payment_hash, payment_secret) = if let Some(payment_hash) = payment_hash { let payment_secret = create_from_hash( &keys, diff --git a/lightning/src/ln/max_payment_path_len_tests.rs b/lightning/src/ln/max_payment_path_len_tests.rs index 4efa105e0ad..177050baf90 100644 --- a/lightning/src/ln/max_payment_path_len_tests.rs +++ b/lightning/src/ln/max_payment_path_len_tests.rs @@ -222,7 +222,7 @@ fn one_hop_blinded_path_with_custom_tlv() { payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), }; let nonce = Nonce([42u8; 16]); - let expanded_key = chanmon_cfgs[2].keys_manager.get_inbound_payment_key(); + let expanded_key = chanmon_cfgs[2].keys_manager.get_expanded_key(); let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); let mut secp_ctx = Secp256k1::new(); let blinded_path = BlindedPaymentPath::new( diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index e0219a5523f..371dd591588 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -3548,7 +3548,7 @@ where }, ChaChaPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs) } => { let ReceiveTlvs { tlvs, authentication: (hmac, nonce) } = receive_tlvs; - let expanded_key = node_signer.get_inbound_payment_key(); + let expanded_key = node_signer.get_expanded_key(); if tlvs.verify_for_offer_payment(hmac, nonce, &expanded_key).is_err() { return Err(DecodeError::InvalidValue); } @@ -3700,7 +3700,7 @@ where readable: BlindedTrampolineTlvs::Receive(receive_tlvs), } => { let ReceiveTlvs { tlvs, authentication: (hmac, nonce) } = receive_tlvs; - let expanded_key = node_signer.get_inbound_payment_key(); + let expanded_key = node_signer.get_expanded_key(); if tlvs.verify_for_offer_payment(hmac, nonce, &expanded_key).is_err() { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index ba9858be197..6bda12c8069 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -46,11 +46,12 @@ use bitcoin::network::Network; use bitcoin::secp256k1::{PublicKey, Secp256k1}; use core::time::Duration; use crate::blinded_path::IntroductionNode; -use crate::blinded_path::message::BlindedMessagePath; +use crate::blinded_path::message::{BlindedMessagePath, MAX_DUMMY_HOPS_COUNT}; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, PaymentContext}; use crate::blinded_path::message::OffersContext; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; use crate::ln::channelmanager::{Bolt12PaymentError, PaymentId, RecentPaymentDetails, RecipientOnionFields, Retry, self}; +use crate::offers::test_utils::FixedEntropy; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, NodeAnnouncement, OnionMessage, OnionMessageHandler, RoutingMessageHandler, SocketAddress, UnsignedGossipMessage, UnsignedNodeAnnouncement}; @@ -60,11 +61,12 @@ use crate::offers::invoice_error::InvoiceError; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields}; use crate::offers::nonce::Nonce; use crate::offers::parse::Bolt12SemanticError; -use crate::onion_message::messenger::{Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion}; +use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion}; use crate::onion_message::offers::OffersMessage; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; use crate::sign::{NodeSigner, Recipient}; +use crate::sync::Arc; use crate::util::ser::Writeable; /// This used to determine whether we built a compact path or not, but now its just a random @@ -438,6 +440,63 @@ fn prefers_more_connected_nodes_in_blinded_paths() { } } +/// Tests the dummy hop behavior of Offers based on the message router used: +/// - Compact paths (`DefaultMessageRouter`) should not include dummy hops. +/// - Node ID paths (`NodeIdMessageRouter`) may include 0 to [`MAX_DUMMY_HOPS_COUNT`] dummy hops. +#[test] +fn check_dummy_hop_pattern_in_offer() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let deterministic_entropy = Arc::new(FixedEntropy); + + // Case 1: DefaultMessageRouter → uses compact blinded paths (via SCIDs) + // Expected: No dummy hops; each path contains only the recipient. + let default_router = DefaultMessageRouter::new(alice.network_graph, deterministic_entropy.clone()); + + let compact_offer = alice.node + .create_offer_builder_using_router(&default_router).unwrap() + .build().unwrap(); + + assert!(!compact_offer.paths().is_empty()); + + for path in compact_offer.paths() { + assert_eq!( + path.blinded_hops().len(), 1, + "Compact paths must include only the recipient" + ); + } + + // Case 2: NodeIdMessageRouter → uses node ID-based blinded paths + // Expected: 0 to MAX_DUMMY_HOPS_COUNT dummy hops, followed by recipient. + let node_id_router = NodeIdMessageRouter::new(alice.network_graph, deterministic_entropy); + + let padded_offer = alice.node + .create_offer_builder_using_router(&node_id_router).unwrap() + .build().unwrap(); + + assert!(!padded_offer.paths().is_empty()); + + for path in padded_offer.paths() { + let hops = path.blinded_hops(); + assert!( + hops.len() > 1, + "Non-compact paths must include at least one dummy hop plus recipient" + ); + + let dummy_count = hops.len() - 1; + assert!( + dummy_count <= MAX_DUMMY_HOPS_COUNT, + "Dummy hops must not exceed MAX_DUMMY_HOPS_COUNT" + ); + } +} + /// Checks that blinded paths are compact for short-lived offers. #[test] fn creates_short_lived_offer() { @@ -2275,7 +2334,7 @@ fn fails_paying_invoice_with_unknown_required_features() { let payment_paths = invoice.payment_paths().to_vec(); let payment_hash = invoice.payment_hash(); - let expanded_key = alice.keys_manager.get_inbound_payment_key(); + let expanded_key = alice.keys_manager.get_expanded_key(); let secp_ctx = Secp256k1::new(); let created_at = alice.node.duration_since_epoch(); diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 3cbb618dc0b..bea7182c2aa 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -443,6 +443,34 @@ fn one_blinded_hop() { pass_along_path(&nodes); } +#[test] +fn blinded_path_with_dummy() { + let nodes = create_nodes(2); + let test_msg = TestCustomMessage::Pong; + + let secp_ctx = Secp256k1::new(); + let context = MessageContext::Custom(Vec::new()); + let entropy = &*nodes[1].entropy_source; + let receive_key = nodes[1].messenger.node_signer.get_receive_auth_key(); + let blinded_path = BlindedMessagePath::new_with_dummy_hops( + &[], + nodes[1].node_id, + 5, + receive_key, + context, + entropy, + &secp_ctx, + ) + .unwrap(); + // Ensure that dummy hops are added to the blinded path. + assert_eq!(blinded_path.blinded_hops().len(), 6); + let destination = Destination::BlindedPath(blinded_path); + let instructions = MessageSendInstructions::WithoutReplyPath { destination }; + nodes[0].messenger.send_onion_message(test_msg, instructions).unwrap(); + nodes[1].custom_message_handler.expect_message(TestCustomMessage::Pong); + pass_along_path(&nodes); +} + #[test] fn two_unblinded_two_blinded() { let nodes = create_nodes(5); @@ -658,9 +686,10 @@ fn test_blinded_path_padding_for_full_length_path() { let context = MessageContext::Custom(vec![0u8; 42]); let entropy = &*nodes[3].entropy_source; let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key(); - let blinded_path = BlindedMessagePath::new( + let blinded_path = BlindedMessagePath::new_with_dummy_hops( &intermediate_nodes, nodes[3].node_id, + 5, receive_key, context, entropy, @@ -694,9 +723,10 @@ fn test_blinded_path_no_padding_for_compact_path() { let context = MessageContext::Custom(vec![0u8; 42]); let entropy = &*nodes[3].entropy_source; let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key(); - let blinded_path = BlindedMessagePath::new( + let blinded_path = BlindedMessagePath::new_with_dummy_hops( &intermediate_nodes, nodes[3].node_id, + 5, receive_key, context, entropy, diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 38c8cd304d9..d95a6d6cf66 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -29,8 +29,8 @@ use super::packet::{ #[cfg(async_payments)] use crate::blinded_path::message::AsyncPaymentsContext; use crate::blinded_path::message::{ - BlindedMessagePath, DNSResolverContext, ForwardTlvs, MessageContext, MessageForwardNode, - NextMessageHop, OffersContext, ReceiveTlvs, + BlindedMessagePath, DNSResolverContext, DummyTlv, ForwardTlvs, MessageContext, + MessageForwardNode, NextMessageHop, OffersContext, ReceiveTlvs, }; use crate::blinded_path::utils; use crate::blinded_path::{IntroductionNode, NodeIdLookUp}; @@ -39,6 +39,7 @@ use crate::ln::msgs::{ self, BaseMessageHandler, MessageSendEvent, OnionMessage, OnionMessageHandler, SocketAddress, }; use crate::ln::onion_utils; +use crate::onion_message::packet::DummyControlTlvs; use crate::routing::gossip::{NetworkGraph, NodeId, ReadOnlyNetworkGraph}; use crate::sign::{EntropySource, NodeSigner, ReceiveAuthKey, Recipient}; use crate::types::features::{InitFeatures, NodeFeatures}; @@ -570,6 +571,25 @@ where // recipient's node_id. const MIN_PEER_CHANNELS: usize = 3; + // Add a random number (0 to `MAX_DUMMY_HOPS`) of dummy hops to each non-compact blinded path + // to make it harder to infer the recipient's position. + // + // # Note on compact paths: + // + // Compact paths are optimized for minimal size. Adding dummy hops to them + // would increase their size and undermine their primary advantage. + // Therefore, we avoid adding dummy hops to compact paths. + const MAX_DUMMY_HOPS: usize = 3; + + let dummy_hops_count = if compact_paths { + 0 + } else { + { + let random_byte: usize = entropy_source.get_secure_random_bytes()[0].into(); + random_byte % (MAX_DUMMY_HOPS + 1) + } + }; + let network_graph = network_graph.deref().read_only(); let is_recipient_announced = network_graph.nodes().contains_key(&NodeId::from_pubkey(&recipient)); @@ -604,9 +624,10 @@ where let paths = peer_info .into_iter() .map(|(peer, _, _)| { - BlindedMessagePath::new( + BlindedMessagePath::new_with_dummy_hops( &[peer], recipient, + dummy_hops_count, local_node_receive_key, context.clone(), entropy, @@ -620,9 +641,10 @@ where Ok(paths) if !paths.is_empty() => Ok(paths), _ => { if is_recipient_announced { - BlindedMessagePath::new( + BlindedMessagePath::new_with_dummy_hops( &[], recipient, + dummy_hops_count, local_node_receive_key, context, &**entropy_source, @@ -1150,6 +1172,44 @@ where msg.onion_routing_packet.hmac, (control_tlvs_ss, custom_handler.deref(), receiving_context_auth_key, logger.deref()), ); + + // Constructs the next onion message using packet data and blinding logic. + let compute_onion_message = |packet_pubkey: PublicKey, + next_hop_hmac: [u8; 32], + new_packet_bytes: Vec, + blinding_point_opt: Option| + -> Result { + let new_pubkey = + match onion_utils::next_hop_pubkey(&secp_ctx, packet_pubkey, &onion_decode_ss) { + Ok(pk) => pk, + Err(e) => { + log_trace!(logger, "Failed to compute next hop packet pubkey: {}", e); + return Err(()); + }, + }; + let outgoing_packet = Packet { + version: 0, + public_key: new_pubkey, + hop_data: new_packet_bytes, + hmac: next_hop_hmac, + }; + let blinding_point = match blinding_point_opt { + Some(bp) => bp, + None => match onion_utils::next_hop_pubkey( + &secp_ctx, + msg.blinding_point, + control_tlvs_ss.as_ref(), + ) { + Ok(bp) => bp, + Err(e) => { + log_trace!(logger, "Failed to compute next blinding point: {}", e); + return Err(()); + }, + }, + }; + Ok(OnionMessage { blinding_point, onion_routing_packet: outgoing_packet }) + }; + match next_hop { Ok(( Payload::Receive { @@ -1223,6 +1283,26 @@ where Err(()) }, }, + Ok(( + Payload::Dummy { + control_tlvs_authenticated, + control_tlvs: DummyControlTlvs(DummyTlv {}), + }, + Some((next_hop_hmac, new_packet_bytes)), + )) => { + if !control_tlvs_authenticated { + log_trace!(logger, "Received an unauthenticated dummy onion message"); + return Err(()); + } + + let onion_message = compute_onion_message( + msg.onion_routing_packet.public_key, + next_hop_hmac, + new_packet_bytes, + None, + )?; + peel_onion_message(&onion_message, secp_ctx, node_signer, logger, custom_handler) + }, Ok(( Payload::Forward(ForwardControlTlvs::Unblinded(ForwardTlvs { next_hop, @@ -1230,46 +1310,12 @@ where })), Some((next_hop_hmac, new_packet_bytes)), )) => { - // TODO: we need to check whether `next_hop` is our node, in which case this is a dummy - // blinded hop and this onion message is destined for us. In this situation, we should keep - // unwrapping the onion layers to get to the final payload. Since we don't have the option - // of creating blinded paths with dummy hops currently, we should be ok to not handle this - // for now. - let packet_pubkey = msg.onion_routing_packet.public_key; - let new_pubkey_opt = - onion_utils::next_hop_pubkey(&secp_ctx, packet_pubkey, &onion_decode_ss); - let new_pubkey = match new_pubkey_opt { - Ok(pk) => pk, - Err(e) => { - log_trace!(logger, "Failed to compute next hop packet pubkey: {}", e); - return Err(()); - }, - }; - let outgoing_packet = Packet { - version: 0, - public_key: new_pubkey, - hop_data: new_packet_bytes, - hmac: next_hop_hmac, - }; - let onion_message = OnionMessage { - blinding_point: match next_blinding_override { - Some(blinding_point) => blinding_point, - None => { - match onion_utils::next_hop_pubkey( - &secp_ctx, - msg.blinding_point, - control_tlvs_ss.as_ref(), - ) { - Ok(bp) => bp, - Err(e) => { - log_trace!(logger, "Failed to compute next blinding point: {}", e); - return Err(()); - }, - } - }, - }, - onion_routing_packet: outgoing_packet, - }; + let onion_message = compute_onion_message( + msg.onion_routing_packet.public_key, + next_hop_hmac, + new_packet_bytes, + next_blinding_override, + )?; Ok(PeeledOnion::Forward(next_hop, onion_message)) }, diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index 301473fba6a..5b2bf63c1a5 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -17,7 +17,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; @@ -112,6 +114,13 @@ impl LengthReadable for Packet { pub(super) enum Payload { /// This payload is for an intermediate hop. Forward(ForwardControlTlvs), + /// This payload is a dummy hop, and is intended to be peeled. + Dummy { + /// The [`DummyControlTlvs`] were authenticated with the additional key that was + /// provided to [`ReadableArgs::read`]. + control_tlvs_authenticated: bool, + control_tlvs: DummyControlTlvs, + }, /// This payload is for the final hop. Receive { /// The [`ReceiveControlTlvs`] were authenticated with the additional key which was @@ -212,6 +221,9 @@ pub(super) enum ForwardControlTlvs { Unblinded(ForwardTlvs), } +/// Dummy control TLVs, used for dummy hops. +pub(super) struct DummyControlTlvs(pub(super) DummyTlv); + /// Receive control TLVs in their blinded and unblinded form. pub(super) enum ReceiveControlTlvs { /// See [`ForwardControlTlvs::Blinded`]. @@ -243,6 +255,13 @@ impl Writeable for (Payload, [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: DummyControlTlvs(control_tlvs), + control_tlvs_authenticated: _, + } => { + let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &control_tlvs); + _encode_varint_length_prefixed_tlv!(w, { (4, write_adapter, required) }) + }, Payload::Receive { control_tlvs: ReceiveControlTlvs::Unblinded(control_tlvs), reply_path, @@ -323,6 +342,12 @@ impl } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) }, + Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Dummy(tlvs), used_aad }) => { + Ok(Payload::Dummy { + control_tlvs_authenticated: used_aad, + control_tlvs: DummyControlTlvs(tlvs), + }) + }, Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), used_aad }) => { Ok(Payload::Receive { control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), @@ -342,6 +367,8 @@ impl 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. + Dummy(DummyTlv), /// This onion message is intended to be received. Receive(ReceiveTlvs), } @@ -357,6 +384,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) { @@ -366,18 +394,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(DummyTlv {}), + (None, None, None) => ControlTlvs::Receive(ReceiveTlvs { context }), + _ => return Err(DecodeError::InvalidValue), }; Ok(payload_fmt) @@ -388,6 +411,7 @@ impl Writeable for ControlTlvs { fn write(&self, w: &mut W) -> Result<(), io::Error> { match self { Self::Forward(tlvs) => tlvs.write(w), + Self::Dummy(tlvs) => tlvs.write(w), Self::Receive(tlvs) => tlvs.write(w), } } diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 057a7406a5a..a8f2d30d273 100644 --- a/lightning/src/sign/mod.rs +++ b/lightning/src/sign/mod.rs @@ -837,19 +837,25 @@ pub trait EntropySource { /// A trait that can handle cryptographic operations at the scope level of a node. pub trait NodeSigner { - /// Get the [`ExpandedKey`] for use in encrypting and decrypting inbound payment data. + /// Get the [`ExpandedKey`] which provides cryptographic material for various Lightning Network operations. + /// + /// This key set is used for: + /// - Encrypting and decrypting inbound payment metadata + /// - Authenticating payment hashes (both LDK-provided and user-provided) + /// - Supporting BOLT 12 Offers functionality (key derivation and authentication) + /// - Authenticating spontaneous payments' metadata /// /// If the implementor of this trait supports [phantom node payments], then every node that is /// intended to be included in the phantom invoice route hints must return the same value from - /// this method. - // This is because LDK avoids storing inbound payment data by encrypting payment data in the - // payment hash and/or payment secret, therefore for a payment to be receivable by multiple - // nodes, they must share the key that encrypts this payment data. + /// this method. This is because LDK avoids storing inbound payment data by encrypting payment + /// data in the payment hash and/or payment secret, therefore for a payment to be receivable by + /// multiple nodes, they must share the key that encrypts this payment data. /// - /// This method must return the same value each time it is called. + /// Consistent return values across nodes are also essential for verifying signatures and decrypting + /// payment data in both Offers and spontaneous payments. /// /// [phantom node payments]: PhantomKeysManager - fn get_inbound_payment_key(&self) -> ExpandedKey; + fn get_expanded_key(&self) -> ExpandedKey; /// Defines a method to derive a 32-byte encryption key for peer storage. /// @@ -2184,7 +2190,7 @@ impl NodeSigner for KeysManager { Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { self.inbound_payment_key.clone() } @@ -2357,7 +2363,7 @@ impl NodeSigner for PhantomKeysManager { Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { self.inbound_payment_key.clone() } diff --git a/lightning/src/util/dyn_signer.rs b/lightning/src/util/dyn_signer.rs index fc2f6329aeb..b040ecab6f4 100644 --- a/lightning/src/util/dyn_signer.rs +++ b/lightning/src/util/dyn_signer.rs @@ -217,7 +217,7 @@ inner, fn sign_bolt12_invoice(, invoice: &crate::offers::invoice::UnsignedBolt12Invoice ) -> Result, - fn get_inbound_payment_key(,) -> ExpandedKey, + fn get_expanded_key(,) -> ExpandedKey, fn get_peer_storage_key(,) -> PeerStorageKey, fn get_receive_auth_key(,) -> ReceiveAuthKey ); @@ -284,7 +284,7 @@ delegate!(DynPhantomKeysInterface, NodeSigner, fn sign_invoice(, invoice: &RawBolt11Invoice, recipient: Recipient) -> Result, fn sign_bolt12_invoice(, invoice: &crate::offers::invoice::UnsignedBolt12Invoice ) -> Result, - fn get_inbound_payment_key(,) -> ExpandedKey, + fn get_expanded_key(,) -> ExpandedKey, fn get_peer_storage_key(,) -> PeerStorageKey, fn get_receive_auth_key(,) -> ReceiveAuthKey ); diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 776d876bbc8..7c23e05e9f3 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1550,7 +1550,7 @@ impl TestNodeSigner { } impl NodeSigner for TestNodeSigner { - fn get_inbound_payment_key(&self) -> ExpandedKey { + fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } @@ -1636,8 +1636,8 @@ impl NodeSigner for TestKeysInterface { self.backing.ecdh(recipient, other_key, tweak) } - fn get_inbound_payment_key(&self) -> ExpandedKey { - self.backing.get_inbound_payment_key() + fn get_expanded_key(&self) -> ExpandedKey { + self.backing.get_expanded_key() } fn sign_invoice(