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 88e68e5e01d..2e4e6bd4af2 100644 --- a/fuzz/src/full_stack.rs +++ b/fuzz/src/full_stack.rs @@ -80,9 +80,9 @@ use bitcoin::secp256k1::{self, Message, PublicKey, Scalar, Secp256k1, SecretKey} use lightning::util::dyn_signer::DynSigner; -use std::collections::VecDeque; use std::cell::RefCell; use std::cmp; +use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; @@ -406,7 +406,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 954247d936d..8105bb206ee 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,29 @@ 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 allows specifying a number of dummy hops. + /// + /// Note: + /// At most [`MAX_DUMMY_HOPS_COUNT`] dummy hops can be added to the blinded path. + 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 +114,7 @@ impl BlindedMessagePath { secp_ctx, intermediate_nodes, recipient_node_id, + dummy_hop_count, context, &blinding_secret, local_node_receive_key, @@ -266,6 +290,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 @@ -618,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 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()); @@ -641,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)) .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..3956fd9bcdf 100644 --- a/lightning/src/blinded_path/utils.rs +++ b/lightning/src/blinded_path/utils.rs @@ -276,16 +276,43 @@ 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(); - - // 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 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; + } + + let extra = first_len % padding_round_off; + + // Helper to check if a hop follows the padding pattern + let is_hop_padded = |hop: &BlindedHop| { + let len = hop.encrypted_payload.len(); + len > extra && (len - extra) % padding_round_off == 0 + }; + + // All hops must follow the same padding structure AND + // all hops except the final one must have the same length as the first + // to ensure proper masking. + hops.iter().all(is_hop_padded) + && hops + .iter() + .take(hops.len().saturating_sub(1)) + .all(|hop| hop.encrypted_payload.len() == first_len) } diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs index 2fa5dee8a84..e617f6fbf1f 100644 --- a/lightning/src/ln/async_payments_tests.rs +++ b/lightning/src/ln/async_payments_tests.rs @@ -255,7 +255,7 @@ fn create_static_invoice_builder<'a>( let created_at = recipient.node.duration_since_epoch(); let payment_secret = inbound_payment::create_for_spontaneous_payment( - &recipient.keys_manager.get_inbound_payment_key(), + &recipient.keys_manager.get_expanded_key(), amount_msat, relative_expiry_secs, created_at.as_secs(), @@ -982,7 +982,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 0631db3d408..8db793054dc 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 c953e39d6d6..fa12f1e4b39 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -315,7 +315,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, @@ -3732,7 +3732,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( @@ -16697,7 +16697,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 f7b2a4a2b57..17c2526e78d 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, @@ -321,7 +321,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. @@ -342,7 +342,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 71f73e04c2f..6d025293b9e 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -3589,7 +3589,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); } @@ -3741,7 +3741,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 6c56ecc4270..e2bdfc44e29 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -60,7 +60,7 @@ 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, PADDED_PATH_LENGTH}; use crate::onion_message::offers::OffersMessage; use crate::routing::gossip::{NodeAlias, NodeId}; use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig}; @@ -435,6 +435,76 @@ 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. +/// +/// Also verifies that the resulting paths are functional: the counterparty can respond with a valid `invoice_request`. +#[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 alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + // 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, alice.keys_manager); + + let compact_offer = alice.node + .create_offer_builder_using_router(&default_router).unwrap() + .amount_msats(10_000_000) + .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" + ); + } + + let payment_id = PaymentId([1; 32]); + bob.node.pay_for_offer(&compact_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap(); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); + + assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id)); + + // 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, alice.keys_manager); + + let padded_offer = alice.node + .create_offer_builder_using_router(&node_id_router).unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + assert!(!padded_offer.paths().is_empty()); + assert!(padded_offer.paths().iter().all(|path| path.blinded_hops().len() == PADDED_PATH_LENGTH)); + + let payment_id = PaymentId([2; 32]); + bob.node.pay_for_offer(&padded_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap(); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message); + + assert_eq!(invoice_request.amount_msats(), Some(10_000_000)); + assert_ne!(invoice_request.payer_signing_pubkey(), bob_id); + assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id)); +} + /// Checks that blinded paths are compact for short-lived offers. #[test] fn creates_short_lived_offer() { @@ -2272,7 +2342,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..4bec3dc31b3 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -145,6 +145,9 @@ const CUSTOM_PONG_MESSAGE_TYPE: u64 = 4343; const CUSTOM_PING_MESSAGE_CONTENTS: [u8; 32] = [42; 32]; const CUSTOM_PONG_MESSAGE_CONTENTS: [u8; 32] = [43; 32]; +/// A dummy hop count for testing purposes. +const TEST_DUMMY_HOP_COUNT: usize = 5; + impl OnionMessageContents for TestCustomMessage { fn tlv_type(&self) -> u64 { match self { @@ -443,6 +446,34 @@ fn one_blinded_hop() { pass_along_path(&nodes); } +#[test] +fn blinded_path_with_dummy_hops() { + 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, + TEST_DUMMY_HOP_COUNT, + 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 +689,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, + TEST_DUMMY_HOP_COUNT, receive_key, context, entropy, @@ -694,9 +726,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, + TEST_DUMMY_HOP_COUNT, receive_key, context, entropy, diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index 553977dfe18..ede82b41aa8 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -540,6 +540,14 @@ where entropy_source: ES, } +// Target total length (in hops) for non-compact blinded paths. +// We pad with dummy hops until the path reaches this length, +// obscuring the recipient's true position. +// +// Compact paths are optimized for minimal size, so we avoid +// adding dummy hops to them. +pub(crate) const PADDED_PATH_LENGTH: usize = 4; + impl>, L: Deref, ES: Deref> DefaultMessageRouter where L::Target: Logger, @@ -595,40 +603,46 @@ where a_tor_only.cmp(b_tor_only).then(a_channels.cmp(b_channels).reverse()) }); - let entropy = &**entropy_source; - let paths = peer_info + let build_path = |intermediate_hops: &[MessageForwardNode]| { + let dummy_hops_count = if compact_paths { + 0 + } else { + // Add one for the final recipient TLV + PADDED_PATH_LENGTH.saturating_sub(intermediate_hops.len() + 1) + }; + + BlindedMessagePath::new_with_dummy_hops( + intermediate_hops, + recipient, + dummy_hops_count, + local_node_receive_key, + context.clone(), + &**entropy_source, + secp_ctx, + ) + }; + + // Try to create paths from peer info, fall back to direct path if needed + let mut paths = peer_info .into_iter() - .map(|(peer, _, _)| { - BlindedMessagePath::new( - &[peer], - recipient, - local_node_receive_key, - context.clone(), - entropy, - secp_ctx, - ) - }) + .map(|(peer, _, _)| build_path(&[peer])) .take(MAX_PATHS) - .collect::, _>>(); - - let mut paths = match paths { - Ok(paths) if !paths.is_empty() => Ok(paths), - _ => { - if is_recipient_announced { - BlindedMessagePath::new( - &[], - recipient, - local_node_receive_key, - context, - &**entropy_source, - secp_ctx, - ) + .collect::, _>>() + .ok() + .filter(|paths| !paths.is_empty()) + .or_else(|| { + is_recipient_announced + .then(|| build_path(&[])) + .and_then(|result| result.ok()) .map(|path| vec![path]) - } else { - Err(()) - } - }, - }?; + }) + .ok_or(())?; + + // Sanity check: Ones the paths are created for the non-compact case, ensure + // each of them are of the length `PADDED_PATH_LENGTH`. + if !compact_paths { + debug_assert!(paths.iter().all(|path| path.blinded_hops().len() == PADDED_PATH_LENGTH)); + } if compact_paths { for path in &mut paths { @@ -1144,6 +1158,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 build_outbound_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 { @@ -1216,6 +1268,23 @@ where Err(()) }, }, + Ok(( + Payload::Dummy { control_tlvs_authenticated }, + 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 = build_outbound_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, @@ -1223,46 +1292,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 = build_outbound_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 ee41ee98572..2e0ccaf3a3e 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 { /// 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 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_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 } 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 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(&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), } } diff --git a/lightning/src/sign/mod.rs b/lightning/src/sign/mod.rs index 057a7406a5a..4a3383c443f 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. /// - /// 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 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 /// /// This method must return the same value each time it is called. /// + /// 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. Instead, this key + /// is used to construct a payment secret which is received in the payment onion and used to + /// reconstruct the payment preimage. Therefore, for a payment to be receivable by multiple + /// nodes, they must share the same key. + /// /// [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 e5388b76049..22334b182dc 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(