Correct blinded path forwarding CLTV expiry check
What changed, and why it matters
This commit fixes a bug in how Lightning Dev Kit checks expiry times for payments sent through 'blinded paths' (a privacy feature that hides the final recipient). The code was accidentally checking the outgoing expiry time instead of the incoming one. Because of this, a payment could travel deeper into the blinded path than intended before being rejected, slightly weakening the privacy/cost protection that blinded-path expiry is meant to provide. The commit explicitly states this does not risk loss of funds.
Upgrade to a rust-lightning release containing this commit. No immediate emergency response is warranted because the commit message states funds are not at risk and the issue is a privacy/cost-optimization concern for blinded-path recipients.
Security signals we found
CLTV expiry check using wrong variable (outgoing vs inbound)
Blinded path privacy/cost-protection bypass
Test updated to reflect intended introduction-node rejection behavior
Reporter credited from Block's Security Team
Evidence from the diff
In onion_payment.rs, check_blinded_forward now calls check_blinded_payment_constraints with inbound_cltv_expiry instead of outgoing_cltv_value. PaymentConstraints::max_cltv_expiry is intended to make an entire blinded path expire atomically at the introduction node, but the previous code allowed the HTLC to proceed until the destination failed it. The related async_payments_tests.rs test is updated so the path is now rejected at the introduction node with InvalidOnion rather than reaching the destination and logging ‘violated blinded payment constraints’.
Changed components
lightning/src/ln/onion_payment.rslightning/src/ln/async_payments_tests.rsInspect captured patch +14 / −15
diff --git a/lightning/src/ln/async_payments_tests.rs b/lightning/src/ln/async_payments_tests.rs
index 60632b1..bd07d13 100644
--- a/lightning/src/ln/async_payments_tests.rs
+++ b/lightning/src/ln/async_payments_tests.rs
@@ -1886,8 +1886,9 @@ fn expired_static_invoice_payment_path() {
}
};
- // Mine a bunch of blocks so the hardcoded path's `max_cltv_expiry` is expired at the recipient's
- // end by the time the payment arrives.
+ // Mine a bunch of blocks on the sender so the hardcoded path's `max_cltv_expiry` is expired.
+ // Note that the path expires "all at once" and will be invalid at the intro point so will be
+ // rejected before it reaches the destination.
let min_cltv_expiry_delta = test_default_channel_config().channel_config.cltv_expiry_delta;
connect_blocks(
&nodes[0],
@@ -1902,7 +1903,6 @@ fn expired_static_invoice_payment_path() {
&nodes[1],
final_max_cltv_expiry
- nodes[1].best_block_info().1
- // Don't expire the path for nodes[1]
- min_cltv_expiry_delta as u32
- HTLC_FAIL_BACK_BUFFER
- LATENCY_GRACE_PERIOD_BLOCKS
@@ -1939,18 +1939,17 @@ fn expired_static_invoice_payment_path() {
let payment_hash = extract_payment_hash(&ev);
check_added_monitors(&nodes[0], 1);
- let route: &[&[&Node]] = &[&[&nodes[1], &nodes[2]]];
- let args = PassAlongPathArgs::new(&nodes[0], route[0], amt_msat, payment_hash, ev)
- .without_claimable_event()
- .expect_failure(HTLCHandlingFailureType::Receive { payment_hash })
- .with_dummy_tlvs(&[DummyTlvs::default(); DEFAULT_PAYMENT_DUMMY_HOPS]);
- do_pass_along_path(args);
- fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1], &nodes[2]], false);
- nodes[2].logger.assert_log_contains(
- "lightning::ln::channelmanager",
- "violated blinded payment constraints",
- 1,
+ let payment_event = SendEvent::from_event(ev);
+ nodes[1].node.handle_update_add_htlc(nodes[0].node.get_our_node_id(), &payment_event.msgs[0]);
+ check_added_monitors(&nodes[1], 0);
+ do_commitment_signed_dance(&nodes[1], &nodes[0], &payment_event.commitment_msg, false, true);
+ expect_and_process_pending_htlcs(&nodes[1], false);
+ expect_htlc_handling_failed_destinations!(
+ nodes[1].node.get_and_clear_pending_events(),
+ &[HTLCHandlingFailureType::InvalidOnion]
);
+ check_added_monitors(&nodes[1], 1);
+ fail_blinded_htlc_backwards(payment_hash, 1, &[&nodes[0], &nodes[1]], false);
}
#[cfg_attr(feature = "std", ignore)]
diff --git a/lightning/src/ln/onion_payment.rs b/lightning/src/ln/onion_payment.rs
index 5111f69..bd06bfc 100644
--- a/lightning/src/ln/onion_payment.rs
+++ b/lightning/src/ln/onion_payment.rs
@@ -66,7 +66,7 @@ fn check_blinded_forward(
let outgoing_cltv_value = inbound_cltv_expiry.checked_sub(
payment_relay.cltv_expiry_delta as u32
).ok_or(())?;
- check_blinded_payment_constraints(inbound_amt_msat, outgoing_cltv_value, payment_constraints)?;
+ check_blinded_payment_constraints(inbound_amt_msat, inbound_cltv_expiry, payment_constraints)?;
if features.requires_unknown_bits_from(&BlindedHopFeatures::empty()) { return Err(()) }
Ok((amt_to_forward, outgoing_cltv_value))
Why this scored 43/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.