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 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( diff --git a/lightning/src/blinded_path/payment.rs b/lightning/src/blinded_path/payment.rs index b68be811cb4..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::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; use crate::io; use crate::io::Cursor; use crate::ln::channel_state::CounterpartyForwardingInfo; @@ -284,18 +284,20 @@ 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(|_| ())?; - - match (&readable, used_aad) { - (BlindedPaymentTlvs::Forward(_), false) - | (BlindedPaymentTlvs::Dummy(_), true) - | (BlindedPaymentTlvs::Receive(_), true) => Ok((readable, control_tlvs_ss)), + let ChaChaTriPolyReadAdapter { readable, used_aad } = + ChaChaTriPolyReadAdapter::read(&mut reader, read_arg).map_err(|_| ())?; + + 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 c406e933bc9..37c7a681037 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 { @@ -84,34 +84,48 @@ 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. /// -/// 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: TriPolyAADUsed, } -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 +139,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 +156,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 +176,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: TriPolyAADUsed::NoAAD }) + } else if fixed_time_eq(&mac_aad_a.result(), &tag) { + Ok(Self { readable, used_aad: TriPolyAADUsed::A }) + } else if fixed_time_eq(&mac_aad_b.result(), &tag) { + Ok(Self { readable, used_aad: TriPolyAADUsed::B }) } 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 +369,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/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/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/channelmanager.rs b/lightning/src/ln/channelmanager.rs index af395154760..abc7981ccf4 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_nodes(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,41 @@ where now } + /// 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); + 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) + .map(|pos| start + pos) + .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/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, } } diff --git a/lightning/src/ln/msgs.rs b/lightning/src/ln/msgs.rs index 2bb2b244ccb..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::ChaChaDualPolyReadAdapter; +use crate::crypto::streams::{ChaChaTriPolyReadAdapter, TriPolyAADUsed}; 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, @@ -3729,7 +3732,7 @@ where || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3742,7 +3745,7 @@ where next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Dummy(DummyTlvs { payment_relay, payment_constraints }), used_aad, @@ -3751,7 +3754,7 @@ where || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || !used_aad + || used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3761,11 +3764,11 @@ where intro_node_blinding_point, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedPaymentTlvs::Receive(receive_tlvs), used_aad, } => { - if !used_aad { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3831,6 +3834,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 +3888,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, @@ -3900,7 +3905,7 @@ where || cltv_value.is_some() || total_msat.is_some() || keysend_preimage.is_some() || invoice_request.is_some() - || used_aad + || used_aad != TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } @@ -3913,11 +3918,11 @@ where next_blinding_override, })) }, - ChaChaDualPolyReadAdapter { + ChaChaTriPolyReadAdapter { readable: BlindedTrampolineTlvs::Receive(receive_tlvs), used_aad, } => { - if !used_aad { + if used_aad == TriPolyAADUsed::NoAAD { return Err(DecodeError::InvalidValue); } diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 1d20d1d368e..2a8a238c562 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,92 @@ 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() { + // 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); + 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); + + // 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()); + } +} 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/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..86cb47d30b0 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, TriPolyAADUsed}; +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,31 @@ 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, + }) => { + if used_aad != TriPolyAADUsed::NoAAD || 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, + }) => Ok(Payload::Dummy { + control_tlvs_authenticated: used_aad != TriPolyAADUsed::NoAAD, + }), + Some(ChaChaTriPolyReadAdapter { + readable: ControlTlvs::Receive(tlvs), + 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 == TriPolyAADUsed::A, + control_tlvs_from_phantom_participant: used_aad == TriPolyAADUsed::B, + }), } } } diff --git a/lightning/src/util/test_utils.rs b/lightning/src/util/test_utils.rs index 34f5d5fe36e..f9115e4bbcf 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 { @@ -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) }