refactor(offers): extract payer key derivation helpers
What changed, and why it matters
This commit is a code cleanup (refactor) in the Lightning Dev Kit library. It moves existing payer key-derivation logic into shared helper functions so that future 'payer proof' features can reuse the same code. The change does not appear to fix a security bug; it reorganizes existing logic and adds a new public method to re-derive a payer's signing keys from invoice data. No vulnerability or exploit is described in the commit itself.
No immediate security action required. Treat as a normal refactor review: verify that the extracted helpers preserve the original TLV-stream filtering behavior (especially the `exclude_payer_id` flag) and that the new public API is appropriately documented and tested.
Security signals we found
Refactor only: moves existing key derivation/verification logic into helpers without changing algorithms
Adds new public API `Bolt12Invoice::derive_payer_signing_keys` for payer proof key recovery
No mention of vulnerability, bug, CVE, security fix, or exploit in commit title/message
No changes to cryptographic primitives, constants, or trust boundaries visible in the diff
Evidence from the diff
The patch extracts duplicated payer metadata/TLV-stream handling from Bolt12 invoice verification into reusable helpers: InvoiceContents::payer_tlv_stream, signer::derive_payer_keys, and a shared verify_payer_metadata_inner. It adds Bolt12Invoice::derive_payer_signing_keys, which lets a payer recover the keypair used to sign an invoice request, using the encrypted payment id and nonce embedded in the invoice’s payer metadata. The cryptographic derivation and verification paths remain the same; only the code structure and API surface change.
Changed components
lightning/src/offers/invoice.rslightning/src/offers/signer.rsInspect captured patch +120 / −21
diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs
index 2a42d0f..cf0aa22 100644
--- a/lightning/src/offers/invoice.rs
+++ b/lightning/src/offers/invoice.rs
@@ -131,7 +131,8 @@ use crate::offers::invoice_request::{
IV_BYTES as INVOICE_REQUEST_IV_BYTES,
};
use crate::offers::merkle::{
- self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream,
+ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvRecord,
+ TlvStream,
};
use crate::offers::offer::{
Amount, ExperimentalOfferTlvStream, ExperimentalOfferTlvStreamRef, OfferId, OfferTlvStream,
@@ -1018,6 +1019,34 @@ impl Bolt12Invoice {
self.contents.verify(&self.bytes, metadata, key, iv_bytes, secp_ctx)
}
+ /// Re-derives the payer's signing keypair for payer proof creation.
+ ///
+ /// This performs the same key derivation that occurs during invoice request creation
+ /// with `deriving_signing_pubkey`, allowing the payer to recover their signing keypair.
+ ///
+ /// The keypair is derived from the invoice's own payer metadata (which embeds the payer
+ /// [`Nonce`]), so no externally-held nonce or payment id is required. In the common
+ /// proof-of-payment flow, callers can use `PaidBolt12Invoice::prove_payer_derived`.
+ ///
+ /// [`Nonce`]: crate::offers::nonce::Nonce
+ pub fn derive_payer_signing_keys<T: secp256k1::Signing>(
+ &self, key: &ExpandedKey, secp_ctx: &Secp256k1<T>,
+ ) -> Result<Keypair, ()> {
+ // Mirror `verify_using_metadata`'s IV selection so the derived HMAC matches the one
+ // committed to in the payer metadata.
+ let iv_bytes = match &self.contents {
+ InvoiceContents::ForOffer { .. } => INVOICE_REQUEST_IV_BYTES,
+ InvoiceContents::ForRefund { refund, .. } => {
+ if refund.paths().is_empty() {
+ REFUND_IV_BYTES_WITH_METADATA
+ } else {
+ REFUND_IV_BYTES_WITHOUT_METADATA
+ }
+ },
+ };
+ self.contents.derive_payer_signing_keys(&self.bytes, key, iv_bytes, secp_ctx)
+ }
+
pub(crate) fn as_tlv_stream(&self) -> FullInvoiceTlvStreamRef<'_> {
let (
payer_tlv_stream,
@@ -1303,20 +1332,8 @@ impl InvoiceContents {
&self, bytes: &[u8], metadata: &Metadata, key: &ExpandedKey, iv_bytes: &[u8; IV_LEN],
secp_ctx: &Secp256k1<T>,
) -> Result<PaymentId, ()> {
- const EXPERIMENTAL_TYPES: core::ops::Range<u64> =
- EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end;
-
- let offer_records = TlvStream::new(bytes).range(OFFER_TYPES);
- let invreq_records = TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(|record| {
- match record.r#type {
- PAYER_METADATA_TYPE => false, // Should be outside range
- INVOICE_REQUEST_PAYER_ID_TYPE => !metadata.derives_payer_keys(),
- _ => true,
- }
- });
- let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES);
- let tlv_stream = offer_records.chain(invreq_records).chain(experimental_records);
-
+ let exclude_payer_id = metadata.derives_payer_keys();
+ let tlv_stream = Self::payer_tlv_stream(bytes, exclude_payer_id);
let signing_pubkey = self.payer_signing_pubkey();
signer::verify_payer_metadata(
metadata.as_ref(),
@@ -1328,6 +1345,38 @@ impl InvoiceContents {
)
}
+ fn derive_payer_signing_keys<T: secp256k1::Signing>(
+ &self, bytes: &[u8], key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], secp_ctx: &Secp256k1<T>,
+ ) -> Result<Keypair, ()> {
+ let metadata = self.payer_metadata();
+ let tlv_stream = Self::payer_tlv_stream(bytes, true);
+ let signing_pubkey = self.payer_signing_pubkey();
+ signer::derive_payer_keys(metadata, key, iv_bytes, signing_pubkey, tlv_stream, secp_ctx)
+ }
+
+ /// Builds the TLV stream used for payer metadata verification and key derivation.
+ ///
+ /// When `exclude_payer_id` is true, the payer signing pubkey (type 88) is excluded
+ /// from the stream, which is needed when deriving payer keys.
+ fn payer_tlv_stream(
+ bytes: &[u8], exclude_payer_id: bool,
+ ) -> impl core::iter::Iterator<Item = TlvRecord<'_>> {
+ const EXPERIMENTAL_TYPES: core::ops::Range<u64> =
+ EXPERIMENTAL_OFFER_TYPES.start..EXPERIMENTAL_INVOICE_REQUEST_TYPES.end;
+
+ let offer_records = TlvStream::new(bytes).range(OFFER_TYPES);
+ let invreq_records =
+ TlvStream::new(bytes).range(INVOICE_REQUEST_TYPES).filter(move |record| {
+ match record.r#type {
+ PAYER_METADATA_TYPE => false,
+ INVOICE_REQUEST_PAYER_ID_TYPE => !exclude_payer_id,
+ _ => true,
+ }
+ });
+ let experimental_records = TlvStream::new(bytes).range(EXPERIMENTAL_TYPES);
+ offer_records.chain(invreq_records).chain(experimental_records)
+ }
+
fn as_tlv_stream(&self) -> PartialInvoiceTlvStreamRef<'_> {
let (payer, offer, invoice_request, experimental_offer, experimental_invoice_request) =
match self {
diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs
index 43d1370..5f5f12a 100644
--- a/lightning/src/offers/signer.rs
+++ b/lightning/src/offers/signer.rs
@@ -290,6 +290,33 @@ pub(super) fn derive_keys(nonce: Nonce, expanded_key: &ExpandedKey) -> Keypair {
Keypair::from_secret_key(&secp_ctx, &privkey)
}
+/// Re-derives the payer signing keypair from the on-wire payer `metadata`.
+///
+/// Performs the same derivation as keys created by [`Metadata::derive_from`] when using
+/// [`Metadata::DerivedSigningPubkey`] with a [`MetadataMaterial`] built from a `payment_id`.
+/// The `metadata` is the payer metadata as it appears on the wire (the encrypted payment id
+/// followed by the [`Nonce`]); the nonce no longer needs to be supplied separately.
+///
+/// The `tlv_stream` must contain the records matching what was used during the original
+/// key derivation.
+pub(super) fn derive_payer_keys<'a, T: secp256k1::Signing>(
+ metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN],
+ signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>,
+ secp_ctx: &Secp256k1<T>,
+) -> Result<Keypair, ()> {
+ match verify_payer_metadata_inner(
+ metadata,
+ expanded_key,
+ iv_bytes,
+ signing_pubkey,
+ tlv_stream,
+ secp_ctx,
+ )? {
+ Some(keys) => Ok(keys),
+ None => Err(()),
+ }
+}
+
/// Verifies data given in a TLV stream was used to produce the given metadata, consisting of:
/// - a 256-bit [`PaymentId`],
/// - a 128-bit [`Nonce`], and possibly
@@ -304,6 +331,34 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>(
signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>,
secp_ctx: &Secp256k1<T>,
) -> Result<PaymentId, ()> {
+ verify_payer_metadata_inner(
+ metadata,
+ expanded_key,
+ iv_bytes,
+ signing_pubkey,
+ tlv_stream,
+ secp_ctx,
+ )?;
+
+ let mut encrypted_payment_id = [0u8; PaymentId::LENGTH];
+ encrypted_payment_id.copy_from_slice(&metadata[..PaymentId::LENGTH]);
+ let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap();
+ let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce);
+
+ Ok(PaymentId(payment_id))
+}
+
+/// Shared core of [`verify_payer_metadata`] and [`derive_payer_keys`].
+///
+/// Builds the payer HMAC from the given metadata and TLV stream, then verifies it against the
+/// `signing_pubkey`. The `metadata` must be at least `PaymentId::LENGTH` bytes, with the first
+/// `PaymentId::LENGTH` bytes being the encrypted payment ID and the remainder being the nonce
+/// (and possibly an HMAC).
+fn verify_payer_metadata_inner<'a, T: secp256k1::Signing>(
+ metadata: &[u8], expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN],
+ signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>,
+ secp_ctx: &Secp256k1<T>,
+) -> Result<Option<Keypair>, ()> {
if metadata.len() < PaymentId::LENGTH {
return Err(());
}
@@ -321,12 +376,7 @@ pub(super) fn verify_payer_metadata<'a, T: secp256k1::Signing>(
Hmac::from_engine(hmac),
signing_pubkey,
secp_ctx,
- )?;
-
- let nonce = Nonce::try_from(&metadata[PaymentId::LENGTH..][..Nonce::LENGTH]).unwrap();
- let payment_id = expanded_key.crypt_for_offer(encrypted_payment_id, nonce);
-
- Ok(PaymentId(payment_id))
+ )
}
/// Verifies data given in a TLV stream was used to produce the given metadata, consisting of:
Why this scored 17/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.