Remove nonce from outbound payment OffersContexts
What changed, and why it matters
This commit removes an old cryptographic nonce from the data carried inside Lightning "blinded paths" used when sending BOLT 12 offers and refunds. The nonce is no longer needed because a newer "payer metadata" field already carries the same secret. The commit keeps the payment ID in the blinded path and uses it to make sure an incoming invoice really belongs to the payment it claims to belong to. That prevents an attacker who captures one blinded path from delivering a different payment's invoice over it, which could otherwise link two of the user's payments together. The change is mostly a cleanup, but it also tightens the matching logic slightly.
Review the downgrade path: ensure that a node running the new code, then downgraded to an older version that still requires the nonce, can read the persisted `Option<Nonce>` and retry/verify correctly. Also verify that `Bolt12Invoice::verify_using_metadata` robustly extracts the payment_id and that no code path still expects the removed nonce field. No urgent action is required; this is a defensive cleanup with a documented anti-correlation check.
Security signals we found
Removes a redundant nonce from blinded-path context, relying on payer metadata for invoice authentication
Retains and enforces payment_id matching to prevent cross-payment invoice delivery over captured blinded paths
Maintains backward-compatible persistence of the nonce for downgrade/retry scenarios
Updates serialization format for OffersContext and RetryableInvoiceRequest
Adds explicit security rationale in doc comments about preventing correlation of payments by an attacker holding a blinded path
Evidence from the diff
The patch removes the nonce field from OffersContext::OutboundPaymentForOffer and OffersContext::OutboundPaymentForRefund, and drops the nonce parameter from enqueue_invoice_request. Verification of a received Bolt12Invoice now relies on Bolt12Invoice::verify_using_metadata using payer metadata, which already embeds the payer nonce. The payment_id is retained in both variants and is compared against the payment ID recovered from the invoice’s payer metadata to confirm the invoice arrived over the correct reply/blinded path. RetryableInvoiceRequest.nonce is changed to Option<Nonce> and persisted for backward compatibility/downgrade scenarios, but is no longer used in the current version. Serialization is updated so the nonce is optional for RetryableInvoiceRequest and removed from the two OffersContext variants.
Changed components
lightning/src/blinded_path/message.rslightning/src/ln/channelmanager.rslightning/src/ln/outbound_payment.rslightning/src/offers/flow.rsInspect captured patch +33 / −35
diff --git a/lightning/src/blinded_path/message.rs b/lightning/src/blinded_path/message.rs
index 417c663..85cb76b 100644
--- a/lightning/src/blinded_path/message.rs
+++ b/lightning/src/blinded_path/message.rs
@@ -466,15 +466,17 @@ pub enum OffersContext {
OutboundPaymentForRefund {
/// Payment ID used when creating a [`Refund`].
///
- /// [`Refund`]: crate::offers::refund::Refund
- payment_id: PaymentId,
-
- /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid [`Refund`] and
- /// for deriving its signing keys.
+ /// When a [`Bolt12Invoice`] is received, the payment id recovered from its payer metadata
+ /// must equal this one, confirming the invoice arrived over the blinded path included in the
+ /// refund for this payment. Without that check, an attacker holding that path could deliver
+ /// a different payment's invoice over it, and our paying it would reveal that both payments
+ /// came from us. That the invoice is for a refund we created is verified by
+ /// [`Bolt12Invoice::verify_using_metadata`] using its payer metadata.
///
- /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
/// [`Refund`]: crate::offers::refund::Refund
- nonce: Nonce,
+ /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
+ /// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata
+ payment_id: PaymentId,
},
/// Context used by a [`BlindedMessagePath`] as a reply path for an [`InvoiceRequest`].
///
@@ -487,15 +489,17 @@ pub enum OffersContext {
OutboundPaymentForOffer {
/// Payment ID used when creating an [`InvoiceRequest`].
///
- /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
- payment_id: PaymentId,
-
- /// A nonce used for authenticating that a [`Bolt12Invoice`] is for a valid
- /// [`InvoiceRequest`] and for deriving its signing keys.
+ /// When a [`Bolt12Invoice`] is received, the payment id recovered from its payer metadata
+ /// must equal this one, confirming the invoice arrived over the reply path created for this
+ /// payment. Without that check, an attacker holding this reply path could deliver a
+ /// different payment's invoice over it, and our paying it would reveal that both payments
+ /// came from us. That the invoice is for an invoice request we created is verified by
+ /// [`Bolt12Invoice::verify_using_metadata`] using its payer metadata.
///
- /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
/// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
- nonce: Nonce,
+ /// [`Bolt12Invoice`]: crate::offers::invoice::Bolt12Invoice
+ /// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata
+ payment_id: PaymentId,
},
/// Context used by a [`BlindedMessagePath`] as a reply path for a [`Bolt12Invoice`].
///
@@ -678,7 +682,6 @@ impl_ser_tlv_based_enum!(OffersContext,
},
(1, OutboundPaymentForRefund) => {
(0, payment_id, required),
- (1, nonce, required),
},
(2, InboundPayment) => {
(0, payment_hash, required),
@@ -690,7 +693,6 @@ impl_ser_tlv_based_enum!(OffersContext,
},
(4, OutboundPaymentForOffer) => {
(0, payment_id, required),
- (1, nonce, required),
},
);
diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs
index 6398613..ff4c0f8 100644
--- a/lightning/src/ln/channelmanager.rs
+++ b/lightning/src/ln/channelmanager.rs
@@ -15075,13 +15075,13 @@ impl<
let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self);
self.flow.enqueue_invoice_request(
- invoice_request.clone(), payment_id, nonce,
+ invoice_request.clone(), payment_id,
self.get_peers_for_blinded_path()
)?;
let retryable_invoice_request = RetryableInvoiceRequest {
invoice_request: invoice_request.clone(),
- nonce,
+ nonce: Some(nonce),
needs_retry: true,
};
@@ -17231,11 +17231,11 @@ impl<
for (payment_id, retryable_invoice_request) in
self.pending_outbound_payments.release_invoice_requests_awaiting_invoice()
{
- let RetryableInvoiceRequest { invoice_request, nonce, .. } = retryable_invoice_request;
+ let RetryableInvoiceRequest { invoice_request, .. } = retryable_invoice_request;
let peers = self.get_peers_for_blinded_path();
let enqueue_invreq_res =
- self.flow.enqueue_invoice_request(invoice_request, payment_id, nonce, peers);
+ self.flow.enqueue_invoice_request(invoice_request, payment_id, peers);
if enqueue_invreq_res.is_err() {
log_warn!(
self.logger,
diff --git a/lightning/src/ln/outbound_payment.rs b/lightning/src/ln/outbound_payment.rs
index 04e8003..22fdc47 100644
--- a/lightning/src/ln/outbound_payment.rs
+++ b/lightning/src/ln/outbound_payment.rs
@@ -173,14 +173,18 @@ pub(crate) enum PendingOutboundPayment {
#[derive(Clone)]
pub(crate) struct RetryableInvoiceRequest {
pub(crate) invoice_request: InvoiceRequest,
- pub(crate) nonce: Nonce,
+ // No longer used, but written so that the payment can be retried after downgrading to a
+ // version that verifies invoices using the nonce instead of the payer metadata. Set when
+ // creating an invoice request and otherwise retains the value read from disk, which may have
+ // been written by such a version.
+ pub(crate) nonce: Option<Nonce>,
pub(super) needs_retry: bool,
}
impl_ser_tlv_based!(RetryableInvoiceRequest, {
(0, invoice_request, required),
(1, needs_retry, (default_value, true)),
- (2, nonce, required),
+ (2, nonce, option),
});
impl PendingOutboundPayment {
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index 7362a29..ade684e 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -503,7 +503,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
None if invoice.is_for_refund_without_paths() => {
invoice.verify_using_metadata(expanded_key, secp_ctx)
},
- Some(&OffersContext::OutboundPaymentForOffer { payment_id, .. }) => {
+ Some(&OffersContext::OutboundPaymentForOffer { payment_id }) => {
if invoice.is_for_offer() {
invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| {
(extracted == payment_id).then(|| payment_id).ok_or(())
@@ -512,7 +512,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
Err(())
}
},
- Some(&OffersContext::OutboundPaymentForRefund { payment_id, .. }) => {
+ Some(&OffersContext::OutboundPaymentForRefund { payment_id }) => {
if invoice.is_for_refund() {
invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| {
(extracted == payment_id).then(|| payment_id).ok_or(())
@@ -693,7 +693,7 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
let nonce = Nonce::from_entropy_source(entropy);
let context =
- MessageContext::Offers(OffersContext::OutboundPaymentForRefund { payment_id, nonce });
+ MessageContext::Offers(OffersContext::OutboundPaymentForRefund { payment_id });
// Create the base builder with common properties
let mut builder = RefundBuilder::deriving_signing_pubkey(
@@ -1089,13 +1089,6 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
/// over those blinded paths, which can be verified against the intended outbound payment,
/// ensuring the invoice corresponds to a payment we actually want to make.
///
- /// # Nonce
- /// The nonce is used to create a unique [`MessageContext`] for the reply paths.
- /// These will be used to verify the corresponding [`Bolt12Invoice`] when it is received.
- ///
- /// Note: The provided [`Nonce`] MUST be the same as the [`Nonce`] used for creating the
- /// [`InvoiceRequest`] to ensure correct verification of the corresponding [`Bolt12Invoice`].
- ///
/// See [`OffersMessageFlow::create_invoice_request_builder`] for more details.
///
/// # Peers
@@ -1107,11 +1100,10 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
/// [`InvoiceError`]: crate::offers::invoice_error::InvoiceError
/// [`supports_onion_messages`]: crate::types::features::Features::supports_onion_messages
pub fn enqueue_invoice_request(
- &self, invoice_request: InvoiceRequest, payment_id: PaymentId, nonce: Nonce,
+ &self, invoice_request: InvoiceRequest, payment_id: PaymentId,
peers: Vec<MessageForwardNode>,
) -> Result<(), Bolt12SemanticError> {
- let context =
- MessageContext::Offers(OffersContext::OutboundPaymentForOffer { payment_id, nonce });
+ let context = MessageContext::Offers(OffersContext::OutboundPaymentForOffer { payment_id });
let reply_paths = self
.create_blinded_paths(peers, context)
.map_err(|_| Bolt12SemanticError::MissingPaths)?;
Why this scored 46/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.