Include payer nonce in payer metadata again
What changed, and why it matters
This commit changes how BOLT12 invoices are verified in the Lightning Dev Kit. Previously, some invoices could be verified using a nonce stored in the blinded reply path context. Now, the nonce is always included inside the encrypted payer metadata carried by the invoice request or refund. This makes invoices self-contained and prepares the code for future payment proofs. It is a protocol-correctness and forward-compatibility change, not a fix for an active exploit. Old invoice requests/refunds with blinded paths created before this change will fail verification and must be retried with a new payment id.
Treat this as a behavior-affecting protocol update rather than an urgent vulnerability patch. Nodes using BOLT12 offers/refunds with blinded paths should be prepared for failed invoice verification from counter-parties running older code and should retry with a fresh payment id. Review upcoming payment-proof work (#4297) for related security assumptions. No immediate exploit mitigation is required.
Security signals we found
BOLT12 invoice verification now depends only on data inside the invoice request/refund, reducing reliance on external context
Removes a verification path (verify_using_payer_data) that used reply-path context instead of invoice-contained metadata
Breaks backward compatibility for prior-version invoice requests/refunds with blinded paths, causing payment failures
Payer metadata now includes both encrypted payment id and nonce again
Evidence from the diff
The patch reverts an earlier space optimization that removed the payer nonce from encrypted payer metadata, relying instead on the nonce in OffersContext of blinded reply paths. It restores the nonce in Metadata::Derived construction (signer.rs), updates invoice verification to use verify_using_metadata rather than verify_using_payer_data, and removes the now-unneeded Metadata::PayerData variant and Bolt12Invoice::verify_using_payer_data. Refunds without blinded paths were already unaffected. The change is explicitly described as needed for upcoming payment proofs (#4297) that require invoice signing keys derivable from the invoice request alone.
Changed components
lightning/src/offers/flow.rslightning/src/offers/invoice.rslightning/src/offers/invoice_request.rslightning/src/offers/refund.rslightning/src/offers/signer.rsInspect captured patch +47 / −99
diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs
index bdc3475..7362a29 100644
--- a/lightning/src/offers/flow.rs
+++ b/lightning/src/offers/flow.rs
@@ -484,14 +484,14 @@ impl<MR: MessageRouter, L: Logger> OffersMessageFlow<MR, L> {
Ok(InvreqResponseInstructions::SendInvoice(invoice_request))
}
- /// Verifies a [`Bolt12Invoice`] using the provided [`OffersContext`] or the invoice's payer
- /// metadata, returning the corresponding [`PaymentId`] if successful.
+ /// Verifies a [`Bolt12Invoice`] using the invoice's payer metadata, returning the
+ /// corresponding [`PaymentId`] if successful.
///
/// - If an [`OffersContext::OutboundPaymentForOffer`] or
- /// [`OffersContext::OutboundPaymentForRefund`] with a `nonce` is provided, verification is
- /// performed using this to form the payer metadata.
- /// - If no context is provided and the invoice corresponds to a [`Refund`] without blinded paths,
- /// verification is performed using the [`Bolt12Invoice::payer_metadata`].
+ /// [`OffersContext::OutboundPaymentForRefund`] is provided, the extracted [`PaymentId`] must
+ /// also match the context's `payment_id`.
+ /// - If no context is provided, the invoice must correspond to a [`Refund`] without blinded
+ /// paths.
/// - If neither condition is met, verification fails.
pub fn verify_bolt12_invoice(
&self, invoice: &Bolt12Invoice, context: Option<&OffersContext>,
@@ -503,16 +503,20 @@ 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, nonce, .. }) => {
+ Some(&OffersContext::OutboundPaymentForOffer { payment_id, .. }) => {
if invoice.is_for_offer() {
- invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
+ invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| {
+ (extracted == payment_id).then(|| payment_id).ok_or(())
+ })
} else {
Err(())
}
},
- Some(&OffersContext::OutboundPaymentForRefund { payment_id, nonce, .. }) => {
+ Some(&OffersContext::OutboundPaymentForRefund { payment_id, .. }) => {
if invoice.is_for_refund() {
- invoice.verify_using_payer_data(payment_id, nonce, expanded_key, secp_ctx)
+ invoice.verify_using_metadata(expanded_key, secp_ctx).and_then(|extracted| {
+ (extracted == payment_id).then(|| payment_id).ok_or(())
+ })
} else {
Err(())
}
diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs
index fd77595..2a42d0f 100644
--- a/lightning/src/offers/invoice.rs
+++ b/lightning/src/offers/invoice.rs
@@ -133,7 +133,6 @@ use crate::offers::invoice_request::{
use crate::offers::merkle::{
self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream,
};
-use crate::offers::nonce::Nonce;
use crate::offers::offer::{
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream,
OfferTlvStreamRef, Quantity, EXPERIMENTAL_OFFER_TYPES, OFFER_TYPES,
@@ -1008,30 +1007,17 @@ impl Bolt12Invoice {
(&invoice_request.inner.payer.0, INVOICE_REQUEST_IV_BYTES)
},
InvoiceContents::ForRefund { refund, .. } => {
- (&refund.payer.0, REFUND_IV_BYTES_WITH_METADATA)
+ let iv_bytes = if refund.paths().is_empty() {
+ REFUND_IV_BYTES_WITH_METADATA
+ } else {
+ REFUND_IV_BYTES_WITHOUT_METADATA
+ };
+ (&refund.payer.0, iv_bytes)
},
};
self.contents.verify(&self.bytes, metadata, key, iv_bytes, secp_ctx)
}
- /// Verifies that the invoice was for a request or refund created using the given key by
- /// checking a payment id and nonce included with the [`BlindedMessagePath`] for which the invoice was
- /// sent through.
- pub fn verify_using_payer_data<T: secp256k1::Signing>(
- &self, payment_id: PaymentId, nonce: Nonce, key: &ExpandedKey, secp_ctx: &Secp256k1<T>,
- ) -> Result<PaymentId, ()> {
- let metadata = Metadata::payer_data(payment_id, nonce, key);
- let iv_bytes = match &self.contents {
- InvoiceContents::ForOffer { .. } => INVOICE_REQUEST_IV_BYTES,
- InvoiceContents::ForRefund { .. } => REFUND_IV_BYTES_WITHOUT_METADATA,
- };
- self.contents.verify(&self.bytes, &metadata, key, iv_bytes, secp_ctx).and_then(
- |extracted_payment_id| {
- (payment_id == extracted_payment_id).then(|| payment_id).ok_or(())
- },
- )
- }
-
pub(crate) fn as_tlv_stream(&self) -> FullInvoiceTlvStreamRef<'_> {
let (
payer_tlv_stream,
@@ -1892,6 +1878,8 @@ mod tests {
let secp_ctx = Secp256k1::new();
let payment_id = PaymentId([1; 32]);
let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce);
+ let mut payer_metadata = encrypted_payment_id.to_vec();
+ payer_metadata.extend_from_slice(nonce.as_slice());
let payment_paths = payment_paths();
let payment_hash = payment_hash();
@@ -1913,7 +1901,7 @@ mod tests {
unsigned_invoice.write(&mut buffer).unwrap();
assert_eq!(unsigned_invoice.bytes, buffer.as_slice());
- assert_eq!(unsigned_invoice.payer_metadata(), &encrypted_payment_id);
+ assert_eq!(unsigned_invoice.payer_metadata(), payer_metadata.as_slice());
assert_eq!(
unsigned_invoice.offer_chains(),
Some(vec![ChainHash::using_genesis_block(Network::Bitcoin)])
@@ -1957,7 +1945,7 @@ mod tests {
invoice.write(&mut buffer).unwrap();
assert_eq!(invoice.bytes, buffer.as_slice());
- assert_eq!(invoice.payer_metadata(), &encrypted_payment_id);
+ assert_eq!(invoice.payer_metadata(), payer_metadata.as_slice());
assert_eq!(
invoice.offer_chains(),
Some(vec![ChainHash::using_genesis_block(Network::Bitcoin)])
@@ -1975,10 +1963,7 @@ mod tests {
assert_eq!(invoice.amount_msats(), 1000);
assert_eq!(invoice.invoice_request_features(), &InvoiceRequestFeatures::empty());
assert_eq!(invoice.quantity(), None);
- assert_eq!(
- invoice.verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx),
- Ok(payment_id),
- );
+ assert_eq!(invoice.verify_using_metadata(&expanded_key, &secp_ctx), Ok(payment_id));
assert_eq!(invoice.payer_note(), None);
assert_eq!(invoice.payment_paths(), payment_paths.as_slice());
assert_eq!(invoice.created_at(), now);
@@ -2001,7 +1986,7 @@ mod tests {
assert_eq!(
invoice.as_tlv_stream(),
(
- PayerTlvStreamRef { metadata: Some(&encrypted_payment_id.to_vec()) },
+ PayerTlvStreamRef { metadata: Some(&payer_metadata) },
OfferTlvStreamRef {
chains: None,
metadata: None,
diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs
index 2b4379e..07bd151 100644
--- a/lightning/src/offers/invoice_request.rs
+++ b/lightning/src/offers/invoice_request.rs
@@ -1588,6 +1588,8 @@ mod tests {
let secp_ctx = Secp256k1::new();
let payment_id = PaymentId([1; 32]);
let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce);
+ let mut payer_metadata = encrypted_payment_id.to_vec();
+ payer_metadata.extend_from_slice(nonce.as_slice());
let invoice_request = OfferBuilder::new(recipient_pubkey())
.amount_msats(1000)
@@ -1602,7 +1604,7 @@ mod tests {
invoice_request.write(&mut buffer).unwrap();
assert_eq!(invoice_request.bytes, buffer.as_slice());
- assert_eq!(invoice_request.payer_metadata(), &encrypted_payment_id);
+ assert_eq!(invoice_request.payer_metadata(), payer_metadata.as_slice());
assert_eq!(
invoice_request.chains(),
vec![ChainHash::using_genesis_block(Network::Bitcoin)]
@@ -1634,7 +1636,7 @@ mod tests {
assert_eq!(
invoice_request.as_tlv_stream(),
(
- PayerTlvStreamRef { metadata: Some(&encrypted_payment_id.to_vec()) },
+ PayerTlvStreamRef { metadata: Some(&payer_metadata) },
OfferTlvStreamRef {
chains: None,
metadata: None,
@@ -1735,10 +1737,10 @@ mod tests {
.unwrap()
.sign(recipient_sign)
.unwrap();
- assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_ok());
+ match invoice.verify_using_metadata(&expanded_key, &secp_ctx) {
+ Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])),
+ Err(()) => panic!("verification failed"),
+ }
// Fails verification with altered fields
let (
@@ -1774,9 +1776,7 @@ mod tests {
.unwrap();
let invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap();
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_err());
+ assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
// Fails verification with altered payer id
let (
@@ -1812,9 +1812,7 @@ mod tests {
.unwrap();
let invoice = Bolt12Invoice::try_from(encoded_invoice).unwrap();
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_err());
+ assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
}
#[test]
diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs
index c0fd9df..85ea3b6 100644
--- a/lightning/src/offers/refund.rs
+++ b/lightning/src/offers/refund.rs
@@ -210,15 +210,12 @@ macro_rules! refund_builder_methods { (
///
/// Also, sets the metadata when [`RefundBuilder::build`] is called such that it can be used by
/// [`Bolt12Invoice::verify_using_metadata`] to determine if the invoice was produced for the
- /// refund given an [`ExpandedKey`]. However, if [`RefundBuilder::path`] is called, then the
- /// metadata must be included in each [`BlindedMessagePath`] instead. In this case, use
- /// [`Bolt12Invoice::verify_using_payer_data`].
+ /// refund given an [`ExpandedKey`].
///
/// The `payment_id` is encrypted in the metadata and should be unique. This ensures that only
/// one invoice will be paid for the refund and that payments can be uniquely identified.
///
/// [`Bolt12Invoice::verify_using_metadata`]: crate::offers::invoice::Bolt12Invoice::verify_using_metadata
- /// [`Bolt12Invoice::verify_using_payer_data`]: crate::offers::invoice::Bolt12Invoice::verify_using_payer_data
/// [`ExpandedKey`]: crate::ln::inbound_payment::ExpandedKey
pub fn deriving_signing_pubkey(
node_id: PublicKey, expanded_key: &ExpandedKey, nonce: Nonce,
@@ -329,6 +326,8 @@ macro_rules! refund_builder_methods { (
if $self.refund.payer.0.has_derivation_material() {
let mut metadata = core::mem::take(&mut $self.refund.payer.0);
+ // Don't derive keys if no blinded paths were given since this means the payer id must
+ // be a public node id.
let iv_bytes = if $self.refund.paths.is_none() {
metadata = metadata.without_keys();
IV_BYTES_WITH_METADATA
@@ -1167,9 +1166,6 @@ mod tests {
Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])),
Err(()) => panic!("verification failed"),
}
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_err());
let mut tlv_stream = refund.as_tlv_stream();
tlv_stream.2.amount = Some(2000);
@@ -1248,10 +1244,10 @@ mod tests {
.unwrap()
.sign(recipient_sign)
.unwrap();
- assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_ok());
+ match invoice.verify_using_metadata(&expanded_key, &secp_ctx) {
+ Ok(payment_id) => assert_eq!(payment_id, PaymentId([1; 32])),
+ Err(()) => panic!("verification failed"),
+ }
// Fails verification with altered fields
let mut tlv_stream = refund.as_tlv_stream();
@@ -1268,9 +1264,7 @@ mod tests {
.unwrap()
.sign(recipient_sign)
.unwrap();
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_err());
+ assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
// Fails verification with altered payer_id
let mut tlv_stream = refund.as_tlv_stream();
@@ -1288,9 +1282,7 @@ mod tests {
.unwrap()
.sign(recipient_sign)
.unwrap();
- assert!(invoice
- .verify_using_payer_data(payment_id, nonce, &expanded_key, &secp_ctx)
- .is_err());
+ assert!(invoice.verify_using_metadata(&expanded_key, &secp_ctx).is_err());
}
#[test]
diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs
index e51a120..43d1370 100644
--- a/lightning/src/offers/signer.rs
+++ b/lightning/src/offers/signer.rs
@@ -63,11 +63,6 @@ pub(super) enum Metadata {
/// This variant should only be used at verification time, never when building.
RecipientData(Nonce),
- /// Metadata for deriving keys included as payer data in a blinded path.
- ///
- /// This variant should only be used at verification time, never when building.
- PayerData([u8; PaymentId::LENGTH + Nonce::LENGTH]),
-
/// Metadata to be derived from message contents and given material.
///
/// This variant should only be used at building time.
@@ -80,16 +75,6 @@ pub(super) enum Metadata {
}
impl Metadata {
- pub fn payer_data(payment_id: PaymentId, nonce: Nonce, expanded_key: &ExpandedKey) -> Self {
- let encrypted_payment_id = expanded_key.crypt_for_offer(payment_id.0, nonce);
-
- let mut bytes = [0u8; PaymentId::LENGTH + Nonce::LENGTH];
- bytes[..PaymentId::LENGTH].copy_from_slice(encrypted_payment_id.as_slice());
- bytes[PaymentId::LENGTH..].copy_from_slice(nonce.as_slice());
-
- Metadata::PayerData(bytes)
- }
-
pub fn as_bytes(&self) -> Option<&Vec<u8>> {
match self {
Metadata::Bytes(bytes) => Some(bytes),
@@ -107,10 +92,6 @@ impl Metadata {
debug_assert!(false);
false
},
- Metadata::PayerData(_) => {
- debug_assert!(false);
- false
- },
Metadata::Derived(_) => true,
Metadata::DerivedSigningPubkey(_) => true,
}
@@ -125,7 +106,6 @@ impl Metadata {
// Nonce::LENGTH had been set explicitly.
Metadata::Bytes(bytes) => bytes.len() == PaymentId::LENGTH + Nonce::LENGTH,
Metadata::RecipientData(_) => false,
- Metadata::PayerData(_) => true,
Metadata::Derived(_) => false,
Metadata::DerivedSigningPubkey(_) => true,
}
@@ -140,7 +120,6 @@ impl Metadata {
// been set explicitly.
Metadata::Bytes(bytes) => bytes.len() == Nonce::LENGTH,
Metadata::RecipientData(_) => true,
- Metadata::PayerData(_) => false,
Metadata::Derived(_) => false,
Metadata::DerivedSigningPubkey(_) => true,
}
@@ -158,10 +137,6 @@ impl Metadata {
debug_assert!(false);
self
},
- Metadata::PayerData(_) => {
- debug_assert!(false);
- self
- },
Metadata::Derived(_) => self,
Metadata::DerivedSigningPubkey(material) => Metadata::Derived(material),
}
@@ -176,10 +151,6 @@ impl Metadata {
debug_assert!(false);
(self, None)
},
- Metadata::PayerData(_) => {
- debug_assert!(false);
- (self, None)
- },
Metadata::Derived(metadata_material) => {
(Metadata::Bytes(metadata_material.derive_metadata(iv_bytes, tlv_stream)), None)
},
@@ -204,7 +175,6 @@ impl AsRef<[u8]> for Metadata {
match self {
Metadata::Bytes(bytes) => &bytes,
Metadata::RecipientData(nonce) => &nonce.0,
- Metadata::PayerData(bytes) => bytes.as_slice(),
Metadata::Derived(_) => {
debug_assert!(false);
&[]
@@ -222,7 +192,6 @@ impl fmt::Debug for Metadata {
match self {
Metadata::Bytes(bytes) => bytes.fmt(f),
Metadata::RecipientData(Nonce(bytes)) => bytes.fmt(f),
- Metadata::PayerData(bytes) => bytes.fmt(f),
Metadata::Derived(_) => f.write_str("Derived"),
Metadata::DerivedSigningPubkey(_) => f.write_str("DerivedSigningPubkey"),
}
@@ -241,7 +210,6 @@ impl PartialEq for Metadata {
}
},
Metadata::RecipientData(_) => false,
- Metadata::PayerData(_) => false,
Metadata::Derived(_) => false,
Metadata::DerivedSigningPubkey(_) => false,
}
@@ -290,7 +258,8 @@ impl MetadataMaterial {
self.hmac.input(DERIVED_METADATA_AND_KEYS_HMAC_INPUT);
self.maybe_include_encrypted_payment_id();
- let bytes = self.encrypted_payment_id.map(|id| id.to_vec()).unwrap_or_default();
+ let mut bytes = self.encrypted_payment_id.map(|id| id.to_vec()).unwrap_or_default();
+ bytes.extend_from_slice(self.nonce.as_slice());
let hmac = Hmac::from_engine(self.hmac);
let privkey = SecretKey::from_slice(hmac.as_byte_array()).unwrap();
Why this scored 33/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.