Merge PR 'offers: add Offer::permits_invoice_signed_by' (#4884)
What changed, and why it matters
This commit adds a new public helper method, Offer::matches_invoice_signed_by (later renamed matches_invoice_signing_pubkey), that lets callers check whether an invoice signing key belongs to the recipient named by a BOLT 12 offer. It also refactors an existing internal validation function to reuse the same logic. There is no direct bug fix or security patch in the diff; it is an API addition with tests and a changelog entry.
No immediate security action required. Treat as a normal API addition. Reviewers using the new Offer::matches_invoice_signing_pubkey should ensure it is used consistently with BOLT 12 semantics and not as a substitute for full invoice signature verification.
Security signals we found
Refactors existing BOLT 12 invoice-signing-pubkey validation into a reusable helper
Adds public API to bind an invoice signing key to an offer recipient
Adds unit tests for issuer-id vs. path-last-hop matching behavior
No change to validation rules or cryptographic checks observed
No advisory, CVE, or vendor security disclosure referenced in commit
Evidence from the diff
The change introduces Offer::matches_invoice_signing_pubkey, which delegates to the existing check_invoice_signing_pubkey logic: if the offer has an issuer_signing_pubkey, the invoice key must match it; otherwise it must match the final blinded node id of one of the offer’s paths. The helper is then used in lightning-payer-proof’s VerifiedPayerProof::pays_offers_recipient. The existing invoice parsing code (invoice.rs and static_invoice.rs) is refactored to call check_invoice_signing_pubkey with extracted Option<&PublicKey> and Option<&[BlindedMessagePath]> arguments instead of the whole OfferTlvStream. New unit tests cover issuer-id-only, both issuer-id and paths, and paths-only cases. A pending changelog file documents the API update.
Changed components
lightning/src/offers/offer.rslightning/src/offers/invoice.rslightning/src/offers/static_invoice.rslightning-payer-proof/src/lib.rsInspect captured patch +81 / −13
### lightning-payer-proof/src/lib.rs
@@ -208,15 +208,7 @@ impl VerifiedPayerProof {
/// are indistinguishable here, so a `true` answers "this was paid to whoever `offer` names"
/// rather than "this paid `offer`".
pub fn pays_offers_recipient(&self, offer: &Offer) -> bool {
- let invoice_key = self.0.issuer_signing_pubkey();
- if let Some(issuer_id) = offer.issuer_signing_pubkey() {
- return invoice_key == issuer_id;
- }
- offer
- .paths()
- .iter()
- .filter_map(|path| path.blinded_hops().last())
- .any(|last_hop| invoice_key == last_hop.blinded_node_id)
+ offer.matches_invoice_signing_pubkey(&self.0.issuer_signing_pubkey())
}
/// Whether the invoice this proof covers was issued by the recipient of the offer encoded as
### lightning/src/offers/invoice.rs
@@ -1806,7 +1806,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
experimental_baz,
};
- check_invoice_signing_pubkey(&fields.signing_pubkey, &offer_tlv_stream)?;
+ check_invoice_signing_pubkey(
+ &fields.signing_pubkey,
+ offer_tlv_stream.issuer_id.as_ref(),
+ offer_tlv_stream.paths.as_deref(),
+ )?;
if offer_tlv_stream.issuer_id.is_none() && offer_tlv_stream.paths.is_none() {
let refund = RefundContents::try_from((
@@ -1861,9 +1865,10 @@ pub(super) fn construct_payment_paths(
}
pub(super) fn check_invoice_signing_pubkey(
- invoice_signing_pubkey: &PublicKey, offer_tlv_stream: &OfferTlvStream,
+ invoice_signing_pubkey: &PublicKey, issuer_id: Option<&PublicKey>,
+ paths: Option<&[BlindedMessagePath]>,
) -> Result<(), Bolt12SemanticError> {
- match (&offer_tlv_stream.issuer_id, &offer_tlv_stream.paths) {
+ match (issuer_id, paths) {
(Some(issuer_signing_pubkey), _) => {
if invoice_signing_pubkey != issuer_signing_pubkey {
return Err(Bolt12SemanticError::InvalidSigningPubkey);
### lightning/src/offers/offer.rs
@@ -750,6 +750,21 @@ impl Offer {
self.contents.expects_quantity()
}
+ /// Returns whether `invoice_signing_pubkey` is the recipient of this offer.
+ ///
+ /// Per BOLT 12 that is [`Self::issuer_signing_pubkey`] when the offer has one, and otherwise
+ /// the final blinded node id of one of [`Self::paths`].
+ pub fn matches_invoice_signing_pubkey(
+ &self, invoice_signing_pubkey: &bitcoin::secp256k1::PublicKey,
+ ) -> bool {
+ super::invoice::check_invoice_signing_pubkey(
+ invoice_signing_pubkey,
+ self.contents.issuer_signing_pubkey.as_ref(),
+ self.contents.paths.as_deref(),
+ )
+ .is_ok()
+ }
+
pub(super) fn tlv_stream_iter<'a>(
bytes: &'a [u8],
) -> impl core::iter::Iterator<Item = TlvRecord<'a>> {
@@ -2075,6 +2090,54 @@ mod tests {
}
}
+ #[test]
+ fn matches_invoice_signing_pubkey_issuer_id_or_path_last_hop() {
+ let issuer = pubkey(42);
+ let with_issuer = OfferBuilder::new(issuer).build().unwrap();
+ assert!(with_issuer.matches_invoice_signing_pubkey(&issuer));
+ assert!(!with_issuer.matches_invoice_signing_pubkey(&pubkey(43)));
+
+ // An issuer id wins even when paths are present.
+ let with_both = OfferBuilder::new(issuer)
+ .path(BlindedMessagePath::from_blinded_path(
+ pubkey(40),
+ pubkey(41),
+ vec![
+ BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
+ BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] },
+ ],
+ ))
+ .build()
+ .unwrap();
+ assert!(with_both.matches_invoice_signing_pubkey(&issuer));
+ assert!(!with_both.matches_invoice_signing_pubkey(&pubkey(44)));
+
+ let paths_only = OfferBuilder::new(issuer)
+ .path(BlindedMessagePath::from_blinded_path(
+ pubkey(40),
+ pubkey(41),
+ vec![
+ BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
+ BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] },
+ ],
+ ))
+ .path(BlindedMessagePath::from_blinded_path(
+ pubkey(40),
+ pubkey(41),
+ vec![
+ BlindedHop { blinded_node_id: pubkey(45), encrypted_payload: vec![0; 45] },
+ BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] },
+ ],
+ ))
+ .clear_issuer_signing_pubkey()
+ .build()
+ .unwrap();
+ assert!(paths_only.matches_invoice_signing_pubkey(&pubkey(44)));
+ assert!(paths_only.matches_invoice_signing_pubkey(&pubkey(46)));
+ assert!(!paths_only.matches_invoice_signing_pubkey(&pubkey(43)));
+ assert!(!paths_only.matches_invoice_signing_pubkey(&issuer));
+ }
+
#[test]
fn parses_offer_with_paths() {
let offer = OfferBuilder::new(pubkey(42))
### lightning/src/offers/static_invoice.rs
@@ -711,7 +711,11 @@ impl TryFrom<PartialInvoiceTlvStream> for InvoiceContents {
let features = features.unwrap_or_else(Bolt12InvoiceFeatures::empty);
let signing_pubkey = node_id.ok_or(Bolt12SemanticError::MissingSigningPubkey)?;
- check_invoice_signing_pubkey(&signing_pubkey, &offer_tlv_stream)?;
+ check_invoice_signing_pubkey(
+ &signing_pubkey,
+ offer_tlv_stream.issuer_id.as_ref(),
+ offer_tlv_stream.paths.as_deref(),
+ )?;
if offer_tlv_stream.paths.is_none() {
return Err(Bolt12SemanticError::MissingPaths);
### pending_changelog/offer-matches-invoice-signing-pubkey.txt
@@ -0,0 +1,4 @@
+# API Updates
+ * `Offer::matches_invoice_signing_pubkey` answers whether an invoice signing key is the offer's
+ recipient, binding an invoice it signed to the offer: the issuer id when the offer has one,
+ otherwise a path's final blinded node id.Why this scored 18/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.