diff --git a/lightning/src/ln/blinded_payment_tests.rs b/lightning/src/ln/blinded_payment_tests.rs index 0631db3d408..2ea66e1e93a 100644 --- a/lightning/src/ln/blinded_payment_tests.rs +++ b/lightning/src/ln/blinded_payment_tests.rs @@ -10,8 +10,6 @@ // licenses. use bitcoin::hashes::hex::FromHex; -use bitcoin::hashes::sha256::Hash as Sha256; -use bitcoin::hashes::Hash; use bitcoin::hex::DisplayHex; use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey, schnorr}; use bitcoin::secp256k1::ecdh::SharedSecret; @@ -1830,7 +1828,7 @@ fn test_combined_trampoline_onion_creation_vectors() { let amt_msat = 150_000_000; let cur_height = 800_000; let recipient_onion_fields = RecipientOnionFields::secret_only(payment_secret); - let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &session_priv, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, [0; 32], Some(outer_session_key), Some(outer_onion_prng_seed)).unwrap(); + let (bob_onion, htlc_msat, htlc_cltv) = onion_utils::create_payment_onion_internal(&secp_ctx, &path, &outer_session_key, amt_msat, &recipient_onion_fields, cur_height, &associated_data, &None, None, outer_onion_prng_seed, Some(session_priv), Some([0; 32])).unwrap(); let outer_onion_packet_hex = bob_onion.encode().to_lower_hex_string(); assert_eq!(outer_onion_packet_hex, "00025fd60556c134ae97e4baedba220a644037754ee67c54fd05e93bf40c17cbb73362fb9dee96001ff229945595b6edb59437a6bc143406d3f90f749892a84d8d430c6890437d26d5bfc599d565316ef51347521075bbab87c59c57bcf20af7e63d7192b46cf171e4f73cb11f9f603915389105d91ad630224bea95d735e3988add1e24b5bf28f1d7128db64284d90a839ba340d088c74b1fb1bd21136b1809428ec5399c8649e9bdf92d2dcfc694deae5046fa5b2bdf646847aaad73f5e95275763091c90e71031cae1f9a770fdea559642c9c02f424a2a28163dd0957e3874bd28a97bec67d18c0321b0e68bc804aa8345b17cb626e2348ca06c8312a167c989521056b0f25c55559d446507d6c491d50605cb79fa87929ce64b0a9860926eeaec2c431d926a1cadb9a1186e4061cb01671a122fc1f57602cbef06d6c194ec4b715c2e3dd4120baca3172cd81900b49fef857fb6d6afd24c983b608108b0a5ac0c1c6c52011f23b8778059ffadd1bb7cd06e2525417365f485a7fd1d4a9ba3818ede7cdc9e71afee8532252d08e2531ca52538655b7e8d912f7ec6d37bbcce8d7ec690709dbf9321e92c565b78e7fe2c22edf23e0902153d1ca15a112ad32fb19695ec65ce11ddf670da7915f05ad4b86c154fb908cb567315d1124f303f75fa075ebde8ef7bb12e27737ad9e4924439097338ea6d7a6fc3721b88c9b830a34e8d55f4c582b74a3895cc848fe57f4fe29f115dabeb6b3175be15d94408ed6771109cfaf57067ae658201082eae7605d26b1449af4425ae8e8f58cdda5c6265f1fd7a386fc6cea3074e4f25b909b96175883676f7610a00fdf34df9eb6c7b9a4ae89b839c69fd1f285e38cdceb634d782cc6d81179759bc9fd47d7fd060470d0b048287764c6837963274e708314f017ac7dc26d0554d59bfcfd3136225798f65f0b0fea337c6b256ebbb63a90b994c0ab93fd8b1d6bd4c74aebe535d6110014cd3d525394027dfe8faa98b4e9b2bee7949eb1961f1b026791092f84deea63afab66603dbe9b6365a102a1fef2f6b9744bc1bb091a8da9130d34d4d39f25dbad191649cfb67e10246364b7ce0c6ec072f9690cabb459d9fda0c849e17535de4357e9907270c75953fca3c845bb613926ecf73205219c7057a4b6bb244c184362bb4e2f24279dc4e60b94a5b1ec11c34081a628428ba5646c995b9558821053ba9c84a05afbf00dabd60223723096516d2f5668f3ec7e11612b01eb7a3a0506189a2272b88e89807943adb34291a17f6cb5516ffd6f945a1c42a524b21f096d66f350b1dad4db455741ae3d0e023309fbda5ef55fb0dc74f3297041448b2be76c525141963934c6afc53d263fb7836626df502d7c2ee9e79cbbd87afd84bbb8dfbf45248af3cd61ad5fac827e7683ca4f91dfad507a8eb9c17b2c9ac5ec051fe645a4a6cb37136f6f19b611e0ea8da7960af2d779507e55f57305bc74b7568928c5dd5132990fe54c22117df91c257d8c7b61935a018a28c1c3b17bab8e4294fa699161ec21123c9fc4e71079df31f300c2822e1246561e04765d3aab333eafd026c7431ac7616debb0e022746f4538e1c6348b600c988eeb2d051fc60c468dca260a84c79ab3ab8342dc345a764672848ea234e17332bc124799daf7c5fcb2e2358514a7461357e1c19c802c5ee32deccf1776885dd825bedd5f781d459984370a6b7ae885d4483a76ddb19b30f47ed47cd56aa5a079a89793dbcad461c59f2e002067ac98dd5a534e525c9c46c2af730741bf1f8629357ec0bfc0bc9ecb31af96777e507648ff4260dc3673716e098d9111dfd245f1d7c55a6de340deb8bd7a053e5d62d760f184dc70ca8fa255b9023b9b9aedfb6e419a5b5951ba0f83b603793830ee68d442d7b88ee1bbf6bbd1bcd6f68cc1af"); @@ -1984,7 +1982,10 @@ fn test_trampoline_inbound_payment_decoding() { }; } -fn do_test_trampoline_single_hop_receive(success: bool) { +#[test] +fn test_trampoline_forward_payload_encoded_as_receive() { + // Test that we'll fail backwards as expected when receiving a well-formed blinded forward + // trampoline onion payload with no next hop present. const TOTAL_NODE_COUNT: usize = 3; let secp_ctx = Secp256k1::new(); @@ -2008,30 +2009,18 @@ fn do_test_trampoline_single_hop_receive(success: bool) { let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); let amt_msat = 1000; - let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + let (payment_preimage, payment_hash, _) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); - let carol_alice_trampoline_session_priv = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); - let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &carol_alice_trampoline_session_priv); - let carol_blinded_hops = if success { - let payee_tlvs = UnauthenticatedReceiveTlvs { - payment_secret, - payment_constraints: PaymentConstraints { - max_cltv_expiry: u32::max_value(), - htlc_minimum_msat: amt_msat, - }, - payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), - }; + // We need the session priv to construct an invalid onion packet later. + let override_random_bytes = [3; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); - let nonce = Nonce([42u8; 16]); - let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); - let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); - let carol_unblinded_tlvs = payee_tlvs.encode(); + let outer_session_priv = SecretKey::from_slice(&override_random_bytes).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); - let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; - blinded_path::utils::construct_blinded_hops( - &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, - ).unwrap() - } else { + // Create a blinded hop for the recipient that is encoded as a trampoline forward. + let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &trampoline_session_priv); + let carol_blinded_hops = { let payee_tlvs = blinded_path::payment::TrampolineForwardTlvs { next_trampoline: alice_node_id, payment_constraints: PaymentConstraints { @@ -2050,7 +2039,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { let carol_unblinded_tlvs = payee_tlvs.encode(); let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; blinded_path::utils::construct_blinded_hops( - &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, + &secp_ctx, path.into_iter(), &trampoline_session_priv, ).unwrap() }; @@ -2086,7 +2075,7 @@ fn do_test_trampoline_single_hop_receive(success: bool) { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: 104, }, ], hops: carol_blinded_hops, @@ -2098,83 +2087,350 @@ fn do_test_trampoline_single_hop_receive(success: bool) { route_params: None, }; - // We need the session priv to construct an invalid onion packet later. - let override_random_bytes = [3; 32]; - *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); - nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + check_added_monitors!(&nodes[0], 1); + + let replacement_onion = { + // create a substitute onion where the last Trampoline hop is a forward + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + + let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); + + // append some dummy blinded hop so the intro hop looks like a forward + blinded_tail.hops.push(BlindedHop { + blinded_node_id: alice_node_id, + encrypted_payload: vec![], + }); + + let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + + // pop the last dummy hop + trampoline_payloads.pop(); + + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); + let trampoline_packet = onion_utils::construct_trampoline_onion_packet( + trampoline_payloads, + trampoline_onion_keys, + override_random_bytes, + &payment_hash, + None, + ).unwrap(); + + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); + let outer_packet = onion_utils::construct_onion_packet( + outer_payloads, + outer_onion_keys, + override_random_bytes, + &payment_hash, + ).unwrap(); + + outer_packet + }; + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let mut update_message = match first_message_event { + MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { + assert_eq!(updates.update_add_htlcs.len(), 1); + updates.update_add_htlcs.get_mut(0) + }, + _ => panic!() + }; + update_message.map(|msg| { + msg.onion_routing_packet = replacement_onion.clone(); + }); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::InvalidOnion); + do_pass_along_path(args); + + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } + { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionPayload, &[0; 0]); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + } +} + +fn do_test_trampoline_single_hop_receive(success: bool) { + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); + + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); + + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } + + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + + let amt_msat = 1000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + + // Create a 1-hop blinded path for Carol. + let payee_tlvs = UnauthenticatedReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + let nonce = Nonce([42u8; 16]); + let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); + let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); + let blinded_path = BlindedPaymentPath::new(&[], carol_node_id, payee_tlvs, u64::MAX, 0, nodes[2].keys_manager, &secp_ctx).unwrap(); + + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 0, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: amt_msat, + cltv_expiry_delta: 104, + }, + ], + hops: blinded_path.blinded_hops().to_vec(), + blinding_point: blinded_path.blinding_point(), + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); check_added_monitors!(&nodes[0], 1); + pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); if success { - pass_along_route(&nodes[0], &[&[&nodes[1], &nodes[2]]], amt_msat, payment_hash, payment_secret); claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } else { - let replacement_onion = { - // create a substitute onion where the last Trampoline hop is a forward - let trampoline_secret_key = SecretKey::from_slice(&override_random_bytes).unwrap(); - let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + fail_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_hash); + } +} - let mut blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); +#[test] +fn test_trampoline_single_hop_receive() { + // Simulate a payment of A (0) -> B (1) -> C(Trampoline (blinded intro)) (2) + do_test_trampoline_single_hop_receive(true); - // append some dummy blinded hop so the intro hop looks like a forward - blinded_tail.hops.push(BlindedHop { - blinded_node_id: alice_node_id, - encrypted_payload: vec![], - }); + // Simulate a payment failure of A (0) -> B (1) -> C(Trampoline (blinded forward)) (2) + do_test_trampoline_single_hop_receive(false); +} - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); +fn do_test_trampoline_unblinded_receive(underpay: bool) { + // Test trampoline payment receipt with unblinded final hop. + // Creates custom onion packet where the final trampoline hop uses unblinded receive format + // (not natively supported) to validate payment amount verification. + // - When underpay=false: Payment succeeds with correct amount + // - When underpay=true: Payment fails due to amount mismatch (sends 1/2 expected amount) + // Topology: A (0) -> B (1) C -> (Trampoline receiver) (2) - // pop the last dummy hop - trampoline_payloads.pop(); + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); - let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_secret_key); - let trampoline_packet = onion_utils::construct_trampoline_onion_packet( - trampoline_payloads, - trampoline_onion_keys, - override_random_bytes, - &payment_hash, - None, - ).unwrap(); + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); - let outer_session_priv = { - let session_priv_hash = Sha256::hash(&override_random_bytes).to_byte_array(); - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") - }; - - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); - let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); - let outer_packet = onion_utils::construct_onion_packet( - outer_payloads, - outer_onion_keys, - override_random_bytes, - &payment_hash, - ).unwrap(); + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); - outer_packet - }; + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } - let mut events = nodes[0].node.get_and_clear_pending_msg_events(); - assert_eq!(events.len(), 1); - let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); - let mut update_message = match first_message_event { - MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { - assert_eq!(updates.update_add_htlcs.len(), 1); - updates.update_add_htlcs.get_mut(0) - }, - _ => panic!() + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + + let amt_msat = 1000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 0, // no routing fees because it's the final hop + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: 0, // no trampoline fee because we are receiving. + cltv_expiry_delta: 72, // blinded hop cltv to be used building the outer onion. + }, + ], + // The blinded path data is unused because we replace the onion of the last hop + hops: vec![BlindedHop { + blinded_node_id: PublicKey::from_slice(&[2; 33]).unwrap(), + encrypted_payload: vec![42; 32] + }], + blinding_point: PublicKey::from_slice(&[2; 33]).unwrap(), + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + // We need the session priv to construct an invalid onion packet later. + let override_random_bytes = [42; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + + let replacement_onion = { + // create a substitute onion where the last Trampoline hop is an unblinded receive, which we + // (deliberately) do not support out of the box, therefore necessitating this workaround + let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); + let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); + + let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); + let (_, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); + let replacement_payload_amount = if underpay { amt_msat * 2 } else { amt_msat }; + let trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { + payment_data: Some(msgs::FinalOnionHopData { + payment_secret, + total_msat: replacement_payload_amount, + }), + sender_intended_htlc_amt_msat: replacement_payload_amount, + // We will use the same cltv to the outer onion: 72 (blinded tail) + 32 (offset). + cltv_expiry_height: 104, + }]; + + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); + let trampoline_packet = onion_utils::construct_trampoline_onion_packet( + trampoline_payloads, + trampoline_onion_keys, + override_random_bytes, + &payment_hash, + None, + ).unwrap(); + + // Use a different session key to construct the replacement onion packet. Note that the sender isn't aware of + // this and won't be able to decode the fulfill hold times. + let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); + let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); + let outer_packet = onion_utils::construct_onion_packet( + outer_payloads, + outer_onion_keys, + override_random_bytes, + &payment_hash, + ).unwrap(); + + outer_packet + }; + + check_added_monitors!(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let mut first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + let mut update_message = match first_message_event { + MessageSendEvent::UpdateHTLCs { ref mut updates, .. } => { + assert_eq!(updates.update_add_htlcs.len(), 1); + updates.update_add_htlcs.get_mut(0) + }, + _ => panic!() + }; + update_message.map(|msg| { + msg.onion_routing_packet = replacement_onion.clone(); + }); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event); + + let args = if underpay { + args.with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }) + } else { + args.with_payment_secret(payment_secret) }; - update_message.map(|msg| { - msg.onion_routing_packet = replacement_onion.clone(); - }); - let route: &[&Node] = &[&nodes[1], &nodes[2]]; - let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_preimage(payment_preimage) - .without_claimable_event() - .expect_failure(HTLCHandlingFailureType::InvalidOnion); - do_pass_along_path(args); + do_pass_along_path(args); + if underpay { { let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); nodes[1].node.handle_update_fail_htlc( @@ -2190,25 +2446,43 @@ fn do_test_trampoline_single_hop_receive(success: bool) { do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); } { + let expected_error_data = amt_msat.to_be_bytes(); let payment_failed_conditions = PaymentFailedConditions::new() - .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionPayload, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectHTLCAmount, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); } + } else { + claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); } } #[test] -fn test_trampoline_single_hop_receive() { - // Simulate a payment of A (0) -> B (1) -> C(Trampoline (blinded intro)) (2) - do_test_trampoline_single_hop_receive(true); - - // Simulate a payment failure of A (0) -> B (1) -> C(Trampoline (blinded forward)) (2) - do_test_trampoline_single_hop_receive(false); +fn test_trampoline_unblinded_receive_underpay() { + do_test_trampoline_unblinded_receive(true); } #[test] -fn test_trampoline_unblinded_receive() { - // Simulate a payment of A (0) -> B (1) -> C(Trampoline) (2) +fn test_trampoline_unblinded_receive_normal() { + do_test_trampoline_unblinded_receive(false); +} + +#[derive(PartialEq)] +enum TrampolineConstraintFailureScenarios { + TrampolineCLTVGreaterThanOnion, + #[allow(dead_code)] + // TODO: To test amount greater than onion we need the ability + // to forward Trampoline payments. + TrampolineAmountGreaterThanOnion, +} + +fn do_test_trampoline_unblinded_receive_constraint_failure(failure_scenario: TrampolineConstraintFailureScenarios) { + // Test trampoline payment constraint validation failures with unblinded receive format. + // Creates deliberately invalid trampoline payments to verify constraint enforcement: + // - TrampolineCLTVGreaterThanOnion: Trampoline CLTV exceeds outer onion requirements + // - TrampolineAmountGreaterThanOnion: Trampoline amount exceeds outer onion value + // Uses custom onion construction to simulate constraint violations that should trigger + // specific HTLC failure codes (FinalIncorrectCLTVExpiry or FinalIncorrectHTLCAmount). + // Topology: A (0) -> B (1) -> C (Trampoline receiver) (2) const TOTAL_NODE_COUNT: usize = 3; let secp_ctx = Secp256k1::new(); @@ -2257,6 +2531,15 @@ fn test_trampoline_unblinded_receive() { &secp_ctx, path.into_iter(), &carol_alice_trampoline_session_priv, ).unwrap(); + // We decide an arbitrary ctlv delta for the blinded hop that will be the only cltv delta + // in the blinded tail. + let blinded_hop_cltv = if failure_scenario == TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion { 52 } else { 72 }; + // Then when building the trampoline hop we use an arbitrary cltv delta offset to be used + // when re-building the outer trampoline onion. + let starting_cltv_offset_trampoline = 32; + // Finally we decide a forced cltv delta expiry for the trampoline hop itself. + // This one will be compared against the outer onion ctlv delta. + let forced_trampoline_cltv_delta = 104; let route = Route { paths: vec![Path { hops: vec![ @@ -2277,7 +2560,7 @@ fn test_trampoline_unblinded_receive() { node_features: NodeFeatures::empty(), short_channel_id: bob_carol_scid, channel_features: ChannelFeatures::empty(), - fee_msat: 0, + fee_msat: 0, // no routing fees because it's the final hop cltv_expiry_delta: 48, maybe_announced_channel: false, } @@ -2289,11 +2572,12 @@ fn test_trampoline_unblinded_receive() { pubkey: carol_node_id, node_features: Features::empty(), fee_msat: amt_msat, - cltv_expiry_delta: 24, + cltv_expiry_delta: blinded_hop_cltv, // blinded tail ctlv delta. }, ], hops: carol_blinded_hops, blinding_point: carol_blinding_point, + // This will be ignored because we force the cltv_expiry of the trampoline hop. excess_final_cltv_expiry_delta: 39, final_value_msat: amt_msat, }) @@ -2301,49 +2585,45 @@ fn test_trampoline_unblinded_receive() { route_params: None, }; + let override_random_bytes = [42; 32]; + *nodes[0].keys_manager.override_random_bytes.lock().unwrap() = Some(override_random_bytes); + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); let replacement_onion = { // create a substitute onion where the last Trampoline hop is an unblinded receive, which we // (deliberately) do not support out of the box, therefore necessitating this workaround - let trampoline_secret_key = secret_from_hex("0134928f7b7ca6769080d70f16be84c812c741f545b49a34db47ce338a205799"); - let prng_seed = secret_from_hex("fe02b4b9054302a3ddf4e1e9f7c411d644aebbd295218ab009dca94435f775a9"); + let outer_session_priv = SecretKey::from_slice(&override_random_bytes[..]).unwrap(); + let trampoline_session_priv = onion_utils::compute_trampoline_session_priv(&outer_session_priv); let recipient_onion_fields = RecipientOnionFields::spontaneous_empty(); let blinded_tail = route.paths[0].blinded_tail.clone().unwrap(); - let (mut trampoline_payloads, outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, 32, &None).unwrap(); - // pop the last dummy hop - trampoline_payloads.pop(); - - trampoline_payloads.push(msgs::OutboundTrampolinePayload::Receive { + let (_ , outer_total_msat, outer_starting_htlc_offset) = onion_utils::build_trampoline_onion_payloads(&blinded_tail, amt_msat, &recipient_onion_fields, starting_cltv_offset_trampoline, &None).unwrap(); + let trampoline_payloads = vec![msgs::OutboundTrampolinePayload::Receive { payment_data: Some(msgs::FinalOnionHopData { payment_secret, total_msat: amt_msat, }), sender_intended_htlc_amt_msat: amt_msat, - cltv_expiry_height: 104, - }); + cltv_expiry_height: forced_trampoline_cltv_delta, + }]; - let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_secret_key); + let trampoline_onion_keys = onion_utils::construct_trampoline_onion_keys(&secp_ctx, &route.paths[0].blinded_tail.as_ref().unwrap(), &trampoline_session_priv); let trampoline_packet = onion_utils::construct_trampoline_onion_packet( trampoline_payloads, trampoline_onion_keys, - prng_seed.secret_bytes(), + override_random_bytes, &payment_hash, None, ).unwrap(); - // Use a different session key to construct the replacement onion packet. Note that the sender isn't aware of - // this and won't be able to decode the fulfill hold times. - let outer_session_priv = secret_from_hex("e52c20461ed7acd46c4e7b591a37610519179482887bd73bf3b94617f8f03677"); - let (outer_payloads, _, _) = onion_utils::build_onion_payloads(&route.paths[0], outer_total_msat, &recipient_onion_fields, outer_starting_htlc_offset, &None, None, Some(trampoline_packet)).unwrap(); let outer_onion_keys = onion_utils::construct_onion_keys(&secp_ctx, &route.clone().paths[0], &outer_session_priv); let outer_packet = onion_utils::construct_onion_packet( outer_payloads, outer_onion_keys, - prng_seed.secret_bytes(), + override_random_bytes, &payment_hash, ).unwrap(); @@ -2368,10 +2648,199 @@ fn test_trampoline_unblinded_receive() { let route: &[&Node] = &[&nodes[1], &nodes[2]]; let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) - .with_payment_secret(payment_secret); + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + do_pass_along_path(args); + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } - claim_payment(&nodes[0], &[&nodes[1], &nodes[2]], payment_preimage); + match failure_scenario { + TrampolineConstraintFailureScenarios::TrampolineAmountGreaterThanOnion => { + let expected_error_data = amt_msat.to_be_bytes(); + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectHTLCAmount, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + }, + TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion => { + // The amount of the outer onion cltv delta plus the trampoline offset. + let expected_error_data = (blinded_hop_cltv + starting_cltv_offset_trampoline).to_be_bytes(); + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::FinalIncorrectCLTVExpiry, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + } + } +} + +fn do_test_trampoline_blinded_receive_constraint_failure(failure_scenario: TrampolineConstraintFailureScenarios) { + // Test trampoline payment constraint validation failures with blinded receive format. + // Creates deliberately invalid trampoline payments to verify constraint enforcement: + // - TrampolineCLTVGreaterThanOnion: Trampoline CLTV exceeds outer onion requirements + // - TrampolineAmountGreaterThanOnion: Trampoline amount exceeds outer onion value + // Topology: A (0) -> B (1) -> C (Trampoline receiver inside blinded path) (2) + + const TOTAL_NODE_COUNT: usize = 3; + let secp_ctx = Secp256k1::new(); + + let chanmon_cfgs = create_chanmon_cfgs(TOTAL_NODE_COUNT); + let node_cfgs = create_node_cfgs(TOTAL_NODE_COUNT, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(TOTAL_NODE_COUNT, &node_cfgs, &vec![None; TOTAL_NODE_COUNT]); + let mut nodes = create_network(TOTAL_NODE_COUNT, &node_cfgs, &node_chanmgrs); + + let (_, _, chan_id_alice_bob, _) = create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 1_000_000, 0); + let (_, _, chan_id_bob_carol, _) = create_announced_chan_between_nodes_with_value(&nodes, 1, 2, 1_000_000, 0); + + for i in 0..TOTAL_NODE_COUNT { // connect all nodes' blocks + connect_blocks(&nodes[i], (TOTAL_NODE_COUNT as u32) * CHAN_CONFIRM_DEPTH + 1 - nodes[i].best_block_info().1); + } + + let bob_node_id = nodes[1].node().get_our_node_id(); + let carol_node_id = nodes[2].node().get_our_node_id(); + + let alice_bob_scid = nodes[0].node().list_channels().iter().find(|c| c.channel_id == chan_id_alice_bob).unwrap().short_channel_id.unwrap(); + let bob_carol_scid = nodes[1].node().list_channels().iter().find(|c| c.channel_id == chan_id_bob_carol).unwrap().short_channel_id.unwrap(); + let amt_msat = 1000; + let (payment_preimage, payment_hash, payment_secret) = get_payment_preimage_hash(&nodes[2], Some(amt_msat), None); + + let alice_carol_trampoline_shared_secret = secret_from_hex("a0f4b8d7b6c2d0ffdfaf718f76e9decaef4d9fb38a8c4addb95c4007cc3eee03"); + let carol_blinding_point = PublicKey::from_secret_key(&secp_ctx, &alice_carol_trampoline_shared_secret); + let payee_tlvs = UnauthenticatedReceiveTlvs { + payment_secret, + payment_constraints: PaymentConstraints { + max_cltv_expiry: u32::max_value(), + htlc_minimum_msat: amt_msat, + }, + payment_context: PaymentContext::Bolt12Refund(Bolt12RefundContext {}), + }; + + let nonce = Nonce([42u8; 16]); + let expanded_key = nodes[2].keys_manager.get_inbound_payment_key(); + let payee_tlvs = payee_tlvs.authenticate(nonce, &expanded_key); + let carol_unblinded_tlvs = payee_tlvs.encode(); + + // Blinded path is Carol as recipient. + let path = [((carol_node_id, None), WithoutLength(&carol_unblinded_tlvs))]; + let blinded_hops = blinded_path::utils::construct_blinded_hops( + &secp_ctx, path.into_iter(), &alice_carol_trampoline_shared_secret, + ).unwrap(); + + // We decide an arbitrary ctlv delta for the blinded hop that will be the only cltv delta + // in the blinded tail. + let blinded_hop_cltv = if failure_scenario == TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion { 2 } else { 144 }; + + let route = Route { + paths: vec![Path { + hops: vec![ + // Bob + RouteHop { + pubkey: bob_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: alice_bob_scid, + channel_features: ChannelFeatures::empty(), + fee_msat: 1000, // forwarding fee to Carol + cltv_expiry_delta: 48, + maybe_announced_channel: false, + }, + + // Carol + RouteHop { + pubkey: carol_node_id, + node_features: NodeFeatures::empty(), + short_channel_id: bob_carol_scid, + channel_features: ChannelFeatures::empty(), + // fee for the usage of the entire blinded path, including Trampoline. + // In this case is zero as we are the recipient of the payment. + fee_msat: 0, + cltv_expiry_delta: 48, + maybe_announced_channel: false, + } + ], + blinded_tail: Some(BlindedTail { + trampoline_hops: vec![ + // Carol + TrampolineHop { + pubkey: carol_node_id, + node_features: Features::empty(), + fee_msat: amt_msat, + cltv_expiry_delta: blinded_hop_cltv, + }, + + ], + hops: blinded_hops, + blinding_point: carol_blinding_point, + excess_final_cltv_expiry_delta: 39, + final_value_msat: amt_msat, + }) + }], + route_params: None, + }; + + nodes[0].node.send_payment_with_route(route.clone(), payment_hash, RecipientOnionFields::spontaneous_empty(), PaymentId(payment_hash.0)).unwrap(); + check_added_monitors!(&nodes[0], 1); + + let mut events = nodes[0].node.get_and_clear_pending_msg_events(); + assert_eq!(events.len(), 1); + let first_message_event = remove_first_msg_event_to_node(&nodes[1].node.get_our_node_id(), &mut events); + + let route: &[&Node] = &[&nodes[1], &nodes[2]]; + let args = PassAlongPathArgs::new(&nodes[0], route, amt_msat, payment_hash, first_message_event) + .with_payment_preimage(payment_preimage) + .without_claimable_event() + .expect_failure(HTLCHandlingFailureType::Receive { payment_hash }); + + do_pass_along_path(args); + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[2], nodes[1].node.get_our_node_id()); + nodes[1].node.handle_update_fail_htlc( + nodes[2].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[1], &nodes[2], &unblinded_node_updates.commitment_signed, true, false); + } + { + let unblinded_node_updates = get_htlc_update_msgs!(nodes[1], nodes[0].node.get_our_node_id()); + nodes[0].node.handle_update_fail_htlc( + nodes[1].node.get_our_node_id(), &unblinded_node_updates.update_fail_htlcs[0] + ); + do_commitment_signed_dance(&nodes[0], &nodes[1], &unblinded_node_updates.commitment_signed, false, false); + } + + // We don't share the error data when receiving inside a blinded path. + let expected_error_data = [0; 32]; + match failure_scenario { + TrampolineConstraintFailureScenarios::TrampolineAmountGreaterThanOnion => { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionBlinding, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + }, + TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion => { + let payment_failed_conditions = PaymentFailedConditions::new() + .expected_htlc_error_data(LocalHTLCFailureReason::InvalidOnionBlinding, &expected_error_data); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); + } + } +} + +#[test] +fn test_trampoline_enforced_constraint_cltv() { + do_test_trampoline_unblinded_receive_constraint_failure(TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion); +} + +#[test] +fn test_trampoline_blinded_receive_enforced_constraint_cltv() { + do_test_trampoline_blinded_receive_constraint_failure(TrampolineConstraintFailureScenarios::TrampolineCLTVGreaterThanOnion); } #[test] @@ -2489,6 +2958,6 @@ fn test_trampoline_forward_rejection() { // Expect UnknownNextPeer error while we are unable to route forwarding Trampoline payments. let payment_failed_conditions = PaymentFailedConditions::new() .expected_htlc_error_data(LocalHTLCFailureReason::UnknownNextPeer, &[0; 0]); - expect_payment_failed_conditions(&nodes[0], payment_hash, false, payment_failed_conditions); + expect_payment_failed_conditions(&nodes[0], payment_hash, true, payment_failed_conditions); } } diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs index 79952faca9a..474c7017e2f 100644 --- a/lightning/src/ln/onion_payment.rs +++ b/lightning/src/ln/onion_payment.rs @@ -74,6 +74,34 @@ fn check_blinded_forward( Ok((amt_to_forward, outgoing_cltv_value)) } +fn check_trampoline_onion_constraints( + outer_hop_data: &msgs::InboundTrampolineEntrypointPayload, trampoline_cltv_value: u32, + trampoline_amount: u64, +) -> Result<(), InboundHTLCErr> { + if outer_hop_data.outgoing_cltv_value < trampoline_cltv_value { + let err = InboundHTLCErr { + reason: LocalHTLCFailureReason::FinalIncorrectCLTVExpiry, + err_data: outer_hop_data.outgoing_cltv_value.to_be_bytes().to_vec(), + msg: "Trampoline onion's CLTV value exceeded the outer onion's", + }; + return Err(err); + } + let outgoing_amount = outer_hop_data + .multipath_trampoline_data + .as_ref() + .map_or(outer_hop_data.amt_to_forward, |mtd| mtd.total_msat); + if outgoing_amount < trampoline_amount { + let err = InboundHTLCErr { + reason: LocalHTLCFailureReason::FinalIncorrectHTLCAmount, + err_data: outgoing_amount.to_be_bytes().to_vec(), + msg: "Trampoline onion's amt value exceeded the outer onion's", + }; + return Err(err); + } + + Ok(()) +} + enum RoutingInfo { Direct { short_channel_id: u64, @@ -135,7 +163,9 @@ pub(super) fn create_fwd_pending_htlc_info( reason: LocalHTLCFailureReason::InvalidOnionPayload, err_data: Vec::new(), }), - onion_utils::Hop::TrampolineForward { next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineForward { ref outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + // TODO: return reason as forward issue, not as receiving issue when forwarding is ready. + check_trampoline_onion_constraints(outer_hop_data, next_trampoline_hop_data.outgoing_cltv_value, next_trampoline_hop_data.amt_to_forward)?; ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, @@ -150,7 +180,7 @@ pub(super) fn create_fwd_pending_htlc_info( None ) }, - onion_utils::Hop::TrampolineBlindedForward { outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { + onion_utils::Hop::TrampolineBlindedForward { ref outer_hop_data, next_trampoline_hop_data, next_trampoline_hop_hmac, new_trampoline_packet_bytes, trampoline_shared_secret, .. } => { let (amt_to_forward, outgoing_cltv_value) = check_blinded_forward( msg.amount_msat, msg.cltv_expiry, &next_trampoline_hop_data.payment_relay, &next_trampoline_hop_data.payment_constraints, &next_trampoline_hop_data.features ).map_err(|()| { @@ -162,6 +192,15 @@ pub(super) fn create_fwd_pending_htlc_info( err_data: vec![0; 32], } })?; + check_trampoline_onion_constraints(outer_hop_data, outgoing_cltv_value, amt_to_forward).map_err(|e| { + // The Trampoline onion's amt and CLTV values cannot exceed the outer onion's, but + // we're inside a blinded path + InboundHTLCErr { + reason: LocalHTLCFailureReason::InvalidOnionBlinding, + err_data: vec![0; 32], + msg: e.msg, + } + })?; ( RoutingInfo::Trampoline { next_trampoline: next_trampoline_hop_data.next_trampoline, @@ -281,14 +320,18 @@ pub(super) fn create_recv_pending_htlc_info( intro_node_blinding_point.is_none(), true, invoice_request) } onion_utils::Hop::TrampolineReceive { + ref outer_hop_data, trampoline_hop_data: msgs::InboundOnionReceivePayload { payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, cltv_expiry_height, payment_metadata, .. }, .. - } => + } => { + check_trampoline_onion_constraints(outer_hop_data, cltv_expiry_height, sender_intended_htlc_amt_msat)?; (payment_data, keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, - cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None), + cltv_expiry_height, payment_metadata, None, false, keysend_preimage.is_none(), None) + } onion_utils::Hop::TrampolineBlindedReceive { + ref outer_hop_data, trampoline_hop_data: msgs::InboundOnionBlindedReceivePayload { sender_intended_htlc_amt_msat, total_msat, cltv_expiry_height, payment_secret, intro_node_blinding_point, payment_constraints, payment_context, keysend_preimage, @@ -306,6 +349,15 @@ pub(super) fn create_recv_pending_htlc_info( } })?; let payment_data = msgs::FinalOnionHopData { payment_secret, total_msat }; + check_trampoline_onion_constraints(outer_hop_data, cltv_expiry_height, sender_intended_htlc_amt_msat).map_err(|e| { + // The Trampoline onion's amt and CLTV values cannot exceed the outer onion's, but + // we're inside a blinded path + InboundHTLCErr { + reason: LocalHTLCFailureReason::InvalidOnionBlinding, + err_data: vec![0; 32], + msg: e.msg, + } + })?; (Some(payment_data), keysend_preimage, custom_tlvs, sender_intended_htlc_amt_msat, cltv_expiry_height, None, Some(payment_context), intro_node_blinding_point.is_none(), true, invoice_request) @@ -602,6 +654,25 @@ where outgoing_cltv_value, }) } + onion_utils::Hop::TrampolineBlindedForward { next_trampoline_hop_data: msgs::InboundTrampolineBlindedForwardPayload { next_trampoline, ref payment_relay, ref payment_constraints, ref features, .. }, outer_shared_secret, trampoline_shared_secret, incoming_trampoline_public_key, .. } => { + let (amt_to_forward, outgoing_cltv_value) = match check_blinded_forward( + msg.amount_msat, msg.cltv_expiry, &payment_relay, &payment_constraints, &features + ) { + Ok((amt, cltv)) => (amt, cltv), + Err(()) => { + return encode_relay_error("Trampoline blinded forward amt or CLTV values exceeded the outer onion's", + LocalHTLCFailureReason::InvalidOnionBlinding, outer_shared_secret.secret_bytes(), Some(trampoline_shared_secret.secret_bytes()), &[0; 32]); + } + }; + let next_trampoline_packet_pubkey = onion_utils::next_hop_pubkey(secp_ctx, + incoming_trampoline_public_key, &trampoline_shared_secret.secret_bytes()); + Some(NextPacketDetails { + next_packet_pubkey: next_trampoline_packet_pubkey, + outgoing_connector: HopConnector::Trampoline(next_trampoline), + outgoing_amt_msat: amt_to_forward, + outgoing_cltv_value, + }) + } _ => None }; diff --git a/lightning/src/ln/onion_utils.rs b/lightning/src/ln/onion_utils.rs index d45860b0e26..335f4dca2a5 100644 --- a/lightning/src/ln/onion_utils.rs +++ b/lightning/src/ln/onion_utils.rs @@ -992,41 +992,19 @@ pub fn process_onion_failure( where L::Target: Logger, { - let (path, primary_session_priv) = match htlc_source { + let (path, 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, - path, - &outer_session_priv, - Some(primary_session_priv), - encrypted_packet, - ) - } else { - process_onion_failure_inner( - secp_ctx, - logger, - path, - primary_session_priv, - None, - encrypted_packet, - ) - } + process_onion_failure_inner(secp_ctx, logger, path, &session_priv, None, encrypted_packet) } /// Process failure we got back from upstream on a payment we sent (implying htlc_source is an /// OutboundRoute). fn process_onion_failure_inner( - secp_ctx: &Secp256k1, logger: &L, path: &Path, outer_session_priv: &SecretKey, - inner_session_priv: Option<&SecretKey>, mut encrypted_packet: OnionErrorPacket, + secp_ctx: &Secp256k1, logger: &L, path: &Path, session_priv: &SecretKey, + trampoline_session_priv_override: Option, mut encrypted_packet: OnionErrorPacket, ) -> DecodedOnionFailure where L::Target: Logger, @@ -1097,22 +1075,27 @@ where let nontrampoline_bt = if path.has_trampoline_hops() { None } else { path.blinded_tail.as_ref() }; let nontrampolines = - construct_onion_keys_generic(secp_ctx, &path.hops, nontrampoline_bt, outer_session_priv) - .map(|(shared_secret, _, _, route_hop_option, _)| { + construct_onion_keys_generic(secp_ctx, &path.hops, nontrampoline_bt, session_priv).map( + |(shared_secret, _, _, route_hop_option, _)| { (route_hop_option.map(|rh| ErrorHop::RouteHop(rh)), shared_secret) - }); + }, + ); let trampolines = if path.has_trampoline_hops() { // Trampoline hops are part of the blinded tail, so this can never panic let blinded_tail = path.blinded_tail.as_ref(); let hops = &blinded_tail.unwrap().trampoline_hops; - let inner_session_priv = - inner_session_priv.expect("Trampoline hops always have an inner session priv"); - Some(construct_onion_keys_generic(secp_ctx, hops, blinded_tail, inner_session_priv).map( - |(shared_secret, _, _, route_hop_option, _)| { - (route_hop_option.map(|tram_hop| ErrorHop::TrampolineHop(tram_hop)), shared_secret) - }, - )) + let trampoline_session_priv = trampoline_session_priv_override + .unwrap_or_else(|| compute_trampoline_session_priv(session_priv)); + Some( + construct_onion_keys_generic(secp_ctx, hops, blinded_tail, &trampoline_session_priv) + .map(|(shared_secret, _, _, route_hop_option, _)| { + ( + route_hop_option.map(|tram_hop| ErrorHop::TrampolineHop(tram_hop)), + shared_secret, + ) + }), + ) } else { None }; @@ -1390,10 +1373,17 @@ where short_channel_id = route_hop.short_channel_id() } + // If next hop is from a trampoline we are already inside the trampoline + // route. If we found a permanent failure inside the trampoline route, we + // fail the payment permanently. + let is_next_hop_from_trampoline = + matches!(next_hop, Some((_, (Some(ErrorHop::TrampolineHop(..)), _)))); + res = Some(FailureLearnings { network_update, short_channel_id, - payment_failed_permanently: error_code.is_permanent() && is_from_final_non_blinded_node, + payment_failed_permanently: error_code.is_permanent() + && (is_from_final_non_blinded_node || is_next_hop_from_trampoline), failed_within_blinded_path: false, }); @@ -1678,6 +1668,13 @@ pub enum LocalHTLCFailureReason { HTLCMaximum, /// The HTLC was failed because our remote peer is offline. PeerOffline, + /// We have been unable to forward a payment to the next Trampoline node but may be able to + /// do it later. + TemporaryTrampolineFailure, + /// The amount or CLTV expiry were insufficient to route the payment to the next Trampoline. + TrampolineFeeOrExpiryInsufficient, + /// The specified next Trampoline node cannot be reached from our node. + UnknownNextTrampoline, } impl LocalHTLCFailureReason { @@ -1718,6 +1715,9 @@ impl LocalHTLCFailureReason { Self::InvalidOnionPayload | Self::InvalidTrampolinePayload => PERM | 22, Self::MPPTimeout => 23, Self::InvalidOnionBlinding => BADONION | PERM | 24, + Self::TemporaryTrampolineFailure => NODE | 25, + Self::TrampolineFeeOrExpiryInsufficient => NODE | 26, + Self::UnknownNextTrampoline => PERM | 27, Self::UnknownFailureCode { code } => *code, } } @@ -1852,6 +1852,9 @@ impl_writeable_tlv_based_enum!(LocalHTLCFailureReason, (79, HTLCMinimum) => {}, (81, HTLCMaximum) => {}, (83, PeerOffline) => {}, + (85, TemporaryTrampolineFailure) => {}, + (87, TrampolineFeeOrExpiryInsufficient) => {}, + (89, UnknownNextTrampoline) => {}, ); impl From<&HTLCFailReason> for HTLCHandlingFailureReason { @@ -2018,6 +2021,11 @@ impl HTLCFailReason { debug_assert!(false, "Unknown failure code: {}", code) } }, + LocalHTLCFailureReason::TemporaryTrampolineFailure => debug_assert!(data.is_empty()), + LocalHTLCFailureReason::TrampolineFeeOrExpiryInsufficient => { + debug_assert_eq!(data.len(), 10) + }, + LocalHTLCFailureReason::UnknownNextTrampoline => debug_assert!(data.is_empty()), } Self(HTLCFailReasonRepr::Reason { data, failure_reason }) @@ -2513,18 +2521,24 @@ pub fn create_payment_onion( ) } +pub(super) fn compute_trampoline_session_priv(outer_onion_session_priv: &SecretKey) -> SecretKey { + // When creating the inner trampoline onion, we set the session priv to the hash of the outer + // onion session priv. + let session_priv_hash = Sha256::hash(&outer_onion_session_priv.secret_bytes()).to_byte_array(); + SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") +} + /// Build a payment onion, returning the first hop msat and cltv values as well. /// `cur_block_height` should be set to the best known block height + 1. pub(crate) fn create_payment_onion_internal( secp_ctx: &Secp256k1, path: &Path, session_priv: &SecretKey, total_msat: u64, recipient_onion: &RecipientOnionFields, cur_block_height: u32, payment_hash: &PaymentHash, keysend_preimage: &Option, invoice_request: Option<&InvoiceRequest>, - prng_seed: [u8; 32], secondary_session_priv: Option, - secondary_prng_seed: Option<[u8; 32]>, + prng_seed: [u8; 32], trampoline_session_priv_override: Option, + trampoline_prng_seed_override: Option<[u8; 32]>, ) -> Result<(msgs::OnionPacket, u64, u32), APIError> { let mut outer_total_msat = total_msat; let mut outer_starting_htlc_offset = cur_block_height; - let mut outer_session_priv_override = None; let mut trampoline_packet_option = None; if let Some(blinded_tail) = &path.blinded_tail { @@ -2539,12 +2553,15 @@ pub(crate) fn create_payment_onion_internal( keysend_preimage, )?; + let trampoline_session_priv = trampoline_session_priv_override + .unwrap_or_else(|| compute_trampoline_session_priv(session_priv)); + let trampoline_prng_seed = trampoline_prng_seed_override.unwrap_or(prng_seed); let onion_keys = - construct_trampoline_onion_keys(&secp_ctx, &blinded_tail, &session_priv); + construct_trampoline_onion_keys(&secp_ctx, &blinded_tail, &trampoline_session_priv); let trampoline_packet = construct_trampoline_onion_packet( trampoline_payloads, onion_keys, - prng_seed, + trampoline_prng_seed, payment_hash, // TODO: specify a fixed size for privacy in future spec upgrade None, @@ -2554,11 +2571,6 @@ pub(crate) fn create_payment_onion_internal( })?; trampoline_packet_option = Some(trampoline_packet); - - outer_session_priv_override = Some(secondary_session_priv.unwrap_or_else(|| { - let session_priv_hash = Sha256::hash(&session_priv.secret_bytes()).to_byte_array(); - SecretKey::from_slice(&session_priv_hash[..]).expect("You broke SHA-256!") - })); } } @@ -2572,14 +2584,11 @@ pub(crate) fn create_payment_onion_internal( trampoline_packet_option, )?; - let outer_session_priv = outer_session_priv_override.as_ref().unwrap_or(session_priv); - let onion_keys = construct_onion_keys(&secp_ctx, &path, outer_session_priv); - let outer_onion_prng_seed = secondary_prng_seed.unwrap_or(prng_seed); - let onion_packet = - construct_onion_packet(onion_payloads, onion_keys, outer_onion_prng_seed, payment_hash) - .map_err(|_| APIError::InvalidRoute { - err: "Route size too large considering onion data".to_owned(), - })?; + let onion_keys = construct_onion_keys(&secp_ctx, &path, session_priv); + let onion_packet = construct_onion_packet(onion_payloads, onion_keys, prng_seed, payment_hash) + .map_err(|_| APIError::InvalidRoute { + err: "Route size too large considering onion data".to_owned(), + })?; Ok((onion_packet, htlc_msat, htlc_cltv)) } @@ -3571,7 +3580,7 @@ mod tests { &logger, &build_trampoline_test_path(), &outer_session_priv, - Some(&trampoline_session_priv), + Some(trampoline_session_priv), error_packet, ); assert_eq!( @@ -3584,19 +3593,15 @@ mod tests { // shared secret cryptography sanity tests let session_priv = get_test_session_key(); let path = build_trampoline_test_path(); + let outer_onion_keys = construct_onion_keys(&Secp256k1::new(), &path, &session_priv); + let trampoline_session_priv = compute_trampoline_session_priv(&session_priv); let trampoline_onion_keys = construct_trampoline_onion_keys( &secp_ctx, &path.blinded_tail.as_ref().unwrap(), - &session_priv, + &trampoline_session_priv, ); - 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) - }; - let htlc_source = HTLCSource::OutboundRoute { path, session_priv,