From 5b8bfc62621d13a9a6fc32e37c0f25312a061c9c Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 02:22:59 +0000 Subject: [PATCH 1/9] Fix 0.2 CHANGELOG to note that offers will break on downgrade It turns out we also switched the key we use to authenticate offers *created* in the 0.2 upgrade and as a result downgrading to 0.2 will break any offers created on 0.2. This wasn't intentional but it doesn't really seem worth fixing at this point, so just document it. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a51c5fda8bd..808b960414e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -175,6 +175,8 @@ generated for inclusion in BOLT 12 `Offer`s will no longer be accepted. As most blinded message paths are ephemeral, this should only invalidate issued BOLT 12 `Refund`s in practice (#3917). + * Blinded message paths included in BOLT 12 `Offer`s generated by LDK 0.2 will + not be accepted by prior versions of LDK after downgrade (#3917). * Once a channel has been spliced, LDK can no longer be downgraded. `UserConfig::reject_inbound_splices` can be set to block inbound ones (#4150) * Downgrading after setting `UserConfig::enable_htlc_hold` is not supported From a7832be3abf0c886c981771880dd64d0c69efa79 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:10:32 +0000 Subject: [PATCH 2/9] Add an `ExpandedKey` key for phantom blinded path authentication In the coming commits we'll add support for building a blinded path which can be received to any one of several nodes in a "phantom" configuration (terminology we retain from BOLT 11 though there are no longer any phantom nodes in the paths). Here we adda new key in `ExpandedKey` which we can use to authenticate blinded paths as coming from a phantom node participant. --- lightning/src/crypto/utils.rs | 15 ++++++++++----- lightning/src/ln/inbound_payment.rs | 10 ++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/lightning/src/crypto/utils.rs b/lightning/src/crypto/utils.rs index b59cc6002d9..c05735b0ade 100644 --- a/lightning/src/crypto/utils.rs +++ b/lightning/src/crypto/utils.rs @@ -24,7 +24,7 @@ macro_rules! hkdf_extract_expand { let (k1, k2, _) = hkdf_extract_expand!($salt, $ikm); (k1, k2) }}; - ($salt: expr, $ikm: expr, 6) => {{ + ($salt: expr, $ikm: expr, 7) => {{ let (k1, k2, prk) = hkdf_extract_expand!($salt, $ikm); let mut hmac = HmacEngine::::new(&prk[..]); @@ -47,7 +47,12 @@ macro_rules! hkdf_extract_expand { hmac.input(&[6; 1]); let k6 = Hmac::from_engine(hmac).to_byte_array(); - (k1, k2, k3, k4, k5, k6) + let mut hmac = HmacEngine::::new(&prk[..]); + hmac.input(&k6); + hmac.input(&[7; 1]); + let k7 = Hmac::from_engine(hmac).to_byte_array(); + + (k1, k2, k3, k4, k5, k6, k7) }}; } @@ -55,10 +60,10 @@ pub fn hkdf_extract_expand_twice(salt: &[u8], ikm: &[u8]) -> ([u8; 32], [u8; 32] hkdf_extract_expand!(salt, ikm, 2) } -pub fn hkdf_extract_expand_6x( +pub fn hkdf_extract_expand_7x( salt: &[u8], ikm: &[u8], -) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { - hkdf_extract_expand!(salt, ikm, 6) +) -> ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 32]) { + hkdf_extract_expand!(salt, ikm, 7) } #[inline] diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 17c2526e78d..2ae396f78bb 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -15,7 +15,7 @@ use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; use crate::crypto::chacha20::ChaCha20; -use crate::crypto::utils::hkdf_extract_expand_6x; +use crate::crypto::utils::hkdf_extract_expand_7x; use crate::ln::msgs; use crate::ln::msgs::MAX_VALUE_MSAT; use crate::offers::nonce::Nonce; @@ -58,6 +58,10 @@ pub struct ExpandedKey { /// The key used to authenticate spontaneous payments' metadata as previously registered with LDK /// for inclusion in a blinded path. spontaneous_pmt_key: [u8; 32], + /// The key used to authenticate phantom-node-shared blinded paths as generated by us. Note + /// that this is not used for blinded paths which are not expected to be shared across nodes + /// participating in a "phantom node". + pub(crate) phantom_node_blinded_path_key: [u8; 32], } impl ExpandedKey { @@ -72,7 +76,8 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, - ) = hkdf_extract_expand_6x(b"LDK Inbound Payment Key Expansion", &key_material); + phantom_node_blinded_path_key, + ) = hkdf_extract_expand_7x(b"LDK Inbound Payment Key Expansion", &key_material); Self { metadata_key, ldk_pmt_hash_key, @@ -80,6 +85,7 @@ impl ExpandedKey { offers_base_key, offers_encryption_key, spontaneous_pmt_key, + phantom_node_blinded_path_key, } } From bcaf6c8f6c6004e7605a334077127cb362d186d9 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Thu, 22 Jan 2026 12:11:28 +0000 Subject: [PATCH 3/9] Accept blinded paths built by a phantom node participant In the next commit we'll add support for building a BOLT 12 offer which can be paid to any one of a number of participant nodes. Here we add support for validating blinded paths as coming from one of the participating nodes by deriving a new key as a part of the `ExpandedKey`. We keep this separate from the existing `ReceiveAuthKey` which is node-specific to ensure that we only allow this key to be used for blinded payment paths and contexts in `invoice_request` messages. This ensures that normal onion messages are still tied to specific nodes. Note that we will not yet use the blinded payment path phantom support which requires additional future work. However, allowing them to be authenticated in a phantom configuration should allow for compatibility across versions once the building logic lands. --- lightning/src/blinded_path/payment.rs | 12 ++-- lightning/src/crypto/streams.rs | 59 +++++++++++--------- lightning/src/ln/blinded_payment_tests.rs | 4 +- lightning/src/ln/msgs.rs | 46 +++++++++------ lightning/src/onion_message/messenger.rs | 26 +++++---- lightning/src/onion_message/packet.rs | 68 +++++++++++++++-------- lightning/src/util/test_utils.rs | 2 +- 7 files changed, 133 insertions(+), 84 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index b68be811cb4..60a945771ef 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -14,7 +14,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::ChaChaTriPolyReadAdapter; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -284,15 +284,17 @@ impl BlindedPaymentPath { node_signer.ecdh(Recipient::Node, &self.inner_path.blinding_point, None)?; let rho = onion_utils::gen_rho_from_shared_secret(&control_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_arg = (rho, receive_auth_key.0, phantom_auth_key); + let encrypted_control_tlvs = &self.inner_path.blinded_hops.get(0).ok_or(())?.encrypted_payload; let mut s = Cursor::new(encrypted_control_tlvs); let mut reader = FixedLengthReader::new(&mut s, encrypted_control_tlvs.len() as u64); - let ChaChaDualPolyReadAdapter { readable, used_aad } = - ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0)) - .map_err(|_| ())?; + let ChaChaTriPolyReadAdapter { readable, used_aad_a, used_aad_b } = + ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; - match (&readable, used_aad) { + match (&readable, used_aad_a || used_aad_b) { (BlindedPaymentTlvs::Forward(_), false) | (BlindedPaymentTlvs::Dummy(_), true) | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index c406e933bc9..6bda4078b49 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -58,7 +58,7 @@ impl<'a, T: Writeable> Writeable for ChaChaPolyWriteAdapter<'a, T> { } /// Encrypts the provided plaintext with the given key using ChaCha20Poly1305 in the modified -/// with-AAD form used in [`ChaChaDualPolyReadAdapter`]. +/// with-AAD form used in [`ChaChaTriPolyReadAdapter`]. pub(crate) fn chachapoly_encrypt_with_swapped_aad( mut plaintext: Vec, key: [u8; 32], aad: [u8; 32], ) -> Vec { @@ -87,31 +87,34 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// -/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags twice, once using the given -/// key and once with the given 32-byte AAD appended after the encrypted stream, accepting either -/// being correct as sufficient. +/// This variant of [`ChaChaPolyReadAdapter`] calculates Poly1305 tags thrice, once using the given +/// key and twice with each of the two given 32-byte AADs appended after the encrypted stream, +/// accepting any being correct as sufficient. /// -/// Note that we do *not* use the provided AAD as the standard ChaCha20Poly1305 AAD as that would +/// Note that we do *not* use the provided AADs as the standard ChaCha20Poly1305 AAD as that would /// require placing it first and prevent us from avoiding redundant Poly1305 rounds. Instead, the /// ChaCha20Poly1305 MAC check is tweaked to move the AAD to *after* the the contents being /// checked, effectively treating the contents as the AAD for the AAD-containing MAC but behaving /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. -pub(crate) struct ChaChaDualPolyReadAdapter { +pub(crate) struct ChaChaTriPolyReadAdapter { pub readable: R, - pub used_aad: bool, + pub used_aad_a: bool, + pub used_aad_b: bool, } -impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyReadAdapter { +impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> + for ChaChaTriPolyReadAdapter +{ // Simultaneously read and decrypt an object from a LengthLimitedRead storing it in // Self::readable. LengthLimitedRead must be used instead of std::io::Read because we need the // total length to separate out the tag at the end. fn read( - r: &mut R, params: ([u8; 32], [u8; 32]), + r: &mut R, params: ([u8; 32], [u8; 32], [u8; 32]), ) -> Result { if r.remaining_bytes() < 16 { return Err(DecodeError::InvalidValue); } - let (key, aad) = params; + let (key, aad_a, aad_b) = params; let mut chacha = ChaCha20::new(&key[..], &[0; 12]); let mut mac_key = [0u8; 64]; @@ -125,7 +128,7 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let decrypted_len = r.remaining_bytes() - 16; let s = FixedLengthReader::new(r, decrypted_len); let mut chacha_stream = - ChaChaDualPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; + ChaChaTriPolyReader { chacha: &mut chacha, poly: &mut mac, read_len: 0, read: s }; let readable: T = Readable::read(&mut chacha_stream)?; while chacha_stream.read.bytes_remain() { @@ -142,14 +145,18 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea mac.input(&[0; 16][0..16 - (read_len % 16)]); } - let mut mac_aad = mac; + let mut mac_aad_a = mac; + let mut mac_aad_b = mac; - mac_aad.input(&aad[..]); + mac_aad_a.input(&aad_a[..]); + mac_aad_b.input(&aad_b[..]); // Note that we don't need to pad the AAD since its a multiple of 16 bytes // For the AAD-containing MAC, swap the AAD and the read data, effectively. - mac_aad.input(&(read_len as u64).to_le_bytes()); - mac_aad.input(&32u64.to_le_bytes()); + mac_aad_a.input(&(read_len as u64).to_le_bytes()); + mac_aad_b.input(&(read_len as u64).to_le_bytes()); + mac_aad_a.input(&32u64.to_le_bytes()); + mac_aad_b.input(&32u64.to_le_bytes()); // For the non-AAD-containing MAC, leave the data and AAD where they belong. mac.input(&0u64.to_le_bytes()); @@ -158,23 +165,25 @@ impl LengthReadableArgs<([u8; 32], [u8; 32])> for ChaChaDualPolyRea let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad: false }) - } else if fixed_time_eq(&mac_aad.result(), &tag) { - Ok(Self { readable, used_aad: true }) + Ok(Self { readable, used_aad_a: false, used_aad_b: false }) + } else if fixed_time_eq(&mac_aad_a.result(), &tag) { + Ok(Self { readable, used_aad_a: true, used_aad_b: false }) + } else if fixed_time_eq(&mac_aad_b.result(), &tag) { + Ok(Self { readable, used_aad_a: false, used_aad_b: true }) } else { return Err(DecodeError::InvalidValue); } } } -struct ChaChaDualPolyReader<'a, R: Read> { +struct ChaChaTriPolyReader<'a, R: Read> { chacha: &'a mut ChaCha20, poly: &'a mut Poly1305, read_len: usize, pub read: R, } -impl<'a, R: Read> Read for ChaChaDualPolyReader<'a, R> { +impl<'a, R: Read> Read for ChaChaTriPolyReader<'a, R> { // Decrypts bytes from Self::read into `dest`. // After all reads complete, the caller must compare the expected tag with // the result of `Poly1305::result()`. @@ -349,15 +358,15 @@ mod tests { } #[test] - fn short_read_chacha_dual_read_adapter() { - // Previously, if we attempted to read from a ChaChaDualPolyReadAdapter but the object + fn short_read_chacha_tri_read_adapter() { + // Previously, if we attempted to read from a ChaChaTriPolyReadAdapter but the object // being read is shorter than the available buffer while the buffer passed to - // ChaChaDualPolyReadAdapter itself always thinks it has room, we'd end up + // ChaChaTriPolyReadAdapter itself always thinks it has room, we'd end up // infinite-looping as we didn't handle `Read::read`'s 0 return values at EOF. let mut stream = &[0; 1024][..]; let mut too_long_stream = FixedLengthReader::new(&mut stream, 2048); - let keys = ([42; 32], [99; 32]); - let res = super::ChaChaDualPolyReadAdapter::::read(&mut too_long_stream, keys); + let keys = ([42; 32], [98; 32], [99; 32]); + let res = super::ChaChaTriPolyReadAdapter::::read(&mut too_long_stream, keys); match res { Ok(_) => panic!(), Err(e) => assert_eq!(e, DecodeError::ShortRead), diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 74981ead7f1..e75420e99b4 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -1694,7 +1694,7 @@ fn route_blinding_spec_test_vector() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, @@ -2009,7 +2009,7 @@ fn test_trampoline_inbound_payment_decoding() { } Ok(SharedSecret::new(other_key, &node_secret)) } - fn get_expanded_key(&self) -> ExpandedKey { unreachable!() } + fn get_expanded_key(&self) -> ExpandedKey { ExpandedKey::new([42; 32]) } fn get_node_id(&self, _recipient: Recipient) -> Result { unreachable!() } fn sign_invoice( &self, _invoice: &RawBolt11Invoice, _recipient: Recipient, diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 2bb2b244ccb..193bdbd6990 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -56,7 +56,7 @@ use core::str::FromStr; #[cfg(feature = "std")] use std::net::SocketAddr; -use crate::crypto::streams::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::ChaChaTriPolyReadAdapter; use crate::util::base32; use crate::util::logger; use crate::util::ser::{ @@ -3711,10 +3711,13 @@ where .map_err(|_| DecodeError::InvalidValue)?; let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Forward(ForwardTlvs { short_channel_id, @@ -3723,13 +3726,14 @@ where features, next_blinding_override, }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad_a || used_aad_b { return Err(DecodeError::InvalidValue); } @@ -3742,16 +3746,17 @@ where next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || !used_aad + || (!used_aad_a && !used_aad_b) { return Err(DecodeError::InvalidValue); } @@ -3761,11 +3766,12 @@ where intro_node_blinding_point, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), - used_aad, + used_aad_a, + used_aad_b, } => { - if !used_aad { + if !used_aad_a && !used_aad_b { return Err(DecodeError::InvalidValue); } @@ -3831,6 +3837,7 @@ where fn read(r: &mut R, args: (Option, NS)) -> Result { let (update_add_blinding_point, node_signer) = args; let receive_auth_key = node_signer.get_receive_auth_key(); + let phantom_auth_key = node_signer.get_expanded_key().phantom_node_blinded_path_key; let mut amt = None; let mut cltv_value = None; @@ -3884,8 +3891,9 @@ where let rho = onion_utils::gen_rho_from_shared_secret(&enc_tlvs_ss.secret_bytes()); let mut s = Cursor::new(&enc_tlvs); let mut reader = FixedLengthReader::new(&mut s, enc_tlvs.len() as u64); - match ChaChaDualPolyReadAdapter::read(&mut reader, (rho, receive_auth_key.0))? { - ChaChaDualPolyReadAdapter { + let read_args = (rho, receive_auth_key.0, phantom_auth_key); + match ChaChaTriPolyReadAdapter::read(&mut reader, read_args)? { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Forward(TrampolineForwardTlvs { next_trampoline, @@ -3894,13 +3902,14 @@ where features, next_blinding_override, }), - used_aad, + used_aad_a, + used_aad_b, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad_a || used_aad_b { return Err(DecodeError::InvalidValue); } @@ -3913,11 +3922,12 @@ where next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), - used_aad, + used_aad_a, + used_aad_b, } => { - if !used_aad { + if !used_aad_a && !used_aad_b { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/messenger.rs b/lightning/src/onion_message/messenger.rs index dbeab3937d0..93ead03e10f 100644 --- a/lightning/src/onion_message/messenger.rs +++ b/lightning/src/onion_message/messenger.rs @@ -1203,12 +1203,13 @@ where }, } }; - let receiving_context_auth_key = node_signer.get_receive_auth_key(); + let receive_auth_key = node_signer.get_receive_auth_key(); + let expanded_key = &node_signer.get_expanded_key(); let next_hop = onion_utils::decode_next_untagged_hop( onion_decode_ss, &msg.onion_routing_packet.hop_data[..], msg.onion_routing_packet.hmac, - (control_tlvs_ss, custom_handler.deref(), receiving_context_auth_key, logger.deref()), + (control_tlvs_ss, &*custom_handler, receive_auth_key, expanded_key, &*logger), ); // Constructs the next onion message using packet data and blinding logic. @@ -1254,21 +1255,24 @@ where message, control_tlvs: ReceiveControlTlvs::Unblinded(ReceiveTlvs { context }), reply_path, - control_tlvs_authenticated, + control_tlvs_from_local_node, + control_tlvs_from_phantom_participant: _, }, None, )) => match (message, context) { (ParsedOnionMessageContents::Offers(msg), Some(MessageContext::Offers(ctx))) => { match ctx { OffersContext::InvoiceRequest { .. } => { - // Note: We introduced the `control_tlvs_authenticated` check in LDK v0.2 + // Note: We introduced the `control_tlvs_from_*` check in LDK v0.2 // to simplify and standardize onion message authentication. // To continue supporting offers created before v0.2, we allow // unauthenticated control TLVs for these messages, as they can be // verified using the legacy method. }, _ => { - if !control_tlvs_authenticated { + // In any other offers context, we only allow message authenticated as + // coming from our local, node, not any other phantom participant. + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated offers onion message"); return Err(()); } @@ -1283,14 +1287,14 @@ where ParsedOnionMessageContents::AsyncPayments(msg), Some(MessageContext::AsyncPayments(ctx)), ) => { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated async payments onion message"); return Err(()); } Ok(PeeledOnion::AsyncPayments(msg, ctx, reply_path)) }, (ParsedOnionMessageContents::Custom(msg), Some(MessageContext::Custom(ctx))) => { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated custom onion message"); return Err(()); } @@ -1303,7 +1307,7 @@ where ParsedOnionMessageContents::DNSResolver(msg), Some(MessageContext::DNSResolver(ctx)), ) => { - if !control_tlvs_authenticated { + if !control_tlvs_from_local_node { log_trace!(logger, "Received an unauthenticated DNS resolver onion message"); return Err(()); } @@ -2579,7 +2583,8 @@ fn packet_payloads_and_keys< control_tlvs, reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); @@ -2589,7 +2594,8 @@ fn packet_payloads_and_keys< control_tlvs: ReceiveControlTlvs::Unblinded(ReceiveTlvs { context: None }), reply_path: reply_path.take(), message, - control_tlvs_authenticated: false, + control_tlvs_from_local_node: false, + control_tlvs_from_phantom_participant: false, }, prev_control_tlvs_ss.unwrap(), )); diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index 2e0ccaf3a3e..b7779e87f01 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -19,7 +19,8 @@ use super::offers::OffersMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; -use crate::crypto::streams::{ChaChaDualPolyReadAdapter, ChaChaPolyWriteAdapter}; +use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter}; +use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; use crate::sign::ReceiveAuthKey; @@ -121,9 +122,16 @@ pub(super) enum Payload { }, /// This payload is for the final hop. Receive { - /// The [`ReceiveControlTlvs`] were authenticated with the additional key which was + /// The [`ReceiveControlTlvs`] were authenticated with the [`ReceiveAuthKey`] which was /// provided to [`ReadableArgs::read`]. - control_tlvs_authenticated: bool, + control_tlvs_from_local_node: bool, + /// The [`ReceiveControlTlvs`] were authenticated with the + /// [`ExpandedKey::phantom_node_blinded_path_key`] which was provided to + /// [`ReadableArgs::read`]. + /// Note that this is currently never actually read, but exists to signal the type of + /// authentication we can do. + #[allow(dead_code)] + control_tlvs_from_phantom_participant: bool, control_tlvs: ReceiveControlTlvs, reply_path: Option, message: T, @@ -233,7 +241,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Blinded(encrypted_bytes), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { _encode_varint_length_prefixed_tlv!(w, { (2, reply_path, option), @@ -253,7 +262,8 @@ impl Writeable for (Payload, [u8; 32]) { control_tlvs: ReceiveControlTlvs::Unblinded(control_tlvs), reply_path, message, - control_tlvs_authenticated: _, + control_tlvs_from_local_node: _, + control_tlvs_from_phantom_participant: _, } => { let write_adapter = ChaChaPolyWriteAdapter::new(self.1, &control_tlvs); _encode_varint_length_prefixed_tlv!(w, { @@ -269,24 +279,27 @@ impl Writeable for (Payload, [u8; 32]) { // Uses the provided secret to simultaneously decode and decrypt the control TLVs and data TLV. impl - ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &L)> + ReadableArgs<(SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L)> for Payload::CustomMessage>> { fn read( - r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &L), + r: &mut R, args: (SharedSecret, &H, ReceiveAuthKey, &ExpandedKey, &L), ) -> Result { - let (encrypted_tlvs_ss, handler, receive_tlvs_key, logger) = args; + let (encrypted_tlvs_ss, handler, receive_tlvs_key, expanded_key, logger) = args; let v: BigSize = Readable::read(r)?; let mut rd = FixedLengthReader::new(r, v.0); let mut reply_path: Option = None; - let mut read_adapter: Option> = None; + let mut read_adapter: Option> = None; let rho = onion_utils::gen_rho_from_shared_secret(&encrypted_tlvs_ss.secret_bytes()); + let read_adapter_args = + (rho, receive_tlvs_key.0, expanded_key.phantom_node_blinded_path_key); let mut message_type: Option = None; let mut message = None; + decode_tlv_stream_with_custom_tlv_decode!(&mut rd, { (2, reply_path, option), - (4, read_adapter, (option: LengthReadableArgs, (rho, receive_tlvs_key.0))), + (4, read_adapter, (option: LengthReadableArgs, read_adapter_args)), }, |msg_type, msg_reader| { if msg_type < 64 { return Ok(false) } // Don't allow reading more than one data TLV from an onion message. @@ -322,23 +335,32 @@ impl match read_adapter { None => return Err(DecodeError::InvalidValue), - Some(ChaChaDualPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), used_aad }) => { - if used_aad || message_type.is_some() { + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Forward(tlvs), + used_aad_a, + used_aad_b, + }) => { + if used_aad_a || used_aad_b || message_type.is_some() { return Err(DecodeError::InvalidValue); } 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), - reply_path, - message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_authenticated: used_aad, - }) - }, + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Dummy, + used_aad_a, + used_aad_b, + }) => Ok(Payload::Dummy { control_tlvs_authenticated: used_aad_a || used_aad_b }), + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Receive(tlvs), + used_aad_a, + used_aad_b, + }) => Ok(Payload::Receive { + control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), + reply_path, + message: message.ok_or(DecodeError::InvalidValue)?, + control_tlvs_from_local_node: used_aad_a, + control_tlvs_from_phantom_participant: used_aad_b, + }), } } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 34f5d5fe36e..a12b113b293 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1772,7 +1772,7 @@ impl TestNodeSigner { impl NodeSigner for TestNodeSigner { fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn get_peer_storage_key(&self) -> PeerStorageKey { From 2d92f16c9403fd5169e46947c9a81f9a9b3aafec Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:06:43 +0000 Subject: [PATCH 4/9] f use an enum --- lightning/src/blinded_path/payment.rs | 12 ++++++------ lightning/src/crypto/streams.rs | 21 ++++++++++++++++----- lightning/src/ln/msgs.rs | 27 +++++++++++---------------- lightning/src/onion_message/packet.rs | 21 ++++++++++----------- 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index 60a945771ef..36daecff2ef 100644 --- a/lightning/src/blinded_path/payment.rs +++ b/lightning/src/blinded_path/payment.rs @@ -14,7 +14,7 @@ use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; use crate::blinded_path::utils::{self, BlindedPathWithPadding}; use crate::blinded_path::{BlindedHop, BlindedPath, IntroductionNode, NodeIdLookUp}; -use crate::crypto::streams::ChaChaTriPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -291,13 +291,13 @@ impl BlindedPaymentPath { &self.inner_path.blinded_hops.get(0).ok_or(())?.encrypted_payload; let mut s = Cursor::new(encrypted_control_tlvs); let mut reader = FixedLengthReader::new(&mut s, encrypted_control_tlvs.len() as u64); - let ChaChaTriPolyReadAdapter { readable, used_aad_a, used_aad_b } = + let ChaChaTriPolyReadAdapter { readable, used_aad } = ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; - match (&readable, used_aad_a || used_aad_b) { - (BlindedPaymentTlvs::Forward(_), false) - | (BlindedPaymentTlvs::Dummy(_), true) - | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), + match (&readable, used_aad == TriPolyAADUsed::NoAAD) { + (BlindedPaymentTlvs::Forward(_), true) + | (BlindedPaymentTlvs::Dummy(_), false) + | (BlindedPaymentTlvs::Receive(_), false) => Ok((readable, control_tlvs_ss)), _ => Err(()), } } diff --git a/lightning/src/crypto/streams.rs b/lightning/src/crypto/streams.rs index 6bda4078b49..37c7a681037 100644 --- a/lightning/src/crypto/streams.rs +++ b/lightning/src/crypto/streams.rs @@ -84,6 +84,18 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( plaintext } +#[derive(PartialEq, Eq)] +pub(crate) enum TriPolyAADUsed { + /// No AAD was used. + /// + /// The HMAC validated with standard ChaCha20Poly1305. + NoAAD, + /// The HMAC vlidated using the first AAD provided. + A, + /// The HMAC vlidated using the second AAD provided. + B, +} + /// Enables the use of the serialization macros for objects that need to be simultaneously decrypted /// and deserialized. This allows us to avoid an intermediate Vec allocation. /// @@ -98,8 +110,7 @@ pub(crate) fn chachapoly_encrypt_with_swapped_aad( /// like classic ChaCha20Poly1305 for the non-AAD-containing MAC. pub(crate) struct ChaChaTriPolyReadAdapter { pub readable: R, - pub used_aad_a: bool, - pub used_aad_b: bool, + pub used_aad: TriPolyAADUsed, } impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> @@ -165,11 +176,11 @@ impl LengthReadableArgs<([u8; 32], [u8; 32], [u8; 32])> let mut tag = [0 as u8; 16]; r.read_exact(&mut tag)?; if fixed_time_eq(&mac.result(), &tag) { - Ok(Self { readable, used_aad_a: false, used_aad_b: false }) + Ok(Self { readable, used_aad: TriPolyAADUsed::NoAAD }) } else if fixed_time_eq(&mac_aad_a.result(), &tag) { - Ok(Self { readable, used_aad_a: true, used_aad_b: false }) + Ok(Self { readable, used_aad: TriPolyAADUsed::A }) } else if fixed_time_eq(&mac_aad_b.result(), &tag) { - Ok(Self { readable, used_aad_a: false, used_aad_b: true }) + Ok(Self { readable, used_aad: TriPolyAADUsed::B }) } else { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 193bdbd6990..4d611e06105 100644 --- a/lightning/src/ln/msgs.rs +++ b/lightning/src/ln/msgs.rs @@ -56,7 +56,7 @@ use core::str::FromStr; #[cfg(feature = "std")] use std::net::SocketAddr; -use crate::crypto::streams::ChaChaTriPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::util::base32; use crate::util::logger; use crate::util::ser::{ @@ -3726,14 +3726,13 @@ where features, next_blinding_override, }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad_a || used_aad_b + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3749,14 +3748,13 @@ where ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || (!used_aad_a && !used_aad_b) + || used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3768,10 +3766,9 @@ where }, ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), - used_aad_a, - used_aad_b, + used_aad, } => { - if !used_aad_a && !used_aad_b { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3902,14 +3899,13 @@ where features, next_blinding_override, }), - used_aad_a, - used_aad_b, + used_aad, } => { if amt.is_some() || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad_a || used_aad_b + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3924,10 +3920,9 @@ where }, ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), - used_aad_a, - used_aad_b, + used_aad, } => { - if !used_aad_a && !used_aad_b { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/onion_message/packet.rs b/lightning/src/onion_message/packet.rs index b7779e87f01..86cb47d30b0 100644 --- a/lightning/src/onion_message/packet.rs +++ b/lightning/src/onion_message/packet.rs @@ -19,7 +19,7 @@ use super::offers::OffersMessage; use crate::blinded_path::message::{ BlindedMessagePath, DummyTlv, ForwardTlvs, NextMessageHop, ReceiveTlvs, }; -use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter}; +use crate::crypto::streams::{ChaChaPolyWriteAdapter, ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::DecodeError; use crate::ln::onion_utils; @@ -337,29 +337,28 @@ impl None => return Err(DecodeError::InvalidValue), Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Forward(tlvs), - used_aad_a, - used_aad_b, + used_aad, }) => { - if used_aad_a || used_aad_b || message_type.is_some() { + if used_aad != TriPolyAADUsed::NoAAD || message_type.is_some() { return Err(DecodeError::InvalidValue); } Ok(Payload::Forward(ForwardControlTlvs::Unblinded(tlvs))) }, Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Dummy, - used_aad_a, - used_aad_b, - }) => Ok(Payload::Dummy { control_tlvs_authenticated: used_aad_a || used_aad_b }), + used_aad, + }) => Ok(Payload::Dummy { + control_tlvs_authenticated: used_aad != TriPolyAADUsed::NoAAD, + }), Some(ChaChaTriPolyReadAdapter { readable: ControlTlvs::Receive(tlvs), - used_aad_a, - used_aad_b, + used_aad, }) => Ok(Payload::Receive { control_tlvs: ReceiveControlTlvs::Unblinded(tlvs), reply_path, message: message.ok_or(DecodeError::InvalidValue)?, - control_tlvs_from_local_node: used_aad_a, - control_tlvs_from_phantom_participant: used_aad_b, + control_tlvs_from_local_node: used_aad == TriPolyAADUsed::A, + control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::B, }), } } From 73a0ba623a7930cf91082c81c7e4620b966b14ae Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 23:08:46 +0000 Subject: [PATCH 5/9] f fix fuzz --- fuzz/src/onion_message.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/src/onion_message.rs b/fuzz/src/onion_message.rs index 09634a1c373..70dfb0753d3 100644 --- a/fuzz/src/onion_message.rs +++ b/fuzz/src/onion_message.rs @@ -260,7 +260,7 @@ impl NodeSigner for KeyProvider { } fn get_expanded_key(&self) -> ExpandedKey { - unreachable!() + ExpandedKey::new([42; 32]) } fn sign_invoice( From 706b0db87ceb03ae2a8a910e577d9e61f00bd27b Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Wed, 21 Jan 2026 21:36:17 +0000 Subject: [PATCH 6/9] Add methods to fetch an `OfferBuilder` for "phantom" node configs In the BOLT 11 world, we have specific support for what we call "phantom nodes" - creating invoices which can be paid to any one of a number of nodes by adding route-hints which represent nodes that do not exist. In BOLT 12, blinded paths make a similar feature much simpler - we can simply add blinded paths which terminate at different nodes. The blinding means that the sender is none the wiser. Here we add logic to fetch an `OfferBuilder` which can generate an offer payable to any one of a set of nodes. We retain the "phantom" terminology even though there are no longer any "phantom" nodes. Note that the current logic only supports the `invoice_request` message going to any of the participating nodes, it then replies with a `Bolt12Invoice` which can only be paid to the responding node. Future work may relax this restriction. --- lightning/src/ln/channelmanager.rs | 71 ++++++++++++ lightning/src/ln/functional_test_utils.rs | 32 +++++- lightning/src/ln/offers_tests.rs | 130 ++++++++++++++++++++-- lightning/src/offers/flow.rs | 58 +++++++++- lightning/src/util/test_utils.rs | 22 +++- 5 files changed, 294 insertions(+), 19 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index af395154760..7eb5f46e373 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13416,6 +13416,45 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`ChannelManager`] (or [`OffersMessageFlow`]) using the same [`ExpandedKey`] (as returned + /// from [`NodeSigner::get_expanded_key`]). This allows any nodes participating in a BOLT 11 + /// "phantom node" cluster to also receive BOLT 12 payments. + /// + /// Note that, unlike with BOLT 11 invoices, BOLT 12 "phantom" offers do not in fact have any + /// "phantom node" appended to receiving paths. Instead, multiple blinded paths are simply + /// included which terminate at different final nodes. + /// + /// `other_nodes_channels` must be set to a list of each participating node's `node_id` (from + /// [`NodeSigner::get_node_id`] with a [`Recipient::Node`]) and its channels. + /// + /// `path_count_limit` is used to limit the number of blinded paths included in the resulting + /// [`Offer`]. Note that if this is less than the number of participating nodes (i.e. + /// `other_nodes_channels.len() + 1`) not all nodes will participate in receiving funds. + /// Because the parameterized [`MessageRouter`] will only get a chance to limit the number of + /// paths *per-node*, it is important to set this for offers which will be included in a QR + /// code. + /// + /// See [`Self::create_offer_builder`] for more details on the blinded path construction. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &$self, other_nodes_channels: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result<$builder, Bolt12SemanticError> { + let mut peers = Vec::with_capacity(other_nodes_channels.len() + 1); + peers.push(($self.get_our_node_id(), $self.get_peers_for_blinded_path())); + for (node_id, peer_chans) in other_nodes_channels { + peers.push((node_id, Self::channel_details_to_forward_node(peer_chans))); + } + + let builder = $self.flow.create_phantom_offer_builder( + &*$self.entropy_source, peers, path_count_limit + )?; + + Ok(builder.into()) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14045,6 +14084,38 @@ where now } + fn channel_details_to_forward_node( + mut channel_list: Vec, + ) -> Vec { + channel_list.sort_unstable_by_key(|chan| chan.counterparty.node_id); + let mut res = Vec::new(); + // TODO: When MSRV reaches 1.77 use chunk_by + let mut start = 0; + while start < channel_list.len() { + let counterparty_node_id = channel_list[start].counterparty.node_id; + let end = channel_list[start..] + .iter() + .position(|chan| chan.counterparty.node_id != counterparty_node_id) + .unwrap_or(channel_list.len()); + + let peer_chans = &channel_list[start..end]; + if peer_chans.iter().any(|chan| chan.is_usable) + && peer_chans.iter().any(|c| c.counterparty.features.supports_onion_messages()) + { + res.push(MessageForwardNode { + node_id: peer_chans[0].counterparty.node_id, + short_channel_id: peer_chans + .iter() + .filter(|chan| chan.is_usable) + .filter_map(|chan| chan.short_channel_id) + .min(), + }) + } + start = end; + } + res + } + fn get_peers_for_blinded_path(&self) -> Vec { let per_peer_state = self.per_peer_state.read().unwrap(); per_peer_state diff --git a/lightning/src/ln/functional_test_utils.rs b/lightning/src/ln/functional_test_utils.rs index 2cf5ea96acb..0678fe44096 100644 --- a/lightning/src/ln/functional_test_utils.rs +++ b/lightning/src/ln/functional_test_utils.rs @@ -4404,21 +4404,41 @@ pub fn create_chanmon_cfgs(node_count: usize) -> Vec { pub fn create_chanmon_cfgs_with_legacy_keys( node_count: usize, predefined_keys_ids: Option>, +) -> Vec { + create_chanmon_cfgs_internal(node_count, predefined_keys_ids, false) +} + +pub fn create_phantom_chanmon_cfgs(node_count: usize) -> Vec { + create_chanmon_cfgs_internal(node_count, None, true) +} + +pub fn create_chanmon_cfgs_internal( + node_count: usize, predefined_keys_ids: Option>, phantom: bool, ) -> Vec { let mut chan_mon_cfgs = Vec::new(); + let phantom_seed = if phantom { Some(&[42; 32]) } else { None }; for i in 0..node_count { let tx_broadcaster = test_utils::TestBroadcaster::new(Network::Testnet); let fee_estimator = test_utils::TestFeeEstimator::new(253); let chain_source = test_utils::TestChainSource::new(Network::Testnet); let logger = test_utils::TestLogger::with_id(format!("node {}", i)); let persister = test_utils::TestPersister::new(); - let seed = [i as u8; 32]; - let keys_manager = if predefined_keys_ids.is_some() { + let mut seed = [i as u8; 32]; + if phantom { + // We would ideally randomize keys on every test run, but some tests fail in that case. + // Instead, we only randomize in the phantom case. + use core::hash::{BuildHasher, Hasher}; + // Get a random value using the only std API to do so - the DefaultHasher + let rand_val = std::collections::hash_map::RandomState::new().build_hasher().finish(); + seed[..8].copy_from_slice(&rand_val.to_ne_bytes()); + } + let keys_manager = test_utils::TestKeysInterface::with_settings( + &seed, + Network::Testnet, // Use legacy (V1) remote_key derivation for tests using legacy key sets. - test_utils::TestKeysInterface::with_v1_remote_key_derivation(&seed, Network::Testnet) - } else { - test_utils::TestKeysInterface::new(&seed, Network::Testnet) - }; + predefined_keys_ids.is_some(), + phantom_seed, + ); let scorer = RwLock::new(test_utils::TestScorer::new()); // Set predefined keys_id if provided diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 1d20d1d368e..6a6a6fe71b5 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -74,15 +74,21 @@ const MAX_SHORT_LIVED_RELATIVE_EXPIRY: Duration = Duration::from_secs(60 * 60 * use crate::prelude::*; macro_rules! expect_recent_payment { - ($node: expr, $payment_state: path, $payment_id: expr) => { - match $node.node.list_recent_payments().first() { - Some(&$payment_state { payment_id: actual_payment_id, .. }) => { - assert_eq!($payment_id, actual_payment_id); - }, - Some(_) => panic!("Unexpected recent payment state"), - None => panic!("No recent payments"), + ($node: expr, $payment_state: path, $payment_id: expr) => {{ + let mut found_payment = false; + for payment in $node.node.list_recent_payments().iter() { + match payment { + $payment_state { payment_id: actual_payment_id, .. } => { + if $payment_id == *actual_payment_id { + found_payment = true; + break; + } + }, + _ => {}, + } } - } + assert!(found_payment); + }} } fn connect_peers<'a, 'b, 'c>(node_a: &Node<'a, 'b, 'c>, node_b: &Node<'a, 'b, 'c>) { @@ -2571,3 +2577,111 @@ fn no_double_pay_with_stale_channelmanager() { // generated in response to the duplicate invoice. assert!(nodes[0].node.get_and_clear_pending_events().is_empty()); } + +#[test] +fn creates_and_pays_for_phantom_offer() { + // XXX: share expanded key + let mut chanmon_cfgs = create_chanmon_cfgs(1); + chanmon_cfgs.append(&mut create_phantom_chanmon_cfgs(2)); + let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(3, &node_cfgs, &[None, None, None]); + let nodes = create_network(3, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + create_announced_chan_between_nodes_with_value(&nodes, 0, 2, 10_000_000, 1_000_000_000); + + let node_a_id = nodes[0].node.get_our_node_id(); + let node_b_id = nodes[1].node.get_our_node_id(); + let node_c_id = nodes[2].node.get_our_node_id(); + + let offer = nodes[1].node + .create_phantom_offer_builder(vec![(node_c_id, nodes[2].node.list_channels())], 2) + .unwrap() + .amount_msats(10_000_000) + .build().unwrap(); + + // The offer should be resolvable by either of node B or C but signed by a derived key + assert!(offer.issuer_signing_pubkey().is_some()); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_b_id)); + assert_ne!(offer.issuer_signing_pubkey(), Some(node_c_id)); + assert_eq!(offer.paths().len(), 2); + let mut b_path_count = 0; + let mut c_path_count = 0; + for path in offer.paths() { + if check_compact_path_introduction_node(&path, &nodes[0], node_b_id) { + b_path_count += 1; + } + if check_compact_path_introduction_node(&path, &nodes[0], node_c_id) { + c_path_count += 1; + } + } + assert_eq!(b_path_count, 1); + assert_eq!(c_path_count, 1); + + // First, pay via node B + let payment_id = PaymentId([1; 32]); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); + let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); + nodes[1].onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&nodes[1], &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = nodes[1].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(node_b_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[&nodes[1]], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[&nodes[1]], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + + // Then pay again via node C + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); + + let payment_id = PaymentId([2; 32]); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); + let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); + nodes[2].onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&nodes[2], &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = nodes[2].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(node_c_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[&nodes[2]], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[&nodes[2]], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); +} diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 8b03f0ea081..ccff89f9304 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -299,6 +299,39 @@ where self.create_blinded_paths(peers, context) } + fn blinded_paths_for_phantom_offer( + &self, per_node_peers: Vec<(PublicKey, Vec)>, path_count_limit: usize, + context: MessageContext, + ) -> Result, ()> { + let receive_key = ReceiveAuthKey(self.inbound_payment_key.phantom_node_blinded_path_key); + let secp_ctx = &self.secp_ctx; + + let mut per_node_paths: Vec<_> = per_node_peers + .into_iter() + .filter_map(|(recipient, peers)| { + self.message_router + .create_blinded_paths(recipient, receive_key, context.clone(), peers, secp_ctx) + .ok() + }) + .collect(); + + let mut res = Vec::new(); + while res.len() < path_count_limit && !per_node_paths.is_empty() { + for node_paths in per_node_paths.iter_mut() { + if let Some(path) = node_paths.pop() { + res.push(path); + } + } + per_node_paths.retain(|node_paths| !node_paths.is_empty()); + } + + if res.is_empty() { + Err(()) + } else { + Ok(res) + } + } + /// Creates a collection of blinded paths by delegating to /// [`MessageRouter::create_blinded_paths`]. /// @@ -583,8 +616,7 @@ where /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using - /// [`Self::verify_invoice_request`]. The offer will expire at `absolute_expiry` if `Some`, - /// or will not expire if `None`. + /// [`Self::verify_invoice_request`]. /// /// # Privacy /// @@ -668,6 +700,28 @@ where }) } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by any + /// [`OffersMessageFlow`] using the same [`ExpandedKey`] (provided in the constructor as + /// `inbound_payment_key`), and any corresponding [`InvoiceRequest`] can be verified using + /// [`Self::verify_invoice_request`]. + /// + /// See [`Self::create_offer_builder`] for more details on privacy and limitations. + /// + /// [`ExpandedKey`]: inbound_payment::ExpandedKey + pub fn create_phantom_offer_builder( + &self, entropy_source: ES, per_node_peers: Vec<(PublicKey, Vec)>, + path_count_limit: usize, + ) -> Result, Bolt12SemanticError> + where + ES::Target: EntropySource, + { + self.create_offer_builder_intern(&*entropy_source, |_, context, _| { + self.blinded_paths_for_phantom_offer(per_node_peers, path_count_limit, context) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }) + .map(|(builder, _)| builder) + } + fn create_refund_builder_intern( &self, entropy_source: ES, make_paths: PF, amount_msats: u64, absolute_expiry: Duration, payment_id: PaymentId, diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index a12b113b293..f9115e4bbcf 100644 --- a/lightning/src/util/test_utils.rs +++ b/lightning/src/util/test_utils.rs @@ -1954,6 +1954,7 @@ pub trait TestSignerFactory: Send + Sync { /// Make a dynamic signer fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box>; } @@ -1963,12 +1964,13 @@ struct DefaultSignerFactory(); impl TestSignerFactory for DefaultSignerFactory { fn make_signer( &self, seed: &[u8; 32], now: Duration, v2_remote_key_derivation: bool, + phantom_seed: Option<&[u8; 32]>, ) -> Box> { let phantom = sign::PhantomKeysManager::new( seed, now.as_secs(), now.subsec_nanos(), - seed, + if let Some(provided_seed) = phantom_seed { provided_seed } else { seed }, v2_remote_key_derivation, ); let dphantom = DynPhantomKeysInterface::new(phantom); @@ -2000,7 +2002,7 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, true); + let backing = factory.make_signer(seed, now, true, None); Self::build(backing) } @@ -2012,7 +2014,21 @@ impl TestKeysInterface { let factory = DefaultSignerFactory(); let now = Duration::from_secs(genesis_block(network).header.time as u64); - let backing = factory.make_signer(seed, now, false); + let backing = factory.make_signer(seed, now, false, None); + Self::build(backing) + } + + pub fn with_settings( + seed: &[u8; 32], network: Network, v1_derivation: bool, phantom_seed: Option<&[u8; 32]>, + ) -> Self { + #[cfg(feature = "std")] + let factory = SIGNER_FACTORY.get(); + + #[cfg(not(feature = "std"))] + let factory = DefaultSignerFactory(); + + let now = Duration::from_secs(genesis_block(network).header.time as u64); + let backing = factory.make_signer(seed, now, !v1_derivation, phantom_seed); Self::build(backing) } From 9c6c5349de163fd3ca3edc881c61b6df35aefc05 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:06:47 +0000 Subject: [PATCH 7/9] f fix offset --- lightning/src/ln/channelmanager.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 7eb5f46e373..332f856fd63 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -14096,6 +14096,7 @@ where let end = channel_list[start..] .iter() .position(|chan| chan.counterparty.node_id != counterparty_node_id) + .map(|pos| start + pos) .unwrap_or(channel_list.len()); let peer_chans = &channel_list[start..end]; From 5ef29ffa428ded2578922645b150bedd983e2044 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Mon, 26 Jan 2026 21:09:14 +0000 Subject: [PATCH 8/9] f cleanup test - make it a loop and remove old XXX --- lightning/src/ln/offers_tests.rs | 115 +++++++++++++------------------ 1 file changed, 48 insertions(+), 67 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 6a6a6fe71b5..2a8a238c562 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2580,7 +2580,7 @@ fn no_double_pay_with_stale_channelmanager() { #[test] fn creates_and_pays_for_phantom_offer() { - // XXX: share expanded key + // Tests that we can pay a "phantom offer" to any participating node. let mut chanmon_cfgs = create_chanmon_cfgs(1); chanmon_cfgs.append(&mut create_phantom_chanmon_cfgs(2)); let node_cfgs = create_node_cfgs(3, &chanmon_cfgs); @@ -2618,70 +2618,51 @@ fn creates_and_pays_for_phantom_offer() { assert_eq!(b_path_count, 1); assert_eq!(c_path_count, 1); - // First, pay via node B - let payment_id = PaymentId([1; 32]); - nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); - expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); - - let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); - let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); - nodes[1].onion_messenger.handle_onion_message(node_a_id, &onion_message); - - let (invoice_request, _) = extract_invoice_request(&nodes[1], &onion_message); - let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: offer.id(), - invoice_request: InvoiceRequestFields { - payer_signing_pubkey: invoice_request.payer_signing_pubkey(), - quantity: None, - payer_note_truncated: None, - human_readable_name: None, - }, - }); - - let onion_message = nodes[1].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); - nodes[0].onion_messenger.handle_onion_message(node_b_id, &onion_message); - - let (invoice, _) = extract_invoice(&nodes[0], &onion_message); - assert_eq!(invoice.amount_msats(), 10_000_000); - - route_bolt12_payment(&nodes[0], &[&nodes[1]], &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); - - claim_bolt12_payment(&nodes[0], &[&nodes[1]], payment_context, &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); - - // Then pay again via node C - assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); - assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); - - let payment_id = PaymentId([2; 32]); - nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); - expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); - - let onion_message = nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).unwrap(); - let _discard = nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).unwrap(); - nodes[2].onion_messenger.handle_onion_message(node_a_id, &onion_message); - - let (invoice_request, _) = extract_invoice_request(&nodes[2], &onion_message); - let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { - offer_id: offer.id(), - invoice_request: InvoiceRequestFields { - payer_signing_pubkey: invoice_request.payer_signing_pubkey(), - quantity: None, - payer_note_truncated: None, - human_readable_name: None, - }, - }); - - let onion_message = nodes[2].onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); - nodes[0].onion_messenger.handle_onion_message(node_c_id, &onion_message); - - let (invoice, _) = extract_invoice(&nodes[0], &onion_message); - assert_eq!(invoice.amount_msats(), 10_000_000); - - route_bolt12_payment(&nodes[0], &[&nodes[2]], &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); - - claim_bolt12_payment(&nodes[0], &[&nodes[2]], payment_context, &invoice); - expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + // Pay twice, first via node B (the node that actually built the offer) then pay via node C + // (which won't have seen the offer until it receives the invoice_request). + for (payment_id, recipient) in [([1; 32], &nodes[1]), ([2; 32], &nodes[2])] { + let payment_id = PaymentId(payment_id); + nodes[0].node.pay_for_offer(&offer, None, payment_id, Default::default()).unwrap(); + expect_recent_payment!(nodes[0], RecentPaymentDetails::AwaitingInvoice, payment_id); + + let recipient_id = recipient.node.get_our_node_id(); + let non_recipient_id = if node_b_id == recipient_id { + node_c_id + } else { + node_b_id + }; + + let onion_message = + nodes[0].onion_messenger.next_onion_message_for_peer(recipient_id).unwrap(); + let _discard = + nodes[0].onion_messenger.next_onion_message_for_peer(non_recipient_id).unwrap(); + recipient.onion_messenger.handle_onion_message(node_a_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(&recipient, &onion_message); + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: offer.id(), + invoice_request: InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + }, + }); + + let onion_message = + recipient.onion_messenger.next_onion_message_for_peer(node_a_id).unwrap(); + nodes[0].onion_messenger.handle_onion_message(recipient_id, &onion_message); + + let (invoice, _) = extract_invoice(&nodes[0], &onion_message); + assert_eq!(invoice.amount_msats(), 10_000_000); + + route_bolt12_payment(&nodes[0], &[recipient], &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(&nodes[0], &[recipient], payment_context, &invoice); + expect_recent_payment!(&nodes[0], RecentPaymentDetails::Fulfilled, payment_id); + + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_b_id).is_none()); + assert!(nodes[0].onion_messenger.next_onion_message_for_peer(node_c_id).is_none()); + } } From 6c4d335698d5deb356221461f335ede70baa6f74 Mon Sep 17 00:00:00 2001 From: Matt Corallo Date: Tue, 27 Jan 2026 20:17:14 +0000 Subject: [PATCH 9/9] f add docs on channel_details_to_forward_nodes --- lightning/src/ln/channelmanager.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 332f856fd63..abc7981ccf4 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -13446,7 +13446,7 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { let mut peers = Vec::with_capacity(other_nodes_channels.len() + 1); peers.push(($self.get_our_node_id(), $self.get_peers_for_blinded_path())); for (node_id, peer_chans) in other_nodes_channels { - peers.push((node_id, Self::channel_details_to_forward_node(peer_chans))); + peers.push((node_id, Self::channel_details_to_forward_nodes(peer_chans))); } let builder = $self.flow.create_phantom_offer_builder( @@ -14084,7 +14084,9 @@ where now } - fn channel_details_to_forward_node( + /// Converts a list of channels to a list of peers which may be suitable to receive onion + /// messages through. + fn channel_details_to_forward_nodes( mut channel_list: Vec, ) -> Vec { channel_list.sort_unstable_by_key(|chan| chan.counterparty.node_id);