Add test for dummy hop insertion
What changed, and why it matters
This commit only adds new tests and updates an existing test helper. It does not change production code behavior. The tests verify that dummy hops can be inserted into blinded paths and that padded versus compact path formats behave as expected. There is no security fix or vulnerability patch here.
No security action required. Review as normal test-only commit if desired.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The diff adds test coverage for BlindedMessagePath::new_with_dummy_hops in onion_message/functional_tests.rs, adds an end-to-end Offers test in offers_tests.rs comparing compact (SCID-based) and node-ID-based blinded paths, and tightens the is_padded helper in blinded_path/utils.rs so it rejects paths where the first hop is too small and requires non-final hops to share the same encrypted payload length. No production logic is modified; the changes are purely test and test-utility code.
Changed components
lightning/src/blinded_path/utils.rslightning/src/ln/offers_tests.rslightning/src/onion_message/functional_tests.rsInspect captured patch +143 / −13
diff --git a/lightning/src/blinded_path/utils.rs b/lightning/src/blinded_path/utils.rs
index 976c821..3956fd9 100644
--- a/lightning/src/blinded_path/utils.rs
+++ b/lightning/src/blinded_path/utils.rs
@@ -276,16 +276,43 @@ impl<T: Writeable> Writeable for BlindedPathWithPadding<T> {
}
#[cfg(test)]
-/// Checks if all the packets in the blinded path are properly padded.
+/// Verifies whether all hops in the blinded path follow the expected padding scheme.
+///
+/// In the padded encoding scheme, each hop's encrypted payload is expected to be of the form:
+/// `n * padding_round_off + extra`, where:
+/// - `padding_round_off` is the fixed block size to which unencrypted payloads are padded.
+/// - `n` is a positive integer (n ≥ 1).
+/// - `extra` is the fixed overhead added during encryption (assumed uniform across hops).
+///
+/// This function infers the `extra` from the first hop, and checks that all other hops conform
+/// to the same pattern.
+///
+/// # Returns
+/// - `true` if all hop payloads are padded correctly.
+/// - `false` if padding is incorrectly applied or intentionally absent (e.g., in compact paths).
pub fn is_padded(hops: &[BlindedHop], padding_round_off: usize) -> bool {
let first_hop = hops.first().expect("BlindedPath must have at least one hop");
- let first_payload_size = first_hop.encrypted_payload.len();
-
- // The unencrypted payload data is padded before getting encrypted.
- // Assuming the first payload is padded properly, get the extra data length.
- let extra_length = first_payload_size % padding_round_off;
- hops.iter().all(|hop| {
- // Check that every packet is padded to the round off length subtracting the extra length.
- (hop.encrypted_payload.len() - extra_length) % padding_round_off == 0
- })
+ let first_len = first_hop.encrypted_payload.len();
+
+ // Early rejection: if the first hop is too small, it can't be correctly padded.
+ if first_len <= padding_round_off {
+ return false;
+ }
+
+ let extra = first_len % padding_round_off;
+
+ // Helper to check if a hop follows the padding pattern
+ let is_hop_padded = |hop: &BlindedHop| {
+ let len = hop.encrypted_payload.len();
+ len > extra && (len - extra) % padding_round_off == 0
+ };
+
+ // All hops must follow the same padding structure AND
+ // all hops except the final one must have the same length as the first
+ // to ensure proper masking.
+ hops.iter().all(is_hop_padded)
+ && hops
+ .iter()
+ .take(hops.len().saturating_sub(1))
+ .all(|hop| hop.encrypted_payload.len() == first_len)
}
diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs
index c2971b3..e2bdfc4 100644
--- a/lightning/src/ln/offers_tests.rs
+++ b/lightning/src/ln/offers_tests.rs
@@ -60,7 +60,7 @@ use crate::offers::invoice_error::InvoiceError;
use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields};
use crate::offers::nonce::Nonce;
use crate::offers::parse::Bolt12SemanticError;
-use crate::onion_message::messenger::{Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion};
+use crate::onion_message::messenger::{DefaultMessageRouter, Destination, MessageSendInstructions, NodeIdMessageRouter, NullMessageRouter, PeeledOnion, PADDED_PATH_LENGTH};
use crate::onion_message::offers::OffersMessage;
use crate::routing::gossip::{NodeAlias, NodeId};
use crate::routing::router::{PaymentParameters, RouteParameters, RouteParametersConfig};
@@ -435,6 +435,76 @@ fn prefers_more_connected_nodes_in_blinded_paths() {
}
}
+/// Tests the dummy hop behavior of Offers based on the message router used:
+/// - Compact paths (`DefaultMessageRouter`) should not include dummy hops.
+/// - Node ID paths (`NodeIdMessageRouter`) may include 0 to [`MAX_DUMMY_HOPS_COUNT`] dummy hops.
+///
+/// Also verifies that the resulting paths are functional: the counterparty can respond with a valid `invoice_request`.
+#[test]
+fn check_dummy_hop_pattern_in_offer() {
+ let chanmon_cfgs = create_chanmon_cfgs(2);
+ let node_cfgs = create_node_cfgs(2, &chanmon_cfgs);
+ let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]);
+ let nodes = create_network(2, &node_cfgs, &node_chanmgrs);
+
+ create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000);
+
+ let alice = &nodes[0];
+ let alice_id = alice.node.get_our_node_id();
+ let bob = &nodes[1];
+ let bob_id = bob.node.get_our_node_id();
+
+ // Case 1: DefaultMessageRouter → uses compact blinded paths (via SCIDs)
+ // Expected: No dummy hops; each path contains only the recipient.
+ let default_router = DefaultMessageRouter::new(alice.network_graph, alice.keys_manager);
+
+ let compact_offer = alice.node
+ .create_offer_builder_using_router(&default_router).unwrap()
+ .amount_msats(10_000_000)
+ .build().unwrap();
+
+ assert!(!compact_offer.paths().is_empty());
+
+ for path in compact_offer.paths() {
+ assert_eq!(
+ path.blinded_hops().len(), 1,
+ "Compact paths must include only the recipient"
+ );
+ }
+
+ let payment_id = PaymentId([1; 32]);
+ bob.node.pay_for_offer(&compact_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap();
+
+ let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
+ let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
+
+ assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
+ assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
+ assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id));
+
+ // Case 2: NodeIdMessageRouter → uses node ID-based blinded paths
+ // Expected: 0 to MAX_DUMMY_HOPS_COUNT dummy hops, followed by recipient.
+ let node_id_router = NodeIdMessageRouter::new(alice.network_graph, alice.keys_manager);
+
+ let padded_offer = alice.node
+ .create_offer_builder_using_router(&node_id_router).unwrap()
+ .amount_msats(10_000_000)
+ .build().unwrap();
+
+ assert!(!padded_offer.paths().is_empty());
+ assert!(padded_offer.paths().iter().all(|path| path.blinded_hops().len() == PADDED_PATH_LENGTH));
+
+ let payment_id = PaymentId([2; 32]);
+ bob.node.pay_for_offer(&padded_offer, None, None, None, payment_id, Retry::Attempts(0), RouteParametersConfig::default()).unwrap();
+
+ let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap();
+ let (invoice_request, reply_path) = extract_invoice_request(alice, &onion_message);
+
+ assert_eq!(invoice_request.amount_msats(), Some(10_000_000));
+ assert_ne!(invoice_request.payer_signing_pubkey(), bob_id);
+ assert!(check_compact_path_introduction_node(&reply_path, alice, bob_id));
+}
+
/// Checks that blinded paths are compact for short-lived offers.
#[test]
fn creates_short_lived_offer() {
diff --git a/lightning/src/onion_message/functional_tests.rs b/lightning/src/onion_message/functional_tests.rs
index 3cbb618..4bec3dc 100644
--- a/lightning/src/onion_message/functional_tests.rs
+++ b/lightning/src/onion_message/functional_tests.rs
@@ -145,6 +145,9 @@ const CUSTOM_PONG_MESSAGE_TYPE: u64 = 4343;
const CUSTOM_PING_MESSAGE_CONTENTS: [u8; 32] = [42; 32];
const CUSTOM_PONG_MESSAGE_CONTENTS: [u8; 32] = [43; 32];
+/// A dummy hop count for testing purposes.
+const TEST_DUMMY_HOP_COUNT: usize = 5;
+
impl OnionMessageContents for TestCustomMessage {
fn tlv_type(&self) -> u64 {
match self {
@@ -443,6 +446,34 @@ fn one_blinded_hop() {
pass_along_path(&nodes);
}
+#[test]
+fn blinded_path_with_dummy_hops() {
+ let nodes = create_nodes(2);
+ let test_msg = TestCustomMessage::Pong;
+
+ let secp_ctx = Secp256k1::new();
+ let context = MessageContext::Custom(Vec::new());
+ let entropy = &*nodes[1].entropy_source;
+ let receive_key = nodes[1].messenger.node_signer.get_receive_auth_key();
+ let blinded_path = BlindedMessagePath::new_with_dummy_hops(
+ &[],
+ nodes[1].node_id,
+ TEST_DUMMY_HOP_COUNT,
+ receive_key,
+ context,
+ entropy,
+ &secp_ctx,
+ )
+ .unwrap();
+ // Ensure that dummy hops are added to the blinded path.
+ assert_eq!(blinded_path.blinded_hops().len(), 6);
+ let destination = Destination::BlindedPath(blinded_path);
+ let instructions = MessageSendInstructions::WithoutReplyPath { destination };
+ nodes[0].messenger.send_onion_message(test_msg, instructions).unwrap();
+ nodes[1].custom_message_handler.expect_message(TestCustomMessage::Pong);
+ pass_along_path(&nodes);
+}
+
#[test]
fn two_unblinded_two_blinded() {
let nodes = create_nodes(5);
@@ -658,9 +689,10 @@ fn test_blinded_path_padding_for_full_length_path() {
let context = MessageContext::Custom(vec![0u8; 42]);
let entropy = &*nodes[3].entropy_source;
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
- let blinded_path = BlindedMessagePath::new(
+ let blinded_path = BlindedMessagePath::new_with_dummy_hops(
&intermediate_nodes,
nodes[3].node_id,
+ TEST_DUMMY_HOP_COUNT,
receive_key,
context,
entropy,
@@ -694,9 +726,10 @@ fn test_blinded_path_no_padding_for_compact_path() {
let context = MessageContext::Custom(vec![0u8; 42]);
let entropy = &*nodes[3].entropy_source;
let receive_key = nodes[3].messenger.node_signer.get_receive_auth_key();
- let blinded_path = BlindedMessagePath::new(
+ let blinded_path = BlindedMessagePath::new_with_dummy_hops(
&intermediate_nodes,
nodes[3].node_id,
+ TEST_DUMMY_HOP_COUNT,
receive_key,
context,
entropy,
Why this scored 13/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.