From 6b5df57a0eacd927913c663853463e154a1d425b Mon Sep 17 00:00:00 2001 From: Arik Sosman Date: Sat, 8 Mar 2025 21:07:57 -0800 Subject: [PATCH 1/4] Make `process_onion_failure` iterate over Vec In an upcoming commit, we will need to decrypt error onions constructed from multiple session_privs. In order to simplify the code legibility, we move from a single-iteration model to one where we first aggregate the shared secrets, and then use them for the error decryption. --- lightning/src/ln/onion_utils.rs | 71 ++++++++++++++++----------------- 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 41701b1ade3..ea84e64dcf2 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -965,8 +965,8 @@ where } let mut res: Option = None; let mut htlc_msat = *first_hop_htlc_msat; - let mut error_code_ret = None; - let mut error_packet_ret = None; + let mut _error_code_ret = None; + let mut _error_packet_ret = None; let mut is_from_final_node = false; const BADONION: u16 = 0x8000; @@ -974,35 +974,39 @@ where const NODE: u16 = 0x2000; const UPDATE: u16 = 0x1000; - // Handle packed channel/node updates for passing back for the route handler - let callback = |shared_secret: SharedSecret, - _, - _, - route_hop_opt: Option<&RouteHop>, - route_hop_idx| { - if res.is_some() { - return; - } + let num_blinded_hops = path.blinded_tail.as_ref().map_or(0, |bt| bt.hops.len()); + let mut onion_keys = Vec::with_capacity(path.hops.len() + num_blinded_hops); + construct_onion_keys_generic_callback( + secp_ctx, + &path.hops, + path.blinded_tail.as_ref(), + session_priv, + |shared_secret, _, _, route_hop_option: Option<&RouteHop>, _| { + onion_keys.push((route_hop_option.cloned(), shared_secret)) + }, + ) + .expect("Route we used spontaneously grew invalid keys in the middle of it?"); - let route_hop = match route_hop_opt { + // Handle packed channel/node updates for passing back for the route handler + for (route_hop_idx, (route_hop_option, shared_secret)) in onion_keys.into_iter().enumerate() { + let route_hop = match route_hop_option.as_ref() { Some(hop) => hop, None => { // Got an error from within a blinded route. - error_code_ret = Some(BADONION | PERM | 24); // invalid_onion_blinding - error_packet_ret = Some(vec![0; 32]); + _error_code_ret = Some(BADONION | PERM | 24); // invalid_onion_blinding + _error_packet_ret = Some(vec![0; 32]); res = Some(FailureLearnings { network_update: None, short_channel_id: None, payment_failed_permanently: false, failed_within_blinded_path: true, }); - return; + break; }, }; // The failing hop includes either the inbound channel to the recipient or the outbound channel // from the current hop (i.e., the next hop's inbound channel). - let num_blinded_hops = path.blinded_tail.as_ref().map_or(0, |bt| bt.hops.len()); // For 1-hop blinded paths, the final `path.hops` entry is the recipient. is_from_final_node = route_hop_idx + 1 == path.hops.len() && num_blinded_hops <= 1; let failing_route_hop = if is_from_final_node { @@ -1014,8 +1018,8 @@ where // The failing hop is within a multi-hop blinded path. #[cfg(not(test))] { - error_code_ret = Some(BADONION | PERM | 24); // invalid_onion_blinding - error_packet_ret = Some(vec![0; 32]); + _error_code_ret = Some(BADONION | PERM | 24); // invalid_onion_blinding + _error_packet_ret = Some(vec![0; 32]); } #[cfg(test)] { @@ -1026,10 +1030,10 @@ where &encrypted_packet.data, )) .unwrap(); - error_code_ret = Some(u16::from_be_bytes( + _error_code_ret = Some(u16::from_be_bytes( err_packet.failuremsg.get(0..2).unwrap().try_into().unwrap(), )); - error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); + _error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); } res = Some(FailureLearnings { @@ -1038,7 +1042,7 @@ where payment_failed_permanently: false, failed_within_blinded_path: true, }); - return; + break; }, } }; @@ -1053,7 +1057,7 @@ where hmac.input(&encrypted_packet.data[32..]); if !fixed_time_eq(&Hmac::from_engine(hmac).to_byte_array(), &encrypted_packet.data[..32]) { - return; + continue; } let err_packet = @@ -1073,7 +1077,7 @@ where payment_failed_permanently: is_from_final_node, failed_within_blinded_path: false, }); - return; + break; }, }; @@ -1095,13 +1099,13 @@ where payment_failed_permanently: is_from_final_node, failed_within_blinded_path: false, }); - return; + break; }, }; let error_code = u16::from_be_bytes(error_code_slice.try_into().expect("len is 2")); - error_code_ret = Some(error_code); - error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); + _error_code_ret = Some(error_code); + _error_packet_ret = Some(err_packet.failuremsg[2..].to_vec()); let (debug_field, debug_field_size) = errors::get_onion_debug_field(error_code); @@ -1212,16 +1216,9 @@ where description ); } - }; - construct_onion_keys_generic_callback( - secp_ctx, - &path.hops, - path.blinded_tail.as_ref(), - session_priv, - callback, - ) - .expect("Route we used spontaneously grew invalid keys in the middle of it?"); + break; + } if let Some(FailureLearnings { network_update, @@ -1236,9 +1233,9 @@ where payment_failed_permanently, failed_within_blinded_path, #[cfg(any(test, feature = "_test_utils"))] - onion_error_code: error_code_ret, + onion_error_code: _error_code_ret, #[cfg(any(test, feature = "_test_utils"))] - onion_error_data: error_packet_ret, + onion_error_data: _error_packet_ret, } } else { // only not set either packet unparseable or hmac does not match with any From 3e148961056f4f8f5bd14aef3f33ae8316e5d84f Mon Sep 17 00:00:00 2001 From: Arik Sosman Date: Mon, 10 Mar 2025 21:56:06 -0700 Subject: [PATCH 2/4] Switch error hop iteration to Peekable We currently check whether our hop is the last in the path by accessing the hops vector by the next index. However, once we start handling Trampoline hops that will become inadequate. Instead, we switch it to check whether there is a subsequent element in the iterator. --- lightning/src/ln/onion_utils.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index ea84e64dcf2..51eec523004 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -988,7 +988,8 @@ where .expect("Route we used spontaneously grew invalid keys in the middle of it?"); // Handle packed channel/node updates for passing back for the route handler - for (route_hop_idx, (route_hop_option, shared_secret)) in onion_keys.into_iter().enumerate() { + let mut iterator = onion_keys.into_iter().peekable(); + while let Some((route_hop_option, shared_secret)) = iterator.next() { let route_hop = match route_hop_option.as_ref() { Some(hop) => hop, None => { @@ -1008,13 +1009,16 @@ where // The failing hop includes either the inbound channel to the recipient or the outbound channel // from the current hop (i.e., the next hop's inbound channel). // For 1-hop blinded paths, the final `path.hops` entry is the recipient. - is_from_final_node = route_hop_idx + 1 == path.hops.len() && num_blinded_hops <= 1; + // In our case that means that if we're on the last iteration, and there is no more than one + // blinded hop, the current iteration references the last non-blinded hop. + let next_hop = iterator.peek(); + is_from_final_node = next_hop.is_none() && num_blinded_hops <= 1; let failing_route_hop = if is_from_final_node { route_hop } else { - match path.hops.get(route_hop_idx + 1) { - Some(hop) => hop, - None => { + match next_hop { + Some((Some(hop), _)) => hop, + _ => { // The failing hop is within a multi-hop blinded path. #[cfg(not(test))] { From 7ded4c36ff77af8504ba790db34c67c2b8fb6ab8 Mon Sep 17 00:00:00 2001 From: Arik Sosman Date: Mon, 10 Mar 2025 21:59:58 -0700 Subject: [PATCH 3/4] Introduce ErrorHop enum When we start handling Trampoline, the hops in our error decryption path could be either `RouteHop`s or `TrampolineHop`s. To avoid excessive code duplication, we introduce an enum with some methods for common accessors. --- lightning/src/ln/onion_utils.rs | 102 +++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 35 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index 51eec523004..da0db877741 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -297,14 +297,14 @@ impl<'a, 'b> OnionPayload<'a, 'b> for msgs::OutboundTrampolinePayload<'a> { } #[inline] -fn construct_onion_keys_generic_callback( - secp_ctx: &Secp256k1, hops: &[H], blinded_tail: Option<&BlindedTail>, +fn construct_onion_keys_generic_callback<'a, T, H, FType>( + secp_ctx: &Secp256k1, hops: &'a [H], blinded_tail: Option<&BlindedTail>, session_priv: &SecretKey, mut callback: FType, ) -> Result<(), secp256k1::Error> where T: secp256k1::Signing, H: HopInfo, - FType: FnMut(SharedSecret, [u8; 32], PublicKey, Option<&H>, usize), + FType: FnMut(SharedSecret, [u8; 32], PublicKey, Option<&'a H>, usize), { let mut blinded_priv = session_priv.clone(); let mut blinded_pub = PublicKey::from_secret_key(secp_ctx, &blinded_priv); @@ -974,6 +974,30 @@ where const NODE: u16 = 0x2000; const UPDATE: u16 = 0x1000; + enum ErrorHop<'a> { + RouteHop(&'a RouteHop), + } + + impl<'a> ErrorHop<'a> { + fn fee_msat(&self) -> u64 { + match self { + ErrorHop::RouteHop(rh) => rh.fee_msat, + } + } + + fn pubkey(&self) -> &PublicKey { + match self { + ErrorHop::RouteHop(rh) => rh.node_pubkey(), + } + } + + fn short_channel_id(&self) -> Option { + match self { + ErrorHop::RouteHop(rh) => Some(rh.short_channel_id), + } + } + } + let num_blinded_hops = path.blinded_tail.as_ref().map_or(0, |bt| bt.hops.len()); let mut onion_keys = Vec::with_capacity(path.hops.len() + num_blinded_hops); construct_onion_keys_generic_callback( @@ -982,7 +1006,7 @@ where path.blinded_tail.as_ref(), session_priv, |shared_secret, _, _, route_hop_option: Option<&RouteHop>, _| { - onion_keys.push((route_hop_option.cloned(), shared_secret)) + onion_keys.push((route_hop_option.map(|rh| ErrorHop::RouteHop(rh)), shared_secret)) }, ) .expect("Route we used spontaneously grew invalid keys in the middle of it?"); @@ -1051,7 +1075,7 @@ where } }; - let amt_to_forward = htlc_msat - route_hop.fee_msat; + let amt_to_forward = htlc_msat - route_hop.fee_msat(); htlc_msat = amt_to_forward; crypt_failure_packet(shared_secret.as_ref(), &mut encrypted_packet); @@ -1068,13 +1092,13 @@ where match msgs::DecodedOnionErrorPacket::read(&mut Cursor::new(&encrypted_packet.data)) { Ok(p) => p, Err(_) => { - log_warn!(logger, "Unreadable failure from {}", route_hop.pubkey); + log_warn!(logger, "Unreadable failure from {}", route_hop.pubkey()); let network_update = Some(NetworkUpdate::NodeFailure { - node_id: route_hop.pubkey, + node_id: *route_hop.pubkey(), is_permanent: true, }); - let short_channel_id = Some(route_hop.short_channel_id); + let short_channel_id = route_hop.short_channel_id(); res = Some(FailureLearnings { network_update, short_channel_id, @@ -1090,13 +1114,13 @@ where None => { // Useless packet that we can't use but it passed HMAC, so it definitely came from the peer // in question - log_warn!(logger, "Missing error code in failure from {}", route_hop.pubkey); + log_warn!(logger, "Missing error code in failure from {}", route_hop.pubkey()); let network_update = Some(NetworkUpdate::NodeFailure { - node_id: route_hop.pubkey, + node_id: *route_hop.pubkey(), is_permanent: true, }); - let short_channel_id = Some(route_hop.short_channel_id); + let short_channel_id = route_hop.short_channel_id(); res = Some(FailureLearnings { network_update, short_channel_id, @@ -1130,22 +1154,26 @@ where // entirely, but we can't be confident in that, as it would allow any node to get us to // completely ban one of its counterparties. Instead, we simply remove the channel in // question. - network_update = Some(NetworkUpdate::ChannelFailure { - short_channel_id: failing_route_hop.short_channel_id, - is_permanent: true, - }); + if let ErrorHop::RouteHop(failing_route_hop) = failing_route_hop { + network_update = Some(NetworkUpdate::ChannelFailure { + short_channel_id: failing_route_hop.short_channel_id, + is_permanent: true, + }); + } } else if error_code & NODE == NODE { let is_permanent = error_code & PERM == PERM; network_update = - Some(NetworkUpdate::NodeFailure { node_id: route_hop.pubkey, is_permanent }); - short_channel_id = Some(route_hop.short_channel_id); + Some(NetworkUpdate::NodeFailure { node_id: *route_hop.pubkey(), is_permanent }); + short_channel_id = route_hop.short_channel_id(); } else if error_code & PERM == PERM { if !payment_failed { - network_update = Some(NetworkUpdate::ChannelFailure { - short_channel_id: failing_route_hop.short_channel_id, - is_permanent: true, - }); - short_channel_id = Some(failing_route_hop.short_channel_id); + if let ErrorHop::RouteHop(failing_route_hop) = failing_route_hop { + network_update = Some(NetworkUpdate::ChannelFailure { + short_channel_id: failing_route_hop.short_channel_id, + is_permanent: true, + }); + } + short_channel_id = failing_route_hop.short_channel_id(); } } else if error_code & UPDATE == UPDATE { if let Some(update_len_slice) = @@ -1158,37 +1186,41 @@ where .get(debug_field_size + 4..debug_field_size + 4 + update_len) .is_some() { - network_update = Some(NetworkUpdate::ChannelFailure { - short_channel_id: failing_route_hop.short_channel_id, - is_permanent: false, - }); - short_channel_id = Some(failing_route_hop.short_channel_id); + if let ErrorHop::RouteHop(failing_route_hop) = failing_route_hop { + network_update = Some(NetworkUpdate::ChannelFailure { + short_channel_id: failing_route_hop.short_channel_id, + is_permanent: false, + }); + } + short_channel_id = failing_route_hop.short_channel_id(); } } if network_update.is_none() { // They provided an UPDATE which was obviously bogus, not worth // trying to relay through them anymore. network_update = Some(NetworkUpdate::NodeFailure { - node_id: route_hop.pubkey, + node_id: *route_hop.pubkey(), is_permanent: true, }); } if short_channel_id.is_none() { - short_channel_id = Some(route_hop.short_channel_id); + short_channel_id = route_hop.short_channel_id(); } } else if payment_failed { // Only blame the hop when a value in the HTLC doesn't match the corresponding value in the // onion. short_channel_id = match error_code & 0xff { - 18 | 19 => Some(route_hop.short_channel_id), + 18 | 19 => route_hop.short_channel_id(), _ => None, }; } else { // We can't understand their error messages and they failed to forward...they probably can't // understand our forwards so it's really not worth trying any further. - network_update = - Some(NetworkUpdate::NodeFailure { node_id: route_hop.pubkey, is_permanent: true }); - short_channel_id = Some(route_hop.short_channel_id); + network_update = Some(NetworkUpdate::NodeFailure { + node_id: *route_hop.pubkey(), + is_permanent: true, + }); + short_channel_id = route_hop.short_channel_id() } res = Some(FailureLearnings { @@ -1203,7 +1235,7 @@ where log_info!( logger, "Onion Error[from {}: {}({:#x}) {}({})] {}", - route_hop.pubkey, + route_hop.pubkey(), title, error_code, debug_field, @@ -1214,7 +1246,7 @@ where log_info!( logger, "Onion Error[from {}: {}({:#x})] {}", - route_hop.pubkey, + route_hop.pubkey(), title, error_code, description From 6d63c24721d3cdceb67c8b6e5b1b2f721b5436a8 Mon Sep 17 00:00:00 2001 From: Arik Sosman Date: Mon, 10 Mar 2025 22:03:14 -0700 Subject: [PATCH 4/4] Handle Trampoline hops in error decryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than solely iterating over `RouteHop`s, we now also append the shared secrets from the inner onion containing `TrampolineHop`s. Additionally, we allow the `outer_session_priv` to be overridden to accommodate the test vector requirements. We do so by separating out a façade method without the override. --- lightning/src/ln/onion_utils.rs | 359 +++++++++++++++++++++++++++++--- lightning/src/routing/router.rs | 5 + 2 files changed, 339 insertions(+), 25 deletions(-) diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index da0db877741..68d7e4d7d9d 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -937,23 +937,58 @@ pub(crate) struct DecodedOnionFailure { pub(crate) onion_error_data: Option>, } +pub(super) fn process_onion_failure( + secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, + encrypted_packet: OnionErrorPacket, +) -> DecodedOnionFailure +where + L::Target: Logger, +{ + let (path, primary_session_priv) = match htlc_source { + HTLCSource::OutboundRoute { ref path, ref session_priv, .. } => (path, session_priv), + _ => unreachable!(), + }; + + if path.has_trampoline_hops() { + // If we have Trampoline hops, the outer onion session_priv is a hash of the inner one. + let session_priv_hash = Sha256::hash(&primary_session_priv.secret_bytes()).to_byte_array(); + let outer_session_priv = + SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!"); + process_onion_failure_inner( + secp_ctx, + logger, + htlc_source, + &outer_session_priv, + Some(primary_session_priv), + encrypted_packet, + ) + } else { + process_onion_failure_inner( + secp_ctx, + logger, + htlc_source, + primary_session_priv, + None, + encrypted_packet, + ) + } +} + /// Process failure we got back from upstream on a payment we sent (implying htlc_source is an /// OutboundRoute). #[inline] -pub(super) fn process_onion_failure( - secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, - mut encrypted_packet: OnionErrorPacket, +pub(super) fn process_onion_failure_inner( + secp_ctx: &Secp256k1, logger: &L, htlc_source: &HTLCSource, outer_session_priv: &SecretKey, + inner_session_priv: Option<&SecretKey>, mut encrypted_packet: OnionErrorPacket, ) -> DecodedOnionFailure where L::Target: Logger, { - let (path, session_priv, first_hop_htlc_msat) = match htlc_source { - HTLCSource::OutboundRoute { - ref path, ref session_priv, ref first_hop_htlc_msat, .. - } => (path, session_priv, first_hop_htlc_msat), - _ => { - unreachable!() + let (path, first_hop_htlc_msat) = match htlc_source { + HTLCSource::OutboundRoute { ref path, ref first_hop_htlc_msat, .. } => { + (path, first_hop_htlc_msat) }, + _ => unreachable!(), }; // Learnings from the HTLC failure to inform future payment retries and scoring. @@ -967,7 +1002,7 @@ where let mut htlc_msat = *first_hop_htlc_msat; let mut _error_code_ret = None; let mut _error_packet_ret = None; - let mut is_from_final_node = false; + let mut is_from_final_non_blinded_node = false; const BADONION: u16 = 0x8000; const PERM: u16 = 0x4000; @@ -976,41 +1011,69 @@ where enum ErrorHop<'a> { RouteHop(&'a RouteHop), + TrampolineHop(&'a TrampolineHop), } impl<'a> ErrorHop<'a> { fn fee_msat(&self) -> u64 { match self { ErrorHop::RouteHop(rh) => rh.fee_msat, + ErrorHop::TrampolineHop(th) => th.fee_msat, } } fn pubkey(&self) -> &PublicKey { match self { ErrorHop::RouteHop(rh) => rh.node_pubkey(), + ErrorHop::TrampolineHop(th) => th.node_pubkey(), } } fn short_channel_id(&self) -> Option { match self { ErrorHop::RouteHop(rh) => Some(rh.short_channel_id), + ErrorHop::TrampolineHop(_) => None, } } } - let num_blinded_hops = path.blinded_tail.as_ref().map_or(0, |bt| bt.hops.len()); - let mut onion_keys = Vec::with_capacity(path.hops.len() + num_blinded_hops); + let (num_blinded_hops, num_trampoline_hops) = + path.blinded_tail.as_ref().map_or((0, 0), |bt| (bt.hops.len(), bt.trampoline_hops.len())); + + // We are first collecting all the unblinded `RouteHop`s inside `onion_keys`. Then, if applicable, + // we will add all the `TrampolineHop`s, and finally, the blinded hops. + let mut onion_keys = + Vec::with_capacity(path.hops.len() + num_trampoline_hops + num_blinded_hops); + construct_onion_keys_generic_callback( secp_ctx, &path.hops, - path.blinded_tail.as_ref(), - session_priv, + // if we have Trampoline hops, the blinded hops are part of the inner Trampoline onion + if path.has_trampoline_hops() { None } else { path.blinded_tail.as_ref() }, + outer_session_priv, |shared_secret, _, _, route_hop_option: Option<&RouteHop>, _| { onion_keys.push((route_hop_option.map(|rh| ErrorHop::RouteHop(rh)), shared_secret)) }, ) .expect("Route we used spontaneously grew invalid keys in the middle of it?"); + if path.has_trampoline_hops() { + construct_onion_keys_generic_callback( + secp_ctx, + // Trampoline hops are part of the blinded tail, so this can never panic + &path.blinded_tail.as_ref().unwrap().trampoline_hops, + path.blinded_tail.as_ref(), + inner_session_priv.expect("Trampoline hops always have an inner session priv"), + |shared_secret, _, _, trampoline_hop_option: Option<&TrampolineHop>, _| { + onion_keys.push(( + trampoline_hop_option.map(|th| ErrorHop::TrampolineHop(th)), + shared_secret, + )) + }, + ) + .expect("Route we used spontaneously grew invalid keys in the middle of it?"); + } + // Handle packed channel/node updates for passing back for the route handler let mut iterator = onion_keys.into_iter().peekable(); while let Some((route_hop_option, shared_secret)) = iterator.next() { @@ -1032,12 +1095,12 @@ where // The failing hop includes either the inbound channel to the recipient or the outbound channel // from the current hop (i.e., the next hop's inbound channel). - // For 1-hop blinded paths, the final `path.hops` entry is the recipient. + // For 1-hop blinded paths, the final `ErrorHop` entry is the recipient. // In our case that means that if we're on the last iteration, and there is no more than one // blinded hop, the current iteration references the last non-blinded hop. let next_hop = iterator.peek(); - is_from_final_node = next_hop.is_none() && num_blinded_hops <= 1; - let failing_route_hop = if is_from_final_node { + is_from_final_non_blinded_node = next_hop.is_none() && num_blinded_hops <= 1; + let failing_route_hop = if is_from_final_non_blinded_node { route_hop } else { match next_hop { @@ -1102,7 +1165,7 @@ where res = Some(FailureLearnings { network_update, short_channel_id, - payment_failed_permanently: is_from_final_node, + payment_failed_permanently: is_from_final_non_blinded_node, failed_within_blinded_path: false, }); break; @@ -1124,7 +1187,7 @@ where res = Some(FailureLearnings { network_update, short_channel_id, - payment_failed_permanently: is_from_final_node, + payment_failed_permanently: is_from_final_non_blinded_node, failed_within_blinded_path: false, }); break; @@ -1141,7 +1204,7 @@ where let payment_failed = match error_code & 0xff { 15 | 16 | 17 | 18 | 19 | 23 => true, _ => false, - } && is_from_final_node; // PERM bit observed below even if this error is from the intermediate nodes + } && is_from_final_non_blinded_node; // PERM bit observed below even if this error is from the intermediate nodes let mut network_update = None; let mut short_channel_id = None; @@ -1226,7 +1289,7 @@ where res = Some(FailureLearnings { network_update, short_channel_id, - payment_failed_permanently: error_code & PERM == PERM && is_from_final_node, + payment_failed_permanently: error_code & PERM == PERM && is_from_final_non_blinded_node, failed_within_blinded_path: false, }); @@ -1285,7 +1348,7 @@ where DecodedOnionFailure { network_update: None, short_channel_id: None, - payment_failed_permanently: is_from_final_node, + payment_failed_permanently: is_from_final_non_blinded_node, failed_within_blinded_path: false, #[cfg(any(test, feature = "_test_utils"))] onion_error_code: None, @@ -2008,11 +2071,11 @@ mod tests { use crate::prelude::*; use crate::util::test_utils::TestLogger; + use super::*; use bitcoin::hex::FromHex; use bitcoin::secp256k1::Secp256k1; use bitcoin::secp256k1::{PublicKey, SecretKey}; - - use super::*; + use types::features::Features; fn get_test_session_key() -> SecretKey { let hex = "4141414141414141414141414141414141414141414141414141414141414141"; @@ -2369,10 +2432,256 @@ mod tests { // Assert that the original failure can be retrieved and that all hmacs check out. let decrypted_failure = process_onion_failure(&ctx_full, &logger, &htlc_source, onion_error); - assert_eq!(decrypted_failure.onion_error_code, Some(0x2002)); } + fn build_trampoline_test_path() -> Path { + Path { + hops: vec![ + // Bob + RouteHop { + pubkey: PublicKey::from_slice(&>::from_hex("0324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c").unwrap()).unwrap(), + node_features: NodeFeatures::empty(), + short_channel_id: 0, + channel_features: ChannelFeatures::empty(), + fee_msat: 3_000, + cltv_expiry_delta: 24, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: PublicKey::from_slice(&>::from_hex("027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007").unwrap()).unwrap(), + node_features: NodeFeatures::empty(), + short_channel_id: (572330 << 40) + (42 << 16) + 2821, + channel_features: ChannelFeatures::empty(), + fee_msat: 153_000, + cltv_expiry_delta: 0, + maybe_announced_channel: false, + }, + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol's pubkey + TrampolineHop { + pubkey: PublicKey::from_slice(&>::from_hex("027f31ebc5462c1fdce1b737ecff52d37d75dea43ce11c74d25aa297165faa2007").unwrap()).unwrap(), + node_features: Features::empty(), + fee_msat: 2_500, + cltv_expiry_delta: 24, + }, + + // Dave's pubkey + TrampolineHop { + pubkey: PublicKey::from_slice(&>::from_hex("02edabbd16b41c8371b92ef2f04c1185b4f03b6dcd52ba9b78d9d7c89c8f221145").unwrap()).unwrap(), + node_features: Features::empty(), + fee_msat: 2_500, + cltv_expiry_delta: 24, + }, + + // Emily's pubkey + TrampolineHop { + pubkey: PublicKey::from_slice(&>::from_hex("032c0b7cf95324a07d05398b240174dc0c2be444d96b159aa6c7f7b1e668680991").unwrap()).unwrap(), + node_features: Features::empty(), + fee_msat: 150_500, + cltv_expiry_delta: 36, + }, + ], + + // Dummy blinded hop (because LDK doesn't allow unblinded Trampoline receives) + hops: vec![ + // Emily's dummy blinded node id + BlindedHop { + blinded_node_id: PublicKey::from_slice(&>::from_hex("0295d40514096a8be54859e7dfe947b376eaafea8afe5cb4eb2c13ff857ed0b4be").unwrap()).unwrap(), + encrypted_payload: vec![], + } + ], + blinding_point: PublicKey::from_slice(&>::from_hex("02988face71e92c345a068f740191fd8e53be14f0bb957ef730d3c5f76087b960e").unwrap()).unwrap(), + excess_final_cltv_expiry_delta: 0, + final_value_msat: 150_000_000, + }), + } + } + + #[test] + fn test_trampoline_onion_error_cryptography() { + // TODO(arik): check intermediate hops' perspectives once we have implemented forwarding + + let secp_ctx = Secp256k1::new(); + let logger: Arc = Arc::new(TestLogger::new()); + let dummy_amt_msat = 150_000_000; + + { + // test vector per https://github.com/lightning/bolts/blob/079f761bf68caa48544bd6bf0a29591d43425b0b/bolt04/trampoline-onion-error-test.json + // all dummy values + let trampoline_session_priv = SecretKey::from_slice(&[3; 32]).unwrap(); + let outer_session_priv = SecretKey::from_slice(&[4; 32]).unwrap(); + + let htlc_source = HTLCSource::OutboundRoute { + path: build_trampoline_test_path(), + session_priv: trampoline_session_priv, + first_hop_htlc_msat: dummy_amt_msat, + payment_id: PaymentId([1; 32]), + }; + + let error_packet_hex = "f8941a320b8fde4ad7b9b920c69cbf334114737497d93059d77e591eaa78d6334d3e2aeefcb0cc83402eaaf91d07d695cd895d9cad1018abdaf7d2a49d7657b1612729db7f393f0bb62b25afaaaa326d72a9214666025385033f2ec4605dcf1507467b5726d806da180ea224a7d8631cd31b0bdd08eead8bfe14fc8c7475e17768b1321b54dd4294aecc96da391efe0ca5bd267a45ee085c85a60cf9a9ac152fa4795fff8700a3ea4f848817f5e6943e855ab2e86f6929c9e885d8b20c49b14d2512c59ed21f10bd38691110b0d82c00d9fa48a20f10c7550358724c6e8e2b966e56a0aadf458695b273768062fa7c6e60eb72d4cdc67bf525c194e4a17fdcaa0e9d80480b586bf113f14eea530b6728a1c53fe5cee092e24a90f21f4b764015e7ed5e23"; + let error_packet = + OnionErrorPacket { data: >::from_hex(error_packet_hex).unwrap() }; + let decrypted_failure = process_onion_failure_inner( + &secp_ctx, + &logger, + &htlc_source, + &outer_session_priv, + Some(&trampoline_session_priv), + error_packet, + ); + assert_eq!(decrypted_failure.onion_error_code, Some(0x400f)); + } + + { + // shared secret cryptography sanity tests + let session_priv = get_test_session_key(); + let path = build_trampoline_test_path(); + + let trampoline_onion_keys = construct_trampoline_onion_keys( + &secp_ctx, + &path.blinded_tail.as_ref().unwrap(), + &session_priv, + ) + .unwrap(); + + let outer_onion_keys = { + let session_priv_hash = Sha256::hash(&session_priv.secret_bytes()).to_byte_array(); + let outer_session_priv = SecretKey::from_slice(&session_priv_hash[..]).unwrap(); + construct_onion_keys(&Secp256k1::new(), &path, &outer_session_priv).unwrap() + }; + + let htlc_source = HTLCSource::OutboundRoute { + path, + session_priv, + first_hop_htlc_msat: dummy_amt_msat, + payment_id: PaymentId([1; 32]), + }; + + { + // Ensure error decryption works without the Trampoline hops having been hit. + let error_code = 0x2002; + let mut first_hop_error_packet = build_unencrypted_failure_packet( + outer_onion_keys[0].shared_secret.as_ref(), + error_code, + &[0; 0], + ); + + crypt_failure_packet( + outer_onion_keys[0].shared_secret.as_ref(), + &mut first_hop_error_packet, + ); + + let decrypted_failure = + process_onion_failure(&secp_ctx, &logger, &htlc_source, first_hop_error_packet); + assert_eq!(decrypted_failure.onion_error_code, Some(error_code)); + }; + + { + // Ensure error decryption works from the first Trampoline hop, but at the outer onion. + let error_code = 0x2003; + let mut trampoline_outer_hop_error_packet = build_unencrypted_failure_packet( + outer_onion_keys[1].shared_secret.as_ref(), + error_code, + &[0; 0], + ); + + crypt_failure_packet( + outer_onion_keys[1].shared_secret.as_ref(), + &mut trampoline_outer_hop_error_packet, + ); + + crypt_failure_packet( + outer_onion_keys[0].shared_secret.as_ref(), + &mut trampoline_outer_hop_error_packet, + ); + + let decrypted_failure = process_onion_failure( + &secp_ctx, + &logger, + &htlc_source, + trampoline_outer_hop_error_packet, + ); + assert_eq!(decrypted_failure.onion_error_code, Some(error_code)); + }; + + { + // Ensure error decryption works from the Trampoline inner onion. + let error_code = 0x2004; + let mut trampoline_inner_hop_error_packet = build_unencrypted_failure_packet( + trampoline_onion_keys[0].shared_secret.as_ref(), + error_code, + &[0; 0], + ); + + crypt_failure_packet( + trampoline_onion_keys[0].shared_secret.as_ref(), + &mut trampoline_inner_hop_error_packet, + ); + + crypt_failure_packet( + outer_onion_keys[1].shared_secret.as_ref(), + &mut trampoline_inner_hop_error_packet, + ); + + crypt_failure_packet( + outer_onion_keys[0].shared_secret.as_ref(), + &mut trampoline_inner_hop_error_packet, + ); + + let decrypted_failure = process_onion_failure( + &secp_ctx, + &logger, + &htlc_source, + trampoline_inner_hop_error_packet, + ); + assert_eq!(decrypted_failure.onion_error_code, Some(error_code)); + } + + { + // Ensure error decryption works from a later hop in the Trampoline inner onion. + let error_code = 0x2005; + let mut trampoline_second_hop_error_packet = build_unencrypted_failure_packet( + trampoline_onion_keys[1].shared_secret.as_ref(), + error_code, + &[0; 0], + ); + + crypt_failure_packet( + trampoline_onion_keys[1].shared_secret.as_ref(), + &mut trampoline_second_hop_error_packet, + ); + + crypt_failure_packet( + trampoline_onion_keys[0].shared_secret.as_ref(), + &mut trampoline_second_hop_error_packet, + ); + + crypt_failure_packet( + outer_onion_keys[1].shared_secret.as_ref(), + &mut trampoline_second_hop_error_packet, + ); + + crypt_failure_packet( + outer_onion_keys[0].shared_secret.as_ref(), + &mut trampoline_second_hop_error_packet, + ); + + let decrypted_failure = process_onion_failure( + &secp_ctx, + &logger, + &htlc_source, + trampoline_second_hop_error_packet, + ); + assert_eq!(decrypted_failure.onion_error_code, Some(error_code)); + } + } + } + #[test] fn test_non_attributable_failure_packet_onion() { // Create a failure packet with bogus data. diff --git a/lightning/src/routing/router.rs b/lightning/src/routing/router.rs index 5516ca325f5..14d06355bc0 100644 --- a/lightning/src/routing/router.rs +++ b/lightning/src/routing/router.rs @@ -519,6 +519,11 @@ impl Path { None => self.hops.last().map(|hop| hop.cltv_expiry_delta) } } + + /// True if this [`Path`] has at least one Trampoline hop. + pub fn has_trampoline_hops(&self) -> bool { + self.blinded_tail.as_ref().map_or(false, |bt| !bt.trampoline_hops.is_empty()) + } } /// A route directs a payment from the sender (us) to the recipient. If the recipient supports MPP,