From d593175ab5bf4cb4ca05aa990a67451904785749 Mon Sep 17 00:00:00 2001 From: Duncan Dean Date: Tue, 23 Jun 2026 15:13:18 +0200 Subject: [PATCH] Add Blinded(Message/Payment)Path constructors for external recipients Add `new_for_external_recipient`, which builds a recipient hop using standard BOLT-4 ChaCha20Poly1305 (empty AAD) and no `MessageContext` or deanonymisation protections. A similar constructor is introduced for `BlindedPaymentPath` to build a compatible payee hop. Tests by Claude --- lightning/src/blinded_path/message.rs | 57 ++++++++-- lightning/src/blinded_path/payment.rs | 102 +++++++++++++++--- .../src/onion_message/functional_tests.rs | 44 +++++++- .../blinded-path-external-recipient.txt | 11 ++ 4 files changed, 192 insertions(+), 22 deletions(-) create mode 100644 pending_changelog/blinded-path-external-recipient.txt diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs index 67233e306c2..2a8a8a0fa1e 100644 --- a/lightning/src/blinded_path/message.rs +++ b/lightning/src/blinded_path/message.rs @@ -113,6 +113,50 @@ impl BlindedMessagePath { intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey, dummy_hop_count: usize, local_node_receive_key: ReceiveAuthKey, context: MessageContext, compact_padding: bool, entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Self { + Self::new_inner( + intermediate_nodes, + recipient_node_id, + dummy_hop_count, + Some(local_node_receive_key), + Some(context), + compact_padding, + entropy_source, + secp_ctx, + ) + } + + /// Create a path for a message to a recipient that is not an LDK node (e.g. CLN). + /// + /// The other constructors authenticate the recipient hop with a [`ReceiveAuthKey`] and require a + /// [`MessageContext`], which a non-LDK node cannot decrypt. This drops deanonymization protections + /// and adds no dummy hops. + /// + /// See [`BlindedMessagePath::new`] regarding `compact_padding`. + pub fn new_for_external_recipient< + ES: EntropySource, + T: secp256k1::Signing + secp256k1::Verification, + >( + intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey, + compact_padding: bool, entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Self { + Self::new_inner( + intermediate_nodes, + recipient_node_id, + 0, + None, + None, + compact_padding, + entropy_source, + secp_ctx, + ) + } + + fn new_inner( + intermediate_nodes: &[MessageForwardNode], recipient_node_id: PublicKey, + dummy_hop_count: usize, local_node_receive_key: Option, + context: Option, compact_padding: bool, entropy_source: ES, + secp_ctx: &Secp256k1, ) -> Self { let introduction_node = IntroductionNode::NodeId( intermediate_nodes.first().map_or(recipient_node_id, |n| n.node_id), @@ -760,17 +804,16 @@ 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, dummy_hop_count: usize, context: MessageContext, - session_priv: &SecretKey, local_node_receive_key: ReceiveAuthKey, compact_padding: bool, + recipient_node_id: PublicKey, dummy_hop_count: usize, context: Option, + session_priv: &SecretKey, local_node_receive_key: Option, + compact_padding: bool, ) -> Vec { 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)))); + .chain(core::iter::repeat((recipient_node_id, local_node_receive_key)).take(dummy_count)) + .chain(core::iter::once((recipient_node_id, local_node_receive_key))); let intermediate_tlvs = pks .clone() @@ -816,7 +859,7 @@ pub(super) fn blinded_hops( res }) .chain(core::iter::once(BlindedPathWithPadding { - tlvs: ControlTlvs::Receive(ReceiveTlvs { context: Some(context) }), + tlvs: ControlTlvs::Receive(ReceiveTlvs { context }), round_off: if compact_padding { 0 } else { MESSAGE_PADDING_ROUND_OFF }, })); diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 5fd608d6135..b52738ba293 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -120,7 +120,35 @@ impl BlindedPaymentPath { BlindedPaymentPath::new_inner( intermediate_nodes, payee_node_id, - local_node_receive_key, + Some(local_node_receive_key), + &[], + payee_tlvs, + htlc_maximum_msat, + min_final_cltv_expiry_delta, + entropy_source, + secp_ctx, + ) + } + + /// Create a blinded path for a payment to a recipient that is not an LDK node (e.g. CLN). + /// + /// The other constructors authenticate the payee hop with a [`ReceiveAuthKey`], which a non-LDK + /// node cannot decrypt. This instead builds a standard BOLT-4 payee hop (no [`ReceiveAuthKey`]), + /// dropping the [`ReceiveAuthKey`]-based deanonymization protections and adding no dummy hops. + /// + /// See [`BlindedPaymentPath::new`] regarding errors. + pub fn new_for_external_recipient< + ES: EntropySource, + T: secp256k1::Signing + secp256k1::Verification, + >( + intermediate_nodes: &[PaymentForwardNode], payee_node_id: PublicKey, + payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, + entropy_source: ES, secp_ctx: &Secp256k1, + ) -> Result { + BlindedPaymentPath::new_inner( + intermediate_nodes, + payee_node_id, + None, &[], payee_tlvs, htlc_maximum_msat, @@ -153,7 +181,7 @@ impl BlindedPaymentPath { BlindedPaymentPath::new_inner( intermediate_nodes, payee_node_id, - local_node_receive_key, + Some(local_node_receive_key), dummy_tlvs, payee_tlvs, htlc_maximum_msat, @@ -176,7 +204,7 @@ impl BlindedPaymentPath { Self::new_inner( intermediate_nodes, payee_node_id, - local_node_receive_key, + Some(local_node_receive_key), &[], payee_tlvs, htlc_maximum_msat, @@ -192,9 +220,9 @@ impl BlindedPaymentPath { T: secp256k1::Signing + secp256k1::Verification, >( intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, - local_node_receive_key: ReceiveAuthKey, dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs, - htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, entropy_source: ES, - secp_ctx: &Secp256k1, + local_node_receive_key: Option, dummy_tlvs: &[DummyTlvs], + payee_tlvs: ReceiveTlvs, htlc_maximum_msat: u64, min_final_cltv_expiry_delta: u16, + entropy_source: ES, secp_ctx: &Secp256k1, ) -> Result { let introduction_node = IntroductionNode::NodeId( intermediate_nodes.first().map_or(payee_node_id, |n| n.node_id), @@ -884,13 +912,13 @@ pub(crate) const PAYMENT_PADDING_ROUND_OFF: usize = 30; pub(super) fn blinded_hops( secp_ctx: &Secp256k1, intermediate_nodes: &[ForwardNode], payee_node_id: PublicKey, dummy_tlvs: &[DummyTlvs], payee_tlvs: ReceiveTlvs, session_priv: &SecretKey, - local_node_receive_key: ReceiveAuthKey, + local_node_receive_key: Option, ) -> Vec { let pks = intermediate_nodes .iter() .map(|node| (node.node_id, None)) - .chain(dummy_tlvs.iter().map(|_| (payee_node_id, Some(local_node_receive_key)))) - .chain(core::iter::once((payee_node_id, Some(local_node_receive_key)))); + .chain(dummy_tlvs.iter().map(|_| (payee_node_id, local_node_receive_key))) + .chain(core::iter::once((payee_node_id, local_node_receive_key))); let tlvs = intermediate_nodes .iter() .map(|node| BlindedPaymentTlvsRef::Forward(&node.tlvs)) @@ -1126,13 +1154,19 @@ impl_ser_tlv_based!(Bolt12RefundContext, { #[cfg(test)] mod tests { use crate::blinded_path::payment::{ - Bolt12RefundContext, ForwardTlvs, PaymentConstraints, PaymentContext, PaymentForwardNode, - PaymentRelay, ReceiveTlvs, + BlindedPaymentPath, BlindedPaymentTlvs, Bolt12RefundContext, ForwardTlvs, + PaymentConstraints, PaymentContext, PaymentForwardNode, PaymentRelay, ReceiveTlvs, }; + use crate::crypto::streams::ChaChaPolyReadAdapter; + use crate::io; use crate::ln::functional_test_utils::TEST_FINAL_CLTV; + use crate::ln::onion_utils::gen_rho_from_shared_secret; + use crate::sign::RandomBytes; use crate::types::features::BlindedHopFeatures; use crate::types::payment::PaymentSecret; - use bitcoin::secp256k1::PublicKey; + use crate::util::ser::{FixedLengthReader, LengthReadableArgs}; + use bitcoin::secp256k1::ecdh::SharedSecret; + use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; #[test] fn compute_payinfo() { @@ -1430,4 +1464,48 @@ mod tests { }; assert!(super::amt_to_forward_msat(2, &payment_relay).is_none()); } + + #[test] + fn blinded_path_for_external_recipient() { + // Check the payee hop of a path for a non-LDK recipient can be read by a spec-standard + // ChaCha20Poly1305 decryptor keyed on `rho` (no `ReceiveAuthKey`, no swapped AAD). + let secp_ctx = Secp256k1::new(); + let entropy_source = RandomBytes::new([42; 32]); + let payee_secret = SecretKey::from_slice(&[2; 32]).unwrap(); + let payee_node_id = PublicKey::from_secret_key(&secp_ctx, &payee_secret); + let payment_secret = PaymentSecret([3; 32]); + let payee_tlvs = ReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { max_cltv_expiry: 0, htlc_minimum_msat: 1 }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext { + payment_metadata: None, + }), + }; + + let path = BlindedPaymentPath::new_for_external_recipient( + &[], + payee_node_id, + payee_tlvs, + u64::max_value(), + TEST_FINAL_CLTV as u16, + &entropy_source, + &secp_ctx, + ) + .unwrap(); + + // The payee hop must decrypt under standard BOLT-4 rho, i.e. without swapped-AAD framing. + let ss = SharedSecret::new(&path.blinding_point(), &payee_secret); + let rho = gen_rho_from_shared_secret(&ss.secret_bytes()); + let encrypted_payload = &path.blinded_hops()[0].encrypted_payload; + let mut s = io::Cursor::new(encrypted_payload); + let mut reader = FixedLengthReader::new(&mut s, encrypted_payload.len() as u64); + let ChaChaPolyReadAdapter { readable } = + >::read(&mut reader, rho).unwrap(); + match readable { + BlindedPaymentTlvs::Receive(tlvs) => { + assert_eq!(tlvs.payment_secret, payment_secret); + }, + _ => panic!("Expected a receive-hop payment TLV"), + } + } } diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs index 4adc126f4fd..d5b5773933e 100644 --- a/lightning/src/onion_message/functional_tests.rs +++ b/lightning/src/onion_message/functional_tests.rs @@ -21,24 +21,27 @@ use super::messenger::{ OnionMessagePath, OnionMessenger, Responder, ResponseInstruction, SendError, SendSuccess, }; use super::offers::{OffersMessage, OffersMessageHandler}; -use super::packet::{OnionMessageContents, Packet}; +use super::packet::{ControlTlvs, OnionMessageContents, Packet}; use crate::blinded_path::message::{ AsyncPaymentsContext, BlindedMessagePath, DNSResolverContext, MessageContext, - MessageForwardNode, NextMessageHop, OffersContext, MESSAGE_PADDING_ROUND_OFF, + MessageForwardNode, NextMessageHop, OffersContext, ReceiveTlvs, MESSAGE_PADDING_ROUND_OFF, }; use crate::blinded_path::utils::is_padded; use crate::blinded_path::NodeIdLookUp; +use crate::crypto::streams::ChaChaPolyReadAdapter; use crate::events::{Event, EventsProvider}; use crate::ln::msgs::{self, BaseMessageHandler, DecodeError, OnionMessageHandler}; +use crate::ln::onion_utils::gen_rho_from_shared_secret; use crate::routing::gossip::{NetworkGraph, P2PGossipSync}; use crate::routing::test_utils::{add_channel, add_or_update_node}; use crate::sign::{NodeSigner, Recipient}; use crate::types::features::{ChannelFeatures, InitFeatures}; -use crate::util::ser::{FixedLengthReader, LengthReadable, Writeable, Writer}; +use crate::util::ser::{FixedLengthReader, LengthReadable, LengthReadableArgs, Writeable, Writer}; use crate::util::test_utils::{TestChainSource, TestKeysInterface, TestLogger, TestNodeSigner}; use bitcoin::hex::FromHex; use bitcoin::network::Network; +use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::{All, PublicKey, Secp256k1, SecretKey}; use crate::io; @@ -480,6 +483,41 @@ fn one_blinded_hop() { pass_along_path(&nodes); } +#[test] +fn blinded_path_for_external_recipient() { + // Check a path for a non-LDK recipient is delivered, and that its recipient hop can be read by a + // spec-standard ChaCha20Poly1305 decryptor with no context. + let nodes = create_nodes(2); + let test_msg = TestCustomMessage::Pong; + + let secp_ctx = Secp256k1::new(); + let entropy = &*nodes[1].entropy_source; + let node_id = nodes[1].node_id; + let blinded_path = + BlindedMessagePath::new_for_external_recipient(&[], node_id, false, entropy, &secp_ctx); + + // The recipient hop must decrypt under standard ChaCha20Poly1305 (empty AAD) with no context. + { + let ss = SharedSecret::new(&blinded_path.blinding_point(), &nodes[1].privkey); + let rho = gen_rho_from_shared_secret(&ss.secret_bytes()); + let encrypted_payload = &blinded_path.blinded_hops()[0].encrypted_payload; + let mut s = io::Cursor::new(encrypted_payload); + let mut reader = FixedLengthReader::new(&mut s, encrypted_payload.len() as u64); + let ChaChaPolyReadAdapter { readable } = + >::read(&mut reader, rho).unwrap(); + match readable { + ControlTlvs::Receive(ReceiveTlvs { context }) => assert!(context.is_none()), + _ => panic!("Expected a receive-hop control TLV"), + } + } + + 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 blinded_path_with_dummy_hops() { let nodes = create_nodes(2); diff --git a/pending_changelog/blinded-path-external-recipient.txt b/pending_changelog/blinded-path-external-recipient.txt new file mode 100644 index 00000000000..68285413b49 --- /dev/null +++ b/pending_changelog/blinded-path-external-recipient.txt @@ -0,0 +1,11 @@ +# API Updates + + * `BlindedMessagePath::new_for_external_recipient` was added to build a blinded message path whose + recipient hop terminates at a non-LDK node (e.g. CLN). It builds a standard BOLT-4 recipient hop + with no `ReceiveAuthKey` and no `MessageContext`, so that such a recipient can decrypt its + `encrypted_recipient_data`, dropping the `ReceiveAuthKey`-based deanonymization protections and + adding no dummy hops. + * `BlindedPaymentPath::new_for_external_recipient` was added to build a blinded payment path whose + payee hop terminates at a non-LDK node (e.g. CLN). It builds a standard BOLT-4 payee hop with no + `ReceiveAuthKey`, so that such a recipient can decrypt its `encrypted_recipient_data`, dropping + the `ReceiveAuthKey`-based deanonymization protections and adding no dummy hops.