From 112c2d8060e0beb3eb849de18c8162cc039ea432 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:13:16 +0100 Subject: [PATCH 01/10] Add contacts module for Lightning offer contact management Implements bLIP 42 contact secret derivation for mutual authentication in Lightning Network payments. - Add ContactSecret struct for TLV serialization with Readable/Writeable - Add ContactSecrets for managing primary and additional remote secrets - Add compute_contact_secret() for deterministic secret derivation - Support offers with issuer_signing_pubkey and blinded paths Signed-off-by: Vincenzo Palazzo --- lightning/src/offers/contacts.rs | 298 +++++++++++++++++++++++++++++++ lightning/src/offers/mod.rs | 1 + 2 files changed, 299 insertions(+) create mode 100644 lightning/src/offers/contacts.rs diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs new file mode 100644 index 00000000000..caa323d7613 --- /dev/null +++ b/lightning/src/offers/contacts.rs @@ -0,0 +1,298 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +//! Data structures and utilities for managing Lightning Network contacts. +//! +//! Contacts are trusted people to which we may want to reveal our identity when paying them. +//! We're also able to figure out when incoming payments have been made by one of our contacts. +//! See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. + +use crate::io::{self, Read}; +use crate::ln::msgs::DecodeError; +use crate::offers::offer::Offer; +use crate::offers::parse::Bolt12SemanticError; +use crate::util::ser::{Readable, Writeable, Writer}; +use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::Scalar; +use bitcoin::secp256k1::{Secp256k1, SecretKey}; + +#[allow(unused_imports)] +use crate::prelude::*; + +/// A contact secret used in experimental TLV fields for BLIP-42. +/// +/// This is a 32-byte secret that can be included in invoice requests to establish +/// contact relationships between Lightning nodes. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct ContactSecret { + contents: [u8; 32], +} + +impl ContactSecret { + /// Creates a new [`ContactSecret`] from a 32-byte array. + pub fn new(contents: [u8; 32]) -> Self { + Self { contents } + } + + /// Returns the inner 32-byte array. + pub fn as_bytes(&self) -> &[u8; 32] { + &self.contents + } +} + +impl From<[u8; 32]> for ContactSecret { + fn from(contents: [u8; 32]) -> Self { + Self { contents } + } +} + +impl AsRef<[u8; 32]> for ContactSecret { + fn as_ref(&self) -> &[u8; 32] { + &self.contents + } +} + +impl Readable for ContactSecret { + fn read(r: &mut R) -> Result { + let mut buf = [0u8; 32]; + r.read_exact(&mut buf)?; + Ok(ContactSecret { contents: buf }) + } +} + +impl Writeable for ContactSecret { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + w.write_all(&self.contents) + } +} + +/// Contact secrets are used to mutually authenticate payments. +/// +/// The first node to add the other to its contacts list will generate the `primary_secret` and +/// send it when paying. If the second node adds the first node to its contacts list from the +/// received payment, it will use the same `primary_secret` and both nodes are able to identify +/// payments from each other. +/// +/// But if the second node independently added the first node to its contacts list, it may have +/// generated a different `primary_secret`. Each node has a different `primary_secret`, but they +/// will store the other node's `primary_secret` in their `additional_remote_secrets`, which lets +/// them correctly identify payments. +/// +/// When sending a payment, we must always send the `primary_secret`. +/// When receiving payments, we must check if the received contact_secret matches either the +/// `primary_secret` or any of the `additional_remote_secrets`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ContactSecrets { + primary_secret: ContactSecret, + additional_remote_secrets: Vec, +} + +impl ContactSecrets { + /// Creates a new [`ContactSecrets`] with the given primary secret. + pub fn new(primary_secret: ContactSecret) -> Self { + Self { primary_secret, additional_remote_secrets: Vec::new() } + } + + /// Creates a new [`ContactSecrets`] with the given primary secret and additional remote secrets. + pub fn with_additional_secrets( + primary_secret: ContactSecret, additional_remote_secrets: Vec, + ) -> Self { + Self { primary_secret, additional_remote_secrets } + } + + /// Returns the primary secret. + pub fn primary_secret(&self) -> &ContactSecret { + &self.primary_secret + } + + /// Returns the additional remote secrets. + pub fn additional_remote_secrets(&self) -> &[ContactSecret] { + &self.additional_remote_secrets + } + + /// This function should be used when we attribute an incoming payment to an existing contact. + /// + /// This can be necessary when: + /// - our contact added us without using the contact_secret we initially sent them + /// - our contact is using a different wallet from the one(s) we have already stored + pub fn add_remote_secret(&mut self, remote_secret: ContactSecret) { + if !self.additional_remote_secrets.contains(&remote_secret) { + self.additional_remote_secrets.push(remote_secret); + } + } + + /// Checks if the given secret matches either the primary secret or any additional remote secret. + pub fn matches(&self, secret: &ContactSecret) -> bool { + &self.primary_secret == secret || self.additional_remote_secrets.contains(secret) + } +} + +/// We derive our contact secret deterministically based on our offer and our contact's offer. +/// +/// This provides a few interesting properties: +/// - if we remove a contact and re-add it using the same offer, we will generate the same +/// contact secret +/// - if our contact is using the same deterministic algorithm with a single static offer, they +/// will also generate the same contact secret +/// +/// Note that this function must only be used when adding a contact that hasn't paid us before. +/// If we're adding a contact that paid us before, we must use the contact_secret they sent us, +/// which ensures that when we pay them, they'll be able to know it was coming from us (see +/// [`from_remote_secret`]). +/// +/// # Arguments +/// * `our_private_key` - The private key associated with our node identity +/// * `their_offer` - The offer from the contact +/// +/// # Errors +/// Returns [`Bolt12SemanticError::MissingSigningPubkey`] if the offer has neither an +/// issuer signing key nor a blinded path. +pub fn compute_contact_secret( + our_private_key: &SecretKey, their_offer: &Offer, +) -> Result { + let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() { + // If the offer has an issuer signing key, use it + issuer + } else { + // Otherwise, use the last node in the first blinded path (if any) + their_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .next() + .ok_or(Bolt12SemanticError::MissingSigningPubkey)? + }; + // Compute ECDH shared secret (multiply their public key by our private key) + let scalar: Scalar = our_private_key.clone().into(); + let secp = Secp256k1::verification_only(); + let ecdh = offer_node_id.mul_tweak(&secp, &scalar).expect("Multiply"); + // Hash the shared secret with the bLIP 42 tag + let mut engine = sha256::Hash::engine(); + engine.input(b"blip42_contact_secret"); + engine.input(&ecdh.serialize()); + let primary_secret = ContactSecret::new(sha256::Hash::from_engine(engine).to_byte_array()); + + Ok(ContactSecrets::new(primary_secret)) +} + +/// When adding a contact from which we've received a payment, we must use the contact_secret +/// they sent us: this ensures that they'll be able to identify payments coming from us. +pub fn from_remote_secret(remote_secret: ContactSecret) -> ContactSecrets { + ContactSecrets::new(remote_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::{hex::DisplayHex, secp256k1::Secp256k1}; + use core::str::FromStr; + + // FIXME: there is a better way to have test vectors? Loading them from + // the json file for instance? + + // derive deterministic contact_secret when both offers use blinded paths only + #[test] + fn test_compute_contact_secret_test_vector_blinded_paths() { + let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; + let alice_priv_key = + SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") + .unwrap(); + let alice_offer = Offer::from_str(alice_offer_str).unwrap(); + + assert!(alice_offer.issuer_signing_pubkey().is_none()); + assert_eq!(alice_offer.paths().len(), 1); + + let alice_offer_node_id = alice_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let alice_offer_node_id = alice_offer_node_id.first().unwrap(); + assert_eq!( + alice_offer_node_id.to_string(), + "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" + ); + + let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj"; + let bob_priv_key = + SecretKey::from_str("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333") + .unwrap(); + let bob_offer = Offer::from_str(bob_offer_str).unwrap(); + assert!(bob_offer.issuer_signing_pubkey().is_none()); + assert_eq!(bob_offer.paths().len(), 1); + + let bob_offer_node_id = bob_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let bob_offer_node_id = bob_offer_node_id.first().unwrap(); + assert_eq!( + bob_offer_node_id.to_string(), + "035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34" + ); + + let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); + + assert_eq!( + alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), + "810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8".to_owned() + ); + assert_eq!(alice_computed, bob_computed); + } + + // derive deterministic contact_secret when one offer uses both blinded paths and issuer_id + #[test] + fn test_compute_contact_secret_test_vector_blinded_paths_and_issuer_id() { + let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; + let alice_priv_key = + SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") + .unwrap(); + let alice_offer = Offer::from_str(alice_offer_str).unwrap(); + + assert!(alice_offer.issuer_signing_pubkey().is_none()); + assert_eq!(alice_offer.paths().len(), 1); + + let alice_offer_node_id = alice_offer + .paths() + .iter() + .filter_map(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .collect::>(); + let alice_offer_node_id = alice_offer_node_id.first().unwrap(); + assert_eq!( + alice_offer_node_id.to_string(), + "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" + ); + + let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx"; + let bob_priv_key = + SecretKey::from_str("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845") + .unwrap(); + let bob_offer = Offer::from_str(bob_offer_str).unwrap(); + let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap(); + assert_eq!( + bob_offer_node_id.to_string(), + "023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6" + ); + + let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); + let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); + + assert_eq!( + alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), + "4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c".to_owned() + ); + assert_eq!(alice_computed, bob_computed); + } +} diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 5b5cf6cdc78..95e2bb046c0 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -17,6 +17,7 @@ pub mod offer; pub mod flow; pub mod async_receive_offer_cache; +pub mod contacts; pub mod invoice; pub mod invoice_error; mod invoice_macros; From 196cff28572b6856bbae3189e789534eb9357994 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Tue, 16 Dec 2025 19:28:26 +0100 Subject: [PATCH 02/10] Add experimental TLV fields for invoice requests: invreq_contact_secret, invreq_payer_offer Signed-off-by: Vincenzo Palazzo --- lightning/src/ln/offers_tests.rs | 2 ++ lightning/src/offers/invoice.rs | 16 +++++++++++---- lightning/src/offers/invoice_request.rs | 27 +++++++++++++++++++------ lightning/src/offers/refund.rs | 12 +++++++++-- 4 files changed, 45 insertions(+), 12 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 8f073168465..3c9544d0cac 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -2655,6 +2655,8 @@ fn creates_and_pays_for_phantom_offer() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..36649c95a62 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1573,7 +1573,7 @@ type FullInvoiceTlvStreamRef<'a> = ( InvoiceTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -1617,7 +1617,7 @@ type PartialInvoiceTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, InvoiceTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ExperimentalInvoiceTlvStreamRef, ); @@ -2041,7 +2041,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + experimental_bar: None, + invreq_contact_secret: None, + invreq_payer_offer: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); @@ -2144,7 +2148,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + experimental_bar: None, + invreq_contact_secret: None, + invreq_payer_offer: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 7805882ef73..1a11e1f5812 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -500,7 +500,10 @@ impl UnsignedInvoiceRequest { invoice_request_tlv_stream.write(&mut bytes).unwrap(); - const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 0; + // Allocate sufficient capacity for experimental TLV fields to avoid reallocations. + // The new fields (invreq_contact_secret: ~48 bytes, invreq_payer_offer: ~116 bytes) + // total ~164 bytes, with 600 providing headroom for future experimental fields. + const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 600; let mut experimental_bytes = Vec::with_capacity(EXPERIMENTAL_TLV_ALLOCATION_SIZE); let experimental_tlv_stream = @@ -1225,6 +1228,8 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1291,9 +1296,11 @@ pub(super) const EXPERIMENTAL_INVOICE_REQUEST_TYPES: core::ops::Range = #[cfg(not(test))] tlv_stream!( ExperimentalInvoiceRequestTlvStream, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: [u8; 32]), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1301,8 +1308,10 @@ tlv_stream!( #[cfg(test)] tlv_stream!( - ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { + (2_000_001_729, invreq_contact_secret: [u8; 32]), + (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1322,7 +1331,7 @@ type FullInvoiceRequestTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for FullInvoiceRequestTlvStream { @@ -1358,7 +1367,7 @@ type PartialInvoiceRequestTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl TryFrom> for UnsignedInvoiceRequest { @@ -1437,6 +1446,8 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, #[cfg(test)] experimental_bar, }, @@ -1660,7 +1671,11 @@ mod tests { }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + experimental_bar: None, + }, ), ); diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..7495c559261 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -809,6 +809,8 @@ impl RefundContents { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -854,7 +856,7 @@ type RefundTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for RefundTlvStream { @@ -927,6 +929,8 @@ impl TryFrom for RefundContents { experimental_foo, }, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, #[cfg(test)] experimental_bar, }, @@ -1113,7 +1117,11 @@ mod tests { offer_from_hrn: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + experimental_bar: None, + }, ), ); From 00d04e56050724c3bd218b75da63631d8c71900d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 17 Dec 2025 13:03:27 +0100 Subject: [PATCH 03/10] blip42: Add contact secret and payer offer support to invoice requests Implements BLIP-42 contact management for the sender side: - Add contact_secret and payer_offer fields to InvoiceRequestContents - Add builder methods: contact_secrets(), payer_offer() - Add accessor methods: contact_secret(), payer_offer() - Add OptionalOfferPaymentParams fields for contact_secrects and payer_offer - Update ChannelManager::pay_for_offer to pass contact information - Add create_compact_offer_builder to OffersMessageFlow for small payer offers - Update tests to include new InvoiceRequestFields Signed-off-by: Vincenzo Palazzo --- fuzz/src/invoice_request_deser.rs | 2 + lightning/src/ln/channelmanager.rs | 79 +++++++++++++++++++++++ lightning/src/ln/offers_tests.rs | 12 ++++ lightning/src/offers/flow.rs | 34 ++++++++++ lightning/src/offers/invoice_request.rs | 84 +++++++++++++++++++++++-- 5 files changed, 206 insertions(+), 5 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index c4b31942843..fd3077f73a3 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -98,6 +98,8 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + contact_secret: None, + payer_offer: None, } }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ae4c87d511..368eb25e649 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -94,6 +94,7 @@ use crate::ln::outbound_payment::{ }; use crate::ln::types::ChannelId; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; +use crate::offers::contacts::ContactSecrets; use crate::offers::flow::{HeldHtlcReplyPath, InvreqResponseInstructions, OffersMessageFlow}; use crate::offers::invoice::{Bolt12Invoice, UnsignedBolt12Invoice}; use crate::offers::invoice_error::InvoiceError; @@ -773,6 +774,34 @@ pub struct OptionalOfferPaymentParams { /// will ultimately fail once all pending paths have failed (generating an /// [`Event::PaymentFailed`]). pub retry_strategy: Retry, + /// Contact secrets to include in the invoice request for BLIP-42 contact management. + /// If provided, these secrets will be used to establish a contact relationship with the recipient. + pub contact_secrets: Option, + /// A custom payer offer to include in the invoice request for BLIP-42 contact management. + /// + /// If provided, this offer will be included in the invoice request, allowing the recipient to + /// contact you back. If `None`, **no payer offer will be included** in the invoice request. + /// + /// You can create custom offers using [`OffersMessageFlow::create_compact_offer_builder`]: + /// - Pass `None` for no blinded path (smallest size, ~70 bytes) + /// - Pass `Some(intro_node_id)` for a single blinded path (~200 bytes) + /// + /// # Example + /// ```rust,ignore + /// // Include a compact offer with a single blinded path + /// let payer_offer = flow.create_compact_offer_builder( + /// &entropy_source, + /// Some(trusted_peer_pubkey) + /// )?.build()?; + /// + /// let params = OptionalOfferPaymentParams { + /// payer_offer: Some(payer_offer), + /// ..Default::default() + /// }; + /// ``` + /// + /// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder + pub payer_offer: Option, } impl Default for OptionalOfferPaymentParams { @@ -784,6 +813,8 @@ impl Default for OptionalOfferPaymentParams { retry_strategy: Retry::Timeout(core::time::Duration::from_secs(2)), #[cfg(not(feature = "std"))] retry_strategy: Retry::Attempts(3), + contact_secrets: None, + payer_offer: None, } } } @@ -14732,6 +14763,33 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// Creates a compact [`OfferBuilder`] suitable for BLIP-42's `payer_offer` field. + /// + /// This creates an offer with minimal size by either: + /// - Having no blinded paths when `intro_node_id` is `None` (for public nodes) + /// - Having a single one-hop blinded path when `intro_node_id` is `Some` (for private nodes) + /// + /// The compact format is ideal for encoding in invoice request fields where space is limited. + /// + /// # Privacy + /// + /// Uses a derived signing pubkey in the offer for recipient privacy. + /// + /// # Errors + /// + /// Errors if a blinded path cannot be created when `intro_node_id` is provided. + /// + /// [`Offer`]: crate::offers::offer::Offer + pub fn create_compact_offer_builder( + &$self, intro_node_id: Option, + ) -> Result<$builder, Bolt12SemanticError> { + let builder = $self.flow.create_compact_offer_builder( + &$self.entropy_source, intro_node_id + )?; + + Ok(builder.into()) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14967,6 +15025,8 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -14996,6 +15056,8 @@ impl< payment_id, Some(offer.hrn), create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -15038,6 +15100,8 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -15046,6 +15110,7 @@ impl< &self, offer: &Offer, quantity: Option, amount_msats: Option, payer_note: Option, payment_id: PaymentId, human_readable_name: Option, create_pending_payment: CPP, + contacts: Option, payer_offer: Option, ) -> Result<(), Bolt12SemanticError> { let entropy = &self.entropy_source; let nonce = Nonce::from_entropy_source(entropy); @@ -15071,6 +15136,20 @@ impl< Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; + let builder = if let Some(secrets) = contacts.as_ref() { + builder.contact_secrets(secrets.clone()) + } else { + builder + }; + + // Add payer offer only if provided by the user. + // If the user explicitly wants to include an offer, they should provide it via payer_offer parameter. + let builder = if let Some(offer) = payer_offer { + builder.payer_offer(&offer) + } else { + builder + }; + let invoice_request = builder.build_and_sign()?; let _persistence_guard = PersistenceNotifierGuard::notify_on_drop(self); diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 3c9544d0cac..53f88384d01 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -577,6 +577,8 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); @@ -736,6 +738,8 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); @@ -1010,6 +1014,8 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); @@ -1279,6 +1285,8 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); @@ -1345,6 +1353,8 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); @@ -1443,6 +1453,8 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: None, }); diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index bdc3475b554..ac82078a73f 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -575,6 +575,40 @@ impl OffersMessageFlow { Ok((builder.into(), nonce)) } + /// Creates a minimal [`OfferBuilder`] with derived metadata and an optional blinded path. + /// + /// If `intro_node_id` is `None`, creates an offer with no blinded paths (~70 bytes) suitable + /// for scenarios like BLIP-42 where the payer intentionally shares their contact info. + /// + /// If `intro_node_id` is `Some`, creates an offer with a single blinded path (~200 bytes) + /// providing privacy/routability for unannounced nodes. The intro node must be a public + /// peer (routable via gossip) with an outbound channel. + /// + /// # Privacy + /// + /// - `None`: Exposes the derived signing pubkey directly without blinded path privacy + /// - `Some`: Intro node learns payer identity (choose trusted/routable peer) + /// + /// This is not exported to bindings users as builder patterns don't map outside of move semantics. + pub fn create_compact_offer_builder( + &self, entropy_source: ES, intro_node_id: Option, + ) -> Result, Bolt12SemanticError> { + match intro_node_id { + None => { + // Use the internal builder but don't add any paths + self.create_offer_builder_intern(&entropy_source, |_, _, _| Ok(core::iter::empty())) + .map(|(builder, _)| builder) + }, + Some(node_id) => { + // Delegate to create_offer_builder with a single-peer list to reuse the router logic + self.create_offer_builder( + entropy_source, + vec![MessageForwardNode { node_id, short_channel_id: None }], + ) + }, + } + } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using /// [`Self::verify_invoice_request`]. diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 1a11e1f5812..e0cd59bf3ae 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -186,7 +186,7 @@ macro_rules! invoice_request_builder_methods { ( InvoiceRequestContentsWithoutPayerSigningPubkey { payer: PayerContents(metadata), offer, chain: None, amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None, - offer_from_hrn: None, + offer_from_hrn: None, invreq_contact_secret: None, invreq_payer_offer: None, #[cfg(test)] experimental_bar: None, } @@ -255,6 +255,29 @@ macro_rules! invoice_request_builder_methods { ( $return_value } + /// Sets the contact secret for BLIP-42 contact authentication. + /// + /// This will include the primary secret from the [`ContactSecrets`] in the invoice request. + /// + /// Successive calls to this method will override the previous setting. + /// + /// [`ContactSecrets`]: crate::offers::contacts::ContactSecrets + pub fn contact_secrets($($self_mut)* $self: $self_type, contact_secrets: crate::offers::contacts::ContactSecrets) -> $return_type { + $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret().as_bytes()); + $return_value + } + + /// Sets the payer's offer for BLIP-42 contact management. + /// + /// This will include the serialized offer bytes in the invoice request, + /// allowing the recipient to identify which offer the payer is responding to. + /// + /// Successive calls to this method will override the previous setting. + pub fn payer_offer($($self_mut)* $self: $self_type, offer: &Offer) -> $return_type { + $self.invoice_request.invreq_payer_offer = Some(offer.bytes.clone()); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -689,6 +712,8 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, + invreq_contact_secret: Option<[u8; 32]>, + invreq_payer_offer: Option>, #[cfg(test)] experimental_bar: Option, } @@ -750,6 +775,16 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { pub fn offer_from_hrn(&$self) -> &Option { $contents.offer_from_hrn() } + + /// Returns the contact secret if present in the invoice request. + pub fn contact_secret(&$self) -> Option<[u8; 32]> { + $contents.contact_secret() + } + + /// Returns the payer offer if present in the invoice request. + pub fn payer_offer(&$self) -> Option { + $contents.payer_offer() + } } } impl UnsignedInvoiceRequest { @@ -1057,6 +1092,10 @@ macro_rules! fields_accessor { }, } = &$inner; + // Extract BLIP-42 contact information if present + let contact_secret = $self.contact_secret(); + let payer_offer = $self.payer_offer(); + InvoiceRequestFields { payer_signing_pubkey: *payer_signing_pubkey, quantity: *quantity, @@ -1066,6 +1105,8 @@ macro_rules! fields_accessor { // down to the nearest valid UTF-8 code point boundary. .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), human_readable_name: $self.offer_from_hrn().clone(), + contact_secret, + payer_offer, } } }; @@ -1182,6 +1223,17 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } + pub(super) fn contact_secret(&self) -> Option<[u8; 32]> { + self.inner.invreq_contact_secret + } + + pub(super) fn payer_offer(&self) -> Option { + self.inner + .invreq_payer_offer + .as_ref() + .and_then(|bytes| crate::offers::offer::Offer::try_from(bytes.clone()).ok()) + } + pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { let (payer, offer, mut invoice_request, experimental_offer, experimental_invoice_request) = self.inner.as_tlv_stream(); @@ -1228,8 +1280,8 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { }; let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { - invreq_contact_secret: None, - invreq_payer_offer: None, + invreq_contact_secret: self.invreq_contact_secret.as_ref(), + invreq_payer_offer: self.invreq_payer_offer.as_ref(), #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1446,8 +1498,8 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { - invreq_contact_secret: _, - invreq_payer_offer: _, + invreq_contact_secret, + invreq_payer_offer, #[cfg(test)] experimental_bar, }, @@ -1491,6 +1543,8 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + invreq_contact_secret, + invreq_payer_offer, #[cfg(test)] experimental_bar, }, @@ -1516,6 +1570,14 @@ pub struct InvoiceRequestFields { /// The Human Readable Name which the sender indicated they were paying to. pub human_readable_name: Option, + + /// BLIP-42: The contact secret included by the payer for contact management. + /// This allows the recipient to establish a contact relationship with the payer. + pub contact_secret: Option<[u8; 32]>, + + /// BLIP-42: The payer's minimal offer included in the invoice request. + /// This is a compact offer (just node_id) to fit within payment onion size constraints. + pub payer_offer: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1528,11 +1590,14 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { + let payer_offer_bytes = self.payer_offer.as_ref().map(|offer| offer.as_ref().to_vec()); write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), + (6, self.contact_secret, option), + (8, payer_offer_bytes.as_ref().map(|v| WithoutLength(&v[..])), option), }); Ok(()) } @@ -1545,13 +1610,20 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), + (6, contact_secret, option), + (8, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), }); + let payer_offer = + payer_offer_bytes.and_then(|bytes| crate::offers::offer::Offer::try_from(bytes).ok()); + Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), quantity, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + contact_secret, + payer_offer, }) } } @@ -3133,6 +3205,8 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + contact_secret: None, + payer_offer: None, } ); From 8f746a0dc1196e9e23417f9808d4f5c84340bc43 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Sat, 16 May 2026 11:06:05 +0200 Subject: [PATCH 04/10] offers: introduce ContactSecret newtype and BLIP 42 TLV-type consts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace `Option<[u8; 32]>` with `Option` across the invoice_request internal storage, the `InvoiceRequest::contact_secret()` accessor, and the `InvoiceRequestFields` round-trip type. The wire format is unchanged (`ContactSecret` writes/reads the same 32 raw bytes via its existing `Writeable`/`Readable` impls), so this is a pure type-system refactor. Add `INVREQ_CONTACT_SECRET_TYPE` and `INVREQ_PAYER_OFFER_TYPE` `pub(super)` constants in `offers::contacts`, mirroring the pattern of `INVOICE_REQUEST_PAYER_ID_TYPE` and the other TLV-type constants in `offers/`. Replace the numeric literals in `ExperimentalInvoiceRequestTlvStream` with these constants so the BLIP 42 TLV numbers are defined in exactly one place. Also document BLIP 42's anti-redirection rule on the public `InvoiceRequestFields::contact_secret` field, since enforcement is the application's responsibility — LDK does not own a contacts store. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/offers/contacts.rs | 8 ++++++++ lightning/src/offers/invoice_request.rs | 24 +++++++++++++++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs index caa323d7613..4ed013fa96d 100644 --- a/lightning/src/offers/contacts.rs +++ b/lightning/src/offers/contacts.rs @@ -25,6 +25,14 @@ use bitcoin::secp256k1::{Secp256k1, SecretKey}; #[allow(unused_imports)] use crate::prelude::*; +/// TLV record type for the `invreq_contact_secret` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_CONTACT_SECRET_TYPE: u64 = 2_000_001_729; + +/// TLV record type for the `invreq_payer_offer` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_PAYER_OFFER_TYPE: u64 = 2_000_001_731; + /// A contact secret used in experimental TLV fields for BLIP-42. /// /// This is a 32-byte secret that can be included in invoice requests to establish diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index e0cd59bf3ae..ba71da37a06 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -71,6 +71,7 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; +use crate::offers::contacts::{ContactSecret, INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_OFFER_TYPE}; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -263,7 +264,7 @@ macro_rules! invoice_request_builder_methods { ( /// /// [`ContactSecrets`]: crate::offers::contacts::ContactSecrets pub fn contact_secrets($($self_mut)* $self: $self_type, contact_secrets: crate::offers::contacts::ContactSecrets) -> $return_type { - $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret().as_bytes()); + $self.invoice_request.invreq_contact_secret = Some(*contact_secrets.primary_secret()); $return_value } @@ -712,7 +713,7 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, offer_from_hrn: Option, - invreq_contact_secret: Option<[u8; 32]>, + invreq_contact_secret: Option, invreq_payer_offer: Option>, #[cfg(test)] experimental_bar: Option, @@ -777,7 +778,7 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { } /// Returns the contact secret if present in the invoice request. - pub fn contact_secret(&$self) -> Option<[u8; 32]> { + pub fn contact_secret(&$self) -> Option { $contents.contact_secret() } @@ -1223,7 +1224,7 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } - pub(super) fn contact_secret(&self) -> Option<[u8; 32]> { + pub(super) fn contact_secret(&self) -> Option { self.inner.invreq_contact_secret } @@ -1351,8 +1352,8 @@ tlv_stream!( ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { - (2_000_001_729, invreq_contact_secret: [u8; 32]), - (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), + (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1362,8 +1363,8 @@ tlv_stream!( tlv_stream!( ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, EXPERIMENTAL_INVOICE_REQUEST_TYPES, { - (2_000_001_729, invreq_contact_secret: [u8; 32]), - (2_000_001_731, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), + (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1573,7 +1574,12 @@ pub struct InvoiceRequestFields { /// BLIP-42: The contact secret included by the payer for contact management. /// This allows the recipient to establish a contact relationship with the payer. - pub contact_secret: Option<[u8; 32]>, + /// + /// Per BLIP 42, if this matches an existing contact of the recipient, the recipient MUST + /// ignore [`Self::payer_offer`] (and any future `bip_353_name` field) to prevent a leaked + /// `contact_secret` from being used to redirect future payments. Enforcement of this rule + /// is the responsibility of the application that owns the contacts list. + pub contact_secret: Option, /// BLIP-42: The payer's minimal offer included in the invoice request. /// This is a compact offer (just node_id) to fit within payment onion size constraints. From 4df2441369484a6d1ad7a553fc7c5ec5b39e42ee Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 27 May 2026 19:37:50 +0200 Subject: [PATCH 05/10] offers: move InvoiceRequestFields BLIP 42 TLVs to odd numbers (7, 9) `InvoiceRequestFields` is round-tripped through the blinded payment path's `path_id` (via `Bolt12OfferContext`), so older LDK nodes can end up parsing a serialization produced by a newer LDK. Per BOLT 1 "odd, it's OK; even, you die", optional/forward-compatible fields must use odd TLV types so unknown-to-an-older-reader fields are skipped rather than rejected with `DecodeError::UnknownRequiredField`. The original PoC used even types 6 and 8 for `contact_secret` and `payer_offer`, which would have broken forward compat between LDK versions once shipped. Move them to odd 7 and 9. Reserve 11 and 13 for `invreq_payer_bip_353_name` and `invreq_payer_bip_353_signature` in a follow-up commit. The wire format change is safe in-flight: PR #4210 has not been released, so no deployed consumer is reading types 6 or 8 today. Flagged by `ldk-claude-review-bot` on the previous push. Co-Authored-By: Claude Opus 4.7 (1M context) --- lightning/src/offers/invoice_request.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index ba71da37a06..9f9548f53ef 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -1597,13 +1597,16 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { let payer_offer_bytes = self.payer_offer.as_ref().map(|offer| offer.as_ref().to_vec()); + // BLIP 42 fields use odd TLV types (7, 9) so older LDK nodes reading newer + // `Bolt12OfferContext.path_id` blobs silently ignore unknown fields per BOLT 1 + // "odd, it's OK". TLVs 11 and 13 are reserved for invreq_payer_bip_353_{name,signature}. write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), - (6, self.contact_secret, option), - (8, payer_offer_bytes.as_ref().map(|v| WithoutLength(&v[..])), option), + (7, self.contact_secret, option), + (9, payer_offer_bytes.as_ref().map(|v| WithoutLength(&v[..])), option), }); Ok(()) } @@ -1616,8 +1619,8 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), - (6, contact_secret, option), - (8, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), + (7, contact_secret, option), + (9, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), }); let payer_offer = From df0d4d09f0f6636bf45fe3d65b3990c817a5220d Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 00:07:55 +0200 Subject: [PATCH 06/10] offers: derive BLIP 42 contact secrets from our offer signing keys Per bLIP 42, the contact secret is computed via ECDH between the private key behind our offer's offer_node_id and the counterparty offer's offer_node_id. The previous API took "our node identity" key, which never matches the derived signing pubkey LDK puts in its offers, so the counterparty could not reproduce the secret. Re-derive the offer signing keys from the offer metadata instead and expose the derivation as OffersMessageFlow::compute_contact_secret and ChannelManager::compute_contact_secret. The freestanding helper now takes a caller-provided secp context, propagates ECDH errors instead of panicking, and documents which key it expects. Also fold from_remote_secret into ContactSecrets, make secret matching constant-time, document the contact lifecycle end-to-end, and deduplicate the test vectors. Co-Authored-By: Claude Fable 5 --- lightning/src/ln/channelmanager.rs | 73 +++++--- lightning/src/offers/contacts.rs | 256 ++++++++++++++++------------- lightning/src/offers/flow.rs | 27 +++ lightning/src/offers/offer.rs | 11 ++ 4 files changed, 233 insertions(+), 134 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 368eb25e649..55b5a7e7079 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -774,25 +774,38 @@ pub struct OptionalOfferPaymentParams { /// will ultimately fail once all pending paths have failed (generating an /// [`Event::PaymentFailed`]). pub retry_strategy: Retry, - /// Contact secrets to include in the invoice request for BLIP-42 contact management. - /// If provided, these secrets will be used to establish a contact relationship with the recipient. + /// BLIP 42 contact secrets identifying us to the recipient, revealing that the payment came + /// from one of their contacts. + /// + /// Only set this when the user explicitly chose to reveal their identity to the recipient + /// (e.g., by saving them as a contact), as it makes payments linkable. When set, the primary + /// secret is included in the invoice request. Obtain the secrets either from + /// [`ChannelManager::compute_contact_secret`] when we add the contact first, or from + /// [`ContactSecrets::from_remote_secret`] with the secret from + /// [`InvoiceRequestFields::contact_secret`] when they paid us first. + /// + /// [`InvoiceRequestFields::contact_secret`]: crate::offers::invoice_request::InvoiceRequestFields::contact_secret pub contact_secrets: Option, - /// A custom payer offer to include in the invoice request for BLIP-42 contact management. + /// Our own offer to include in the invoice request for BLIP 42 contact management, allowing + /// the recipient to pay us back and thereby establish a mutual contact relationship. /// - /// If provided, this offer will be included in the invoice request, allowing the recipient to - /// contact you back. If `None`, **no payer offer will be included** in the invoice request. + /// If `None`, no payer offer will be included in the invoice request. As with + /// [`Self::contact_secrets`], only set this when the user explicitly chose to reveal their + /// identity to the recipient. To keep invoice requests small enough for the recipient to + /// store the contact data, the offer's encoding must not exceed [`PAYER_OFFER_MAX_BYTES`]; + /// use a long-lived offer created via [`ChannelManager::create_compact_offer_builder`]: + /// - Pass `None` for no blinded path (smallest size, for public nodes) + /// - Pass `Some(intro_node_id)` for a single blinded path (for private nodes) /// - /// You can create custom offers using [`OffersMessageFlow::create_compact_offer_builder`]: - /// - Pass `None` for no blinded path (smallest size, ~70 bytes) - /// - Pass `Some(intro_node_id)` for a single blinded path (~200 bytes) + /// Note that to converge on the same contact secret as the recipient, this should be the + /// same offer used with [`ChannelManager::compute_contact_secret`]. /// /// # Example /// ```rust,ignore /// // Include a compact offer with a single blinded path - /// let payer_offer = flow.create_compact_offer_builder( - /// &entropy_source, - /// Some(trusted_peer_pubkey) - /// )?.build()?; + /// let payer_offer = channel_manager + /// .create_compact_offer_builder(Some(trusted_peer_pubkey))? + /// .build()?; /// /// let params = OptionalOfferPaymentParams { /// payer_offer: Some(payer_offer), @@ -800,7 +813,7 @@ pub struct OptionalOfferPaymentParams { /// }; /// ``` /// - /// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder + /// [`PAYER_OFFER_MAX_BYTES`]: crate::offers::contacts::PAYER_OFFER_MAX_BYTES pub payer_offer: Option, } @@ -15105,6 +15118,23 @@ impl< ) } + /// Computes the BLIP 42 contact secret shared between us and a contact, deterministically + /// derived from one of our offers and the contact's offer. + /// + /// `our_offer` must have been created by this [`ChannelManager`] (e.g., via + /// [`Self::create_offer_builder`] or [`Self::create_compact_offer_builder`]) so that the keys + /// behind its signing pubkey can be re-derived. Use this when adding a contact that hasn't + /// paid us before; when they paid us first, use [`ContactSecrets::from_remote_secret`] with + /// the secret they sent instead. + /// + /// The returned secrets may be included in an invoice request via + /// [`OptionalOfferPaymentParams::contact_secrets`]. + pub fn compute_contact_secret( + &self, our_offer: &Offer, their_offer: &Offer, + ) -> Result { + self.flow.compute_contact_secret(our_offer, their_offer) + } + #[rustfmt::skip] fn pay_for_offer_intern Result<(), Bolt12SemanticError>>( &self, offer: &Offer, quantity: Option, amount_msats: Option, @@ -15136,18 +15166,13 @@ impl< Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; - let builder = if let Some(secrets) = contacts.as_ref() { - builder.contact_secrets(secrets.clone()) - } else { - builder + let builder = match contacts { + None => builder, + Some(secrets) => builder.contact_secrets(secrets), }; - - // Add payer offer only if provided by the user. - // If the user explicitly wants to include an offer, they should provide it via payer_offer parameter. - let builder = if let Some(offer) = payer_offer { - builder.payer_offer(&offer) - } else { - builder + let builder = match payer_offer { + None => builder, + Some(offer) => builder.payer_offer(&offer), }; let invoice_request = builder.build_and_sign()?; diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs index 4ed013fa96d..4db7372d9a7 100644 --- a/lightning/src/offers/contacts.rs +++ b/lightning/src/offers/contacts.rs @@ -12,15 +12,37 @@ //! Contacts are trusted people to which we may want to reveal our identity when paying them. //! We're also able to figure out when incoming payments have been made by one of our contacts. //! See [bLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) for more details. +//! +//! The typical lifecycle of a contact relationship is: +//! 1. The payer adds the recipient to their contacts list using the recipient's long-lived +//! offer, deterministically deriving [`ContactSecrets`] from both parties' offers via +//! `ChannelManager::compute_contact_secret`. +//! 2. When paying that contact's offer, the payer opts into revealing their identity by setting +//! `OptionalOfferPaymentParams::contact_secrets` (and optionally their own return offer via +//! `OptionalOfferPaymentParams::payer_offer`). +//! 3. The recipient finds the received secret and offer in +//! [`InvoiceRequestFields::contact_secret`] and [`InvoiceRequestFields::payer_offer`] when +//! claiming the payment. If the secret matches an existing contact (see +//! [`ContactSecrets::matches`]), the payment came from that contact and any received offer +//! MUST be ignored. Otherwise, the recipient may offer the user to add the payer as a +//! contact, storing the received secret via [`ContactSecrets::from_remote_secret`] so the +//! payer can recognize payments coming back from us. +//! 4. If both parties added each other independently (with differing primary secrets), incoming +//! secrets can be attributed to a contact manually, after which they should be stored via +//! [`ContactSecrets::add_remote_secret`] for future matching. +//! +//! [`InvoiceRequestFields::contact_secret`]: crate::offers::invoice_request::InvoiceRequestFields::contact_secret +//! [`InvoiceRequestFields::payer_offer`]: crate::offers::invoice_request::InvoiceRequestFields::payer_offer use crate::io::{self, Read}; use crate::ln::msgs::DecodeError; use crate::offers::offer::Offer; use crate::offers::parse::Bolt12SemanticError; use crate::util::ser::{Readable, Writeable, Writer}; +use bitcoin::hashes::cmp::fixed_time_eq; use bitcoin::hashes::{sha256, Hash, HashEngine}; use bitcoin::secp256k1::Scalar; -use bitcoin::secp256k1::{Secp256k1, SecretKey}; +use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; #[allow(unused_imports)] use crate::prelude::*; @@ -33,6 +55,14 @@ pub(super) const INVREQ_CONTACT_SECRET_TYPE: u64 = 2_000_001_729; /// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). pub(super) const INVREQ_PAYER_OFFER_TYPE: u64 = 2_000_001_731; +/// The maximum encoded size of an [`Offer`] used as a payer offer. +/// +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) recommends keeping +/// payer offers below this size so that invoice requests containing them still fit the sender +/// data that recipients store in blinded path padding and, for async payments, the payment +/// onion. +pub const PAYER_OFFER_MAX_BYTES: usize = 300; + /// A contact secret used in experimental TLV fields for BLIP-42. /// /// This is a 32-byte secret that can be included in invoice requests to establish @@ -107,6 +137,14 @@ impl ContactSecrets { Self { primary_secret, additional_remote_secrets: Vec::new() } } + /// Creates a new [`ContactSecrets`] from the contact secret a contact sent us in a payment. + /// + /// When adding a contact from which we've received a payment, we must use the contact secret + /// they sent us: this ensures that they'll be able to identify payments coming from us. + pub fn from_remote_secret(remote_secret: ContactSecret) -> Self { + Self::new(remote_secret) + } + /// Creates a new [`ContactSecrets`] with the given primary secret and additional remote secrets. pub fn with_additional_secrets( primary_secret: ContactSecret, additional_remote_secrets: Vec, @@ -136,8 +174,14 @@ impl ContactSecrets { } /// Checks if the given secret matches either the primary secret or any additional remote secret. + /// + /// Comparisons are made in constant time to avoid leaking which stored secret matched. pub fn matches(&self, secret: &ContactSecret) -> bool { - &self.primary_secret == secret || self.additional_remote_secrets.contains(secret) + let mut found = fixed_time_eq(self.primary_secret.as_bytes(), secret.as_bytes()); + for remote_secret in &self.additional_remote_secrets { + found |= fixed_time_eq(remote_secret.as_bytes(), secret.as_bytes()); + } + found } } @@ -152,35 +196,42 @@ impl ContactSecrets { /// Note that this function must only be used when adding a contact that hasn't paid us before. /// If we're adding a contact that paid us before, we must use the contact_secret they sent us, /// which ensures that when we pay them, they'll be able to know it was coming from us (see -/// [`from_remote_secret`]). +/// [`ContactSecrets::from_remote_secret`]). /// /// # Arguments -/// * `our_private_key` - The private key associated with our node identity +/// * `our_offer_signing_key` - The private key behind our offer's `offer_node_id`, i.e. its +/// issuer signing pubkey if set, otherwise the final `blinded_node_id` of its first path. +/// For offers whose signing pubkey was derived (e.g. ones built by +/// [`OffersMessageFlow::create_offer_builder`]), use +/// [`OffersMessageFlow::compute_contact_secret`] instead, which re-derives this key +/// internally. /// * `their_offer` - The offer from the contact /// /// # Errors -/// Returns [`Bolt12SemanticError::MissingSigningPubkey`] if the offer has neither an +/// Returns [`Bolt12SemanticError::MissingSigningPubkey`] if their offer has neither an /// issuer signing key nor a blinded path. -pub fn compute_contact_secret( - our_private_key: &SecretKey, their_offer: &Offer, +/// +/// [`OffersMessageFlow::create_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_offer_builder +/// [`OffersMessageFlow::compute_contact_secret`]: crate::offers::flow::OffersMessageFlow::compute_contact_secret +pub fn compute_contact_secret( + secp_ctx: &Secp256k1, our_offer_signing_key: &SecretKey, their_offer: &Offer, ) -> Result { let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() { - // If the offer has an issuer signing key, use it issuer } else { // Otherwise, use the last node in the first blinded path (if any) their_offer .paths() - .iter() - .filter_map(|path| path.blinded_hops().last()) + .first() + .and_then(|path| path.blinded_hops().last()) .map(|hop| hop.blinded_node_id) - .next() .ok_or(Bolt12SemanticError::MissingSigningPubkey)? }; // Compute ECDH shared secret (multiply their public key by our private key) - let scalar: Scalar = our_private_key.clone().into(); - let secp = Secp256k1::verification_only(); - let ecdh = offer_node_id.mul_tweak(&secp, &scalar).expect("Multiply"); + let scalar: Scalar = (*our_offer_signing_key).into(); + let ecdh = offer_node_id + .mul_tweak(secp_ctx, &scalar) + .map_err(|_| Bolt12SemanticError::InvalidSigningPubkey)?; // Hash the shared secret with the bLIP 42 tag let mut engine = sha256::Hash::engine(); engine.input(b"blip42_contact_secret"); @@ -190,117 +241,102 @@ pub fn compute_contact_secret( Ok(ContactSecrets::new(primary_secret)) } -/// When adding a contact from which we've received a payment, we must use the contact_secret -/// they sent us: this ensures that they'll be able to identify payments coming from us. -pub fn from_remote_secret(remote_secret: ContactSecret) -> ContactSecrets { - ContactSecrets::new(remote_secret) -} - #[cfg(test)] mod tests { use super::*; - use bitcoin::{hex::DisplayHex, secp256k1::Secp256k1}; + use bitcoin::hex::DisplayHex; + use bitcoin::secp256k1::Secp256k1; use core::str::FromStr; - // FIXME: there is a better way to have test vectors? Loading them from - // the json file for instance? + const ALICE_OFFER: &str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; + const ALICE_SECRET: &str = "4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb"; + const ALICE_OFFER_NODE_ID: &str = + "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9"; + + struct TestVector { + bob_offer: &'static str, + bob_secret: &'static str, + bob_offer_node_id: &'static str, + expected_contact_secret: &'static str, + } + + // Test vectors from bLIP 42. Alice's offer only contains a blinded path, while Bob's offer + // differs per vector in how its `offer_node_id` is conveyed. + const TEST_VECTORS: &[TestVector] = &[ + // Bob's offer also only uses a blinded path. + TestVector { + bob_offer: "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj", + bob_secret: "12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333", + bob_offer_node_id: "035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34", + expected_contact_secret: "810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8", + }, + // Bob's offer uses both a blinded path and an issuer_id, which takes precedence. + TestVector { + bob_offer: "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx", + bob_secret: "bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845", + bob_offer_node_id: "023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6", + expected_contact_secret: "4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c", + }, + ]; - // derive deterministic contact_secret when both offers use blinded paths only #[test] - fn test_compute_contact_secret_test_vector_blinded_paths() { - let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; - let alice_priv_key = - SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") - .unwrap(); - let alice_offer = Offer::from_str(alice_offer_str).unwrap(); + fn computes_contact_secret_test_vectors() { + let secp_ctx = Secp256k1::verification_only(); + let alice_offer = Offer::from_str(ALICE_OFFER).unwrap(); + let alice_key = SecretKey::from_str(ALICE_SECRET).unwrap(); assert!(alice_offer.issuer_signing_pubkey().is_none()); assert_eq!(alice_offer.paths().len(), 1); - let alice_offer_node_id = alice_offer .paths() - .iter() - .filter_map(|path| path.blinded_hops().last()) + .first() + .and_then(|path| path.blinded_hops().last()) .map(|hop| hop.blinded_node_id) - .collect::>(); - let alice_offer_node_id = alice_offer_node_id.first().unwrap(); - assert_eq!( - alice_offer_node_id.to_string(), - "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" - ); - - let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qj"; - let bob_priv_key = - SecretKey::from_str("12afb8248c7336e6aea5fe247bc4bac5dcabfb6017bd67b32c8195a6c56b8333") - .unwrap(); - let bob_offer = Offer::from_str(bob_offer_str).unwrap(); - assert!(bob_offer.issuer_signing_pubkey().is_none()); - assert_eq!(bob_offer.paths().len(), 1); - - let bob_offer_node_id = bob_offer - .paths() - .iter() - .filter_map(|path| path.blinded_hops().last()) - .map(|hop| hop.blinded_node_id) - .collect::>(); - let bob_offer_node_id = bob_offer_node_id.first().unwrap(); - assert_eq!( - bob_offer_node_id.to_string(), - "035e4d1b7237898390e7999b6835ef83cd93b98200d599d29075b45ab0fedc2b34" - ); - - let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); - let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); - - assert_eq!( - alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), - "810641fab614f8bc1441131dc50b132fd4d1e2ccd36f84b887bbab3a6d8cc3d8".to_owned() - ); - assert_eq!(alice_computed, bob_computed); + .unwrap(); + assert_eq!(alice_offer_node_id.to_string(), ALICE_OFFER_NODE_ID); + + for vector in TEST_VECTORS { + let bob_offer = Offer::from_str(vector.bob_offer).unwrap(); + let bob_key = SecretKey::from_str(vector.bob_secret).unwrap(); + let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap_or_else(|| { + bob_offer + .paths() + .first() + .and_then(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .unwrap() + }); + assert_eq!(bob_offer_node_id.to_string(), vector.bob_offer_node_id); + + let alice_computed = compute_contact_secret(&secp_ctx, &alice_key, &bob_offer).unwrap(); + let bob_computed = compute_contact_secret(&secp_ctx, &bob_key, &alice_offer).unwrap(); + + assert_eq!( + alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), + vector.expected_contact_secret + ); + assert_eq!(alice_computed, bob_computed); + } } - // derive deterministic contact_secret when one offer uses both blinded paths and issuer_id #[test] - fn test_compute_contact_secret_test_vector_blinded_paths_and_issuer_id() { - let alice_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsrejlwh4vyz70s46r62vtakl4sxztqj6gxjged0wx0ly8qtrygufcsyq5agaes6v605af5rr9ydnj9srneudvrmc73n7evp72tzpqcnd28puqr8a3wmcff9wfjwgk32650vl747m2ev4zsjagzucntctlmcpc6vhmdnxlywneg5caqz0ansr45z2faxq7unegzsnyuduzys7kzyugpwcmhdqqj0h70zy92p75pseunclwsrwhaelvsqy9zsejcytxulndppmykcznn7y5h"; - let alice_priv_key = - SecretKey::from_str("4ed1a01dae275f7b7ba503dbae23dddd774a8d5f64788ef7a768ed647dd0e1eb") - .unwrap(); - let alice_offer = Offer::from_str(alice_offer_str).unwrap(); - - assert!(alice_offer.issuer_signing_pubkey().is_none()); - assert_eq!(alice_offer.paths().len(), 1); - - let alice_offer_node_id = alice_offer - .paths() - .iter() - .filter_map(|path| path.blinded_hops().last()) - .map(|hop| hop.blinded_node_id) - .collect::>(); - let alice_offer_node_id = alice_offer_node_id.first().unwrap(); - assert_eq!( - alice_offer_node_id.to_string(), - "0284c9c6f04487ac22710176377680127dfcf110aa0fa8186793c7dd01bafdcfd9" - ); - - let bob_offer_str = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcsesp0grlulxv3jygx83h7tghy3233sqd6xlcccvpar2l8jshxrtwvtcsz4n88s74qhussxsu0vs3c4unck4yelk67zdc29ree3sztvjn7pc9qyqlcpj54jnj67aa9rd2n5dhjlxyfmv3vgqymrks2nf7gnf5u200mn5qrxfrxh9d0ug43j5egklhwgyrfv3n84gyjd2aajhwqxa0cc7zn37sncrwptz4uhlp523l83xpjx9dw72spzecrtex3ku3h3xpepeuend5rtmurekfmnqsq6kva9yr4k3dtplku9v6qqyxr5ep6lls3hvrqyt9y7htaz9qjzcssy065ctv38c5h03lu0hlvq2t4p5fg6u668y6pmzcg64hmdm050jxx"; - let bob_priv_key = - SecretKey::from_str("bcaafa8ed73da11437ce58c7b3458567a870168c0da325a40292fed126b97845") - .unwrap(); - let bob_offer = Offer::from_str(bob_offer_str).unwrap(); - let bob_offer_node_id = bob_offer.issuer_signing_pubkey().unwrap(); - assert_eq!( - bob_offer_node_id.to_string(), - "023f54c2d913e2977c7fc7dfec029750d128d735a39341d8b08d56fb6edf47c8c6" - ); - - let alice_computed = compute_contact_secret(&alice_priv_key, &bob_offer).unwrap(); - let bob_computed = compute_contact_secret(&bob_priv_key, &alice_offer).unwrap(); - - assert_eq!( - alice_computed.primary_secret().as_bytes().to_hex_string(bitcoin::hex::Case::Lower), - "4e0aa72cc42eae9f8dc7c6d2975bbe655683ada2e9abfdfe9f299d391ed9736c".to_owned() - ); - assert_eq!(alice_computed, bob_computed); + fn matches_primary_and_additional_secrets() { + let primary = ContactSecret::new([1; 32]); + let remote = ContactSecret::new([2; 32]); + let unknown = ContactSecret::new([3; 32]); + + let mut secrets = ContactSecrets::new(primary); + assert!(secrets.matches(&primary)); + assert!(!secrets.matches(&remote)); + + secrets.add_remote_secret(remote); + secrets.add_remote_secret(remote); + assert_eq!(secrets.additional_remote_secrets().len(), 1); + assert!(secrets.matches(&primary)); + assert!(secrets.matches(&remote)); + assert!(!secrets.matches(&unknown)); + + let from_remote = ContactSecrets::from_remote_secret(remote); + assert_eq!(*from_remote.primary_secret(), remote); } } diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index ac82078a73f..45af9aab3af 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -36,6 +36,7 @@ use crate::ln::channel_state::ChannelDetails; use crate::ln::channelmanager::{InterceptId, PaymentId, CLTV_FAR_FAR_AWAY}; use crate::ln::inbound_payment; use crate::offers::async_receive_offer_cache::AsyncReceiveOfferCache; +use crate::offers::contacts::{compute_contact_secret, ContactSecrets}; use crate::offers::invoice::{ Bolt12Invoice, DerivedSigningPubkey, ExplicitSigningPubkey, InvoiceBuilder, DEFAULT_RELATIVE_EXPIRY, @@ -609,6 +610,32 @@ impl OffersMessageFlow { } } + /// Computes the BLIP 42 contact secret shared between us and a contact, deterministically + /// derived from one of our offers and the contact's offer. + /// + /// `our_offer` must have been created by this flow (e.g., via [`Self::create_offer_builder`] + /// or [`Self::create_compact_offer_builder`]) so that the keys behind its signing pubkey can + /// be re-derived. Use this when adding a contact that hasn't paid us before; when they paid + /// us first, use [`ContactSecrets::from_remote_secret`] with the secret they sent instead. + /// + /// # Errors + /// + /// Returns [`Bolt12SemanticError::InvalidMetadata`] if `our_offer` was not created by this + /// flow, and [`Bolt12SemanticError::MissingSigningPubkey`] if `their_offer` has neither an + /// issuer signing pubkey nor a blinded path. + pub fn compute_contact_secret( + &self, our_offer: &Offer, their_offer: &Offer, + ) -> Result { + let expanded_key = &self.inbound_payment_key; + let secp_ctx = &self.secp_ctx; + + let keys = our_offer + .derive_issuer_signing_keys(expanded_key, secp_ctx) + .map_err(|()| Bolt12SemanticError::InvalidMetadata)?; + + compute_contact_secret(secp_ctx, &keys.secret_key(), their_offer) + } + /// Creates an [`OfferBuilder`] such that the [`Offer`] it builds is recognized by the /// [`OffersMessageFlow`], and any corresponding [`InvoiceRequest`] can be verified using /// [`Self::verify_invoice_request`]. diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..2cdce7c3147 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -763,6 +763,17 @@ impl Offer { ) -> Result<(OfferId, Option), ()> { self.contents.verify_using_recipient_data(&self.bytes, nonce, key, secp_ctx) } + + /// Re-derives the keys used to sign invoices for this offer, if the offer's signing pubkey + /// was derived from `key` (i.e., the offer was built using `deriving_signing_pubkey` with + /// metadata included). + pub(super) fn derive_issuer_signing_keys( + &self, key: &ExpandedKey, secp_ctx: &Secp256k1, + ) -> Result { + self.contents + .verify_using_metadata(&self.bytes, key, secp_ctx) + .and_then(|(_, keys)| keys.ok_or(())) + } } macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: expr, $builder: ty, $hrn: expr) => { From e1b98d5842f82d57b0dcfa1034369ce3193945d6 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 00:08:14 +0200 Subject: [PATCH 07/10] offers: validate BLIP 42 payer offers when building and parsing invreqs Store the payer offer as a parsed Offer rather than raw bytes so that malformed offers are rejected when the invoice request is parsed instead of being silently dropped by accessors, and so accessors no longer re-parse and clone on every call. Reading persisted InvoiceRequestFields now propagates a decode error for corrupted payer offer bytes for the same reason. Also enforce the bLIP 42 size recommendation by failing to build an invoice request whose payer offer exceeds PAYER_OFFER_MAX_BYTES, right-size the experimental TLV allocation, and cover the new behavior with builder, parsing, and round-trip tests. Co-Authored-By: Claude Fable 5 --- lightning/src/ln/offers_tests.rs | 4 + lightning/src/offers/invoice_request.rs | 173 +++++++++++++++++++----- lightning/src/offers/parse.rs | 2 + 3 files changed, 148 insertions(+), 31 deletions(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 53f88384d01..93d29500d6a 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -822,6 +822,8 @@ fn router_modifies_payment_metadata_in_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: Some(expected_metadata), }); @@ -905,6 +907,8 @@ fn pays_for_offer_with_payment_metadata_in_invoice_request_context() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, }, payment_metadata: Some(expected_metadata), }); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 9f9548f53ef..c7ccbeb748f 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -71,7 +71,9 @@ use crate::io; use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; -use crate::offers::contacts::{ContactSecret, INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_OFFER_TYPE}; +use crate::offers::contacts::{ + ContactSecret, INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_OFFER_TYPE, PAYER_OFFER_MAX_BYTES, +}; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -268,14 +270,19 @@ macro_rules! invoice_request_builder_methods { ( $return_value } - /// Sets the payer's offer for BLIP-42 contact management. + /// Sets the payer's own offer for BLIP-42 contact management. + /// + /// This will include the serialized offer in the invoice request, allowing the recipient to + /// pay us back and thereby establish a mutual contact relationship. /// - /// This will include the serialized offer bytes in the invoice request, - /// allowing the recipient to identify which offer the payer is responding to. + /// The offer's encoding must not exceed [`PAYER_OFFER_MAX_BYTES`], otherwise building the + /// invoice request will fail with [`Bolt12SemanticError::InvalidPayerOffer`]. /// /// Successive calls to this method will override the previous setting. + /// + /// [`PAYER_OFFER_MAX_BYTES`]: crate::offers::contacts::PAYER_OFFER_MAX_BYTES pub fn payer_offer($($self_mut)* $self: $self_type, offer: &Offer) -> $return_type { - $self.invoice_request.invreq_payer_offer = Some(offer.bytes.clone()); + $self.invoice_request.invreq_payer_offer = Some(offer.clone()); $return_value } @@ -307,6 +314,12 @@ macro_rules! invoice_request_builder_methods { ( $self.invoice_request.amount_msats, $self.invoice_request.quantity )?; + if let Some(payer_offer) = &$self.invoice_request.invreq_payer_offer { + if payer_offer.as_ref().len() > PAYER_OFFER_MAX_BYTES { + return Err(Bolt12SemanticError::InvalidPayerOffer); + } + } + Ok($self.build_without_checks()) } @@ -524,10 +537,10 @@ impl UnsignedInvoiceRequest { invoice_request_tlv_stream.write(&mut bytes).unwrap(); - // Allocate sufficient capacity for experimental TLV fields to avoid reallocations. - // The new fields (invreq_contact_secret: ~48 bytes, invreq_payer_offer: ~116 bytes) - // total ~164 bytes, with 600 providing headroom for future experimental fields. - const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 600; + // Allocate sufficient capacity for the common case of the experimental TLV fields: + // invreq_contact_secret (~40 bytes) plus a compact invreq_payer_offer (~210 bytes). + // Underestimating merely results in a reallocation. + const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 250; let mut experimental_bytes = Vec::with_capacity(EXPERIMENTAL_TLV_ALLOCATION_SIZE); let experimental_tlv_stream = @@ -714,7 +727,7 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { payer_note: Option, offer_from_hrn: Option, invreq_contact_secret: Option, - invreq_payer_offer: Option>, + invreq_payer_offer: Option, #[cfg(test)] experimental_bar: Option, } @@ -782,8 +795,9 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { $contents.contact_secret() } - /// Returns the payer offer if present in the invoice request. - pub fn payer_offer(&$self) -> Option { + /// Returns the payer's own offer if present in the invoice request, allowing us to pay them + /// back and thereby establish a mutual contact relationship. + pub fn payer_offer(&$self) -> Option<&crate::offers::offer::Offer> { $contents.payer_offer() } } } @@ -1093,10 +1107,6 @@ macro_rules! fields_accessor { }, } = &$inner; - // Extract BLIP-42 contact information if present - let contact_secret = $self.contact_secret(); - let payer_offer = $self.payer_offer(); - InvoiceRequestFields { payer_signing_pubkey: *payer_signing_pubkey, quantity: *quantity, @@ -1106,8 +1116,8 @@ macro_rules! fields_accessor { // down to the nearest valid UTF-8 code point boundary. .map(|s| UntrustedString(string_truncate_safe(s, PAYER_NOTE_LIMIT))), human_readable_name: $self.offer_from_hrn().clone(), - contact_secret, - payer_offer, + contact_secret: $self.contact_secret(), + payer_offer: $self.payer_offer().cloned(), } } }; @@ -1228,11 +1238,8 @@ impl InvoiceRequestContents { self.inner.invreq_contact_secret } - pub(super) fn payer_offer(&self) -> Option { - self.inner - .invreq_payer_offer - .as_ref() - .and_then(|bytes| crate::offers::offer::Offer::try_from(bytes.clone()).ok()) + pub(super) fn payer_offer(&self) -> Option<&Offer> { + self.inner.invreq_payer_offer.as_ref() } pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { @@ -1282,7 +1289,7 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: self.invreq_contact_secret.as_ref(), - invreq_payer_offer: self.invreq_payer_offer.as_ref(), + invreq_payer_offer: self.invreq_payer_offer.as_ref().map(|offer| &offer.bytes), #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1534,6 +1541,11 @@ impl TryFrom for InvoiceRequestContents { return Err(Bolt12SemanticError::UnexpectedPaths); } + let invreq_payer_offer = invreq_payer_offer + .map(Offer::try_from) + .transpose() + .map_err(|_| Bolt12SemanticError::InvalidPayerOffer)?; + Ok(InvoiceRequestContents { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { payer, @@ -1581,9 +1593,9 @@ pub struct InvoiceRequestFields { /// is the responsibility of the application that owns the contacts list. pub contact_secret: Option, - /// BLIP-42: The payer's minimal offer included in the invoice request. - /// This is a compact offer (just node_id) to fit within payment onion size constraints. - pub payer_offer: Option, + /// BLIP-42: The payer's own offer included in the invoice request, which can be used to pay + /// them back and thereby establish a mutual contact relationship. + pub payer_offer: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1596,7 +1608,6 @@ pub const PAYER_NOTE_LIMIT: usize = 8; impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { - let payer_offer_bytes = self.payer_offer.as_ref().map(|offer| offer.as_ref().to_vec()); // BLIP 42 fields use odd TLV types (7, 9) so older LDK nodes reading newer // `Bolt12OfferContext.path_id` blobs silently ignore unknown fields per BOLT 1 // "odd, it's OK". TLVs 11 and 13 are reserved for invreq_payer_bip_353_{name,signature}. @@ -1606,7 +1617,7 @@ impl Writeable for InvoiceRequestFields { (2, self.quantity.map(|v| HighZeroBytesDroppedBigSize(v)), option), (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), (7, self.contact_secret, option), - (9, payer_offer_bytes.as_ref().map(|v| WithoutLength(&v[..])), option), + (9, self.payer_offer.as_ref().map(|offer| WithoutLength(offer.as_ref())), option), }); Ok(()) } @@ -1623,8 +1634,12 @@ impl Readable for InvoiceRequestFields { (9, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), }); - let payer_offer = - payer_offer_bytes.and_then(|bytes| crate::offers::offer::Offer::try_from(bytes).ok()); + // These bytes were validated as an `Offer` when the `InvoiceRequest` containing them was + // parsed, so failing to parse them here indicates the stored data was corrupted. + let payer_offer = payer_offer_bytes + .map(Offer::try_from) + .transpose() + .map_err(|_| DecodeError::InvalidValue)?; Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), @@ -1648,6 +1663,7 @@ mod tests { use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::ExpandedKey; use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT}; + use crate::offers::contacts::{ContactSecret, ContactSecrets, PAYER_OFFER_MAX_BYTES}; use crate::offers::invoice::{Bolt12Invoice, SIGNATURE_TAG as INVOICE_SIGNATURE_TAG}; use crate::offers::invoice_request::string_truncate_safe; use crate::offers::merkle::{self, SignatureTlvStreamRef, TaggedHash, TlvStream}; @@ -3230,6 +3246,101 @@ mod tests { } } + #[test] + fn builds_invoice_request_with_contact_fields() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + + let payer_offer = OfferBuilder::new(payer_pubkey()).amount_msats(1).build().unwrap(); + assert!(payer_offer.as_ref().len() <= PAYER_OFFER_MAX_BYTES); + let contact_secrets = ContactSecrets::new(ContactSecret::new([3; 32])); + + let invoice_request = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .contact_secrets(contact_secrets.clone()) + .payer_offer(&payer_offer) + .build_and_sign() + .unwrap(); + assert_eq!(invoice_request.contact_secret(), Some(*contact_secrets.primary_secret())); + assert_eq!(invoice_request.payer_offer(), Some(&payer_offer)); + + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + + let parsed = InvoiceRequest::try_from(buffer).unwrap(); + assert_eq!(parsed.contact_secret(), Some(*contact_secrets.primary_secret())); + assert_eq!(parsed.payer_offer(), Some(&payer_offer)); + } + + #[test] + fn fails_building_invoice_request_with_oversized_payer_offer() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + + let oversized_offer = OfferBuilder::new(payer_pubkey()) + .amount_msats(1) + .description("a".repeat(PAYER_OFFER_MAX_BYTES)) + .build() + .unwrap(); + assert!(oversized_offer.as_ref().len() > PAYER_OFFER_MAX_BYTES); + + match OfferBuilder::new(recipient_pubkey()) + .amount_msats(1000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .payer_offer(&oversized_offer) + .build_and_sign() + { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!(e, Bolt12SemanticError::InvalidPayerOffer), + } + } + + #[test] + fn fails_parsing_invoice_request_with_malformed_payer_offer() { + let expanded_key = ExpandedKey::new([42; 32]); + let entropy = FixedEntropy {}; + let nonce = Nonce::from_entropy_source(&entropy); + let secp_ctx = Secp256k1::new(); + let payment_id = PaymentId([1; 32]); + + let invoice_request = OfferBuilder::new(recipient_pubkey()) + .amount_msats(1000) + .build() + .unwrap() + .request_invoice(&expanded_key, nonce, &secp_ctx, payment_id) + .unwrap() + .build_and_sign() + .unwrap(); + + let mut tlv_stream = invoice_request.as_tlv_stream(); + let malformed_offer_bytes = vec![42; 32]; + tlv_stream.5.invreq_payer_offer = Some(&malformed_offer_bytes); + + let mut buffer = Vec::new(); + tlv_stream.write(&mut buffer).unwrap(); + + match InvoiceRequest::try_from(buffer) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidPayerOffer) + ), + } + } + #[test] fn test_string_truncate_safe() { // We'll correctly truncate to the nearest UTF-8 code point boundary: diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index df71e860d2d..e301ac3a2d4 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -233,6 +233,8 @@ pub enum Bolt12SemanticError { /// /// [`Refund`]: super::refund::Refund UnexpectedHumanReadableName, + /// A BLIP 42 payer offer was malformed or too large. + InvalidPayerOffer, } impl From for Bolt12ParseError { From 83de84f2bfd930e6da6257c84253b0604259b03f Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:37:19 +0200 Subject: [PATCH 08/10] offers: re-derive BLIP 42 contact keys from the offer nonce Flow-built offers with blinded paths don't carry derivable metadata: the keys behind their signing pubkey are derived from a nonce that only lives in the paths' OffersContext, so deriving contact secrets via Offer::verify_using_metadata could never succeed. Pathless offers fare no better as they advertise the node id, whose key isn't available for the raw-point ECDH BLIP 42 requires. Instead, require a blinded path in compact contact offers and hand the caller the nonce used to build them, mirroring how the async receive offer cache persists offers. compute_contact_secret then re-derives the signing keys from that nonce via Offer::verify_using_recipient_data. Co-Authored-By: Claude Fable 5 --- lightning/src/ln/channelmanager.rs | 46 ++++++++++++----------- lightning/src/offers/flow.rs | 60 ++++++++++++++---------------- lightning/src/offers/offer.rs | 8 ++-- 3 files changed, 56 insertions(+), 58 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 55b5a7e7079..33261611735 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -793,21 +793,19 @@ pub struct OptionalOfferPaymentParams { /// [`Self::contact_secrets`], only set this when the user explicitly chose to reveal their /// identity to the recipient. To keep invoice requests small enough for the recipient to /// store the contact data, the offer's encoding must not exceed [`PAYER_OFFER_MAX_BYTES`]; - /// use a long-lived offer created via [`ChannelManager::create_compact_offer_builder`]: - /// - Pass `None` for no blinded path (smallest size, for public nodes) - /// - Pass `Some(intro_node_id)` for a single blinded path (for private nodes) + /// use a long-lived offer created via [`ChannelManager::create_compact_offer_builder`]. /// /// Note that to converge on the same contact secret as the recipient, this should be the /// same offer used with [`ChannelManager::compute_contact_secret`]. /// /// # Example /// ```rust,ignore - /// // Include a compact offer with a single blinded path - /// let payer_offer = channel_manager - /// .create_compact_offer_builder(Some(trusted_peer_pubkey))? - /// .build()?; + /// // Include a compact offer with a single blinded path through a trusted peer. + /// let (builder, nonce) = channel_manager.create_compact_offer_builder(trusted_peer_pubkey)?; + /// let payer_offer = builder.build()?; /// /// let params = OptionalOfferPaymentParams { + /// contact_secrets: Some(channel_manager.compute_contact_secret(&payer_offer, nonce, &offer)?), /// payer_offer: Some(payer_offer), /// ..Default::default() /// }; @@ -14777,31 +14775,35 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } - /// Creates a compact [`OfferBuilder`] suitable for BLIP-42's `payer_offer` field. + /// Creates a compact [`OfferBuilder`] suitable for BLIP-42's `payer_offer` field, returning + /// it along with the [`Nonce`] used to derive the offer's metadata and signing pubkey. /// - /// This creates an offer with minimal size by either: - /// - Having no blinded paths when `intro_node_id` is `None` (for public nodes) - /// - Having a single one-hop blinded path when `intro_node_id` is `Some` (for private nodes) + /// The offer contains a single blinded path through `intro_node_id`, keeping its encoding + /// small enough for invoice request fields where space is limited while preserving recipient + /// privacy. The intro node must be a public peer (routable via gossip) with an outbound + /// channel. /// - /// The compact format is ideal for encoding in invoice request fields where space is limited. + /// Persist the returned [`Nonce`] alongside the built offer: it is needed by + /// [`Self::compute_contact_secret`] to re-derive the offer's signing keys. /// /// # Privacy /// - /// Uses a derived signing pubkey in the offer for recipient privacy. + /// Uses a derived signing pubkey in the offer for recipient privacy. The intro node learns + /// that we are the offer's recipient, so choose a trusted peer. /// /// # Errors /// - /// Errors if a blinded path cannot be created when `intro_node_id` is provided. + /// Errors if a blinded path through `intro_node_id` cannot be created. /// /// [`Offer`]: crate::offers::offer::Offer pub fn create_compact_offer_builder( - &$self, intro_node_id: Option, - ) -> Result<$builder, Bolt12SemanticError> { - let builder = $self.flow.create_compact_offer_builder( + &$self, intro_node_id: PublicKey, + ) -> Result<($builder, Nonce), Bolt12SemanticError> { + let (builder, nonce) = $self.flow.create_compact_offer_builder( &$self.entropy_source, intro_node_id )?; - Ok(builder.into()) + Ok((builder.into(), nonce)) } } } @@ -15121,8 +15123,8 @@ impl< /// Computes the BLIP 42 contact secret shared between us and a contact, deterministically /// derived from one of our offers and the contact's offer. /// - /// `our_offer` must have been created by this [`ChannelManager`] (e.g., via - /// [`Self::create_offer_builder`] or [`Self::create_compact_offer_builder`]) so that the keys + /// `our_offer` must have been created by this [`ChannelManager`] with blinded paths and + /// `our_offer_nonce` (e.g., via [`Self::create_compact_offer_builder`]) so that the keys /// behind its signing pubkey can be re-derived. Use this when adding a contact that hasn't /// paid us before; when they paid us first, use [`ContactSecrets::from_remote_secret`] with /// the secret they sent instead. @@ -15130,9 +15132,9 @@ impl< /// The returned secrets may be included in an invoice request via /// [`OptionalOfferPaymentParams::contact_secrets`]. pub fn compute_contact_secret( - &self, our_offer: &Offer, their_offer: &Offer, + &self, our_offer: &Offer, our_offer_nonce: Nonce, their_offer: &Offer, ) -> Result { - self.flow.compute_contact_secret(our_offer, their_offer) + self.flow.compute_contact_secret(our_offer, our_offer_nonce, their_offer) } #[rustfmt::skip] diff --git a/lightning/src/offers/flow.rs b/lightning/src/offers/flow.rs index 45af9aab3af..f4cf542d230 100644 --- a/lightning/src/offers/flow.rs +++ b/lightning/src/offers/flow.rs @@ -576,61 +576,57 @@ impl OffersMessageFlow { Ok((builder.into(), nonce)) } - /// Creates a minimal [`OfferBuilder`] with derived metadata and an optional blinded path. + /// Creates a minimal [`OfferBuilder`] with derived metadata and a single blinded path through + /// `intro_node_id`, returning it along with the [`Nonce`] used to derive the offer's metadata + /// and signing pubkey. /// - /// If `intro_node_id` is `None`, creates an offer with no blinded paths (~70 bytes) suitable - /// for scenarios like BLIP-42 where the payer intentionally shares their contact info. + /// The resulting offer (~200 bytes) is suitable as a BLIP 42 payer offer, which must stay + /// below [`PAYER_OFFER_MAX_BYTES`]. The intro node must be a public peer (routable via + /// gossip) with an outbound channel. /// - /// If `intro_node_id` is `Some`, creates an offer with a single blinded path (~200 bytes) - /// providing privacy/routability for unannounced nodes. The intro node must be a public - /// peer (routable via gossip) with an outbound channel. + /// Persist the returned [`Nonce`] alongside the built offer: it is needed by + /// [`Self::compute_contact_secret`] to re-derive the offer's signing keys. /// /// # Privacy /// - /// - `None`: Exposes the derived signing pubkey directly without blinded path privacy - /// - `Some`: Intro node learns payer identity (choose trusted/routable peer) + /// The intro node learns that we are the offer's recipient, so choose a trusted peer. /// /// This is not exported to bindings users as builder patterns don't map outside of move semantics. + /// + /// [`PAYER_OFFER_MAX_BYTES`]: crate::offers::contacts::PAYER_OFFER_MAX_BYTES pub fn create_compact_offer_builder( - &self, entropy_source: ES, intro_node_id: Option, - ) -> Result, Bolt12SemanticError> { - match intro_node_id { - None => { - // Use the internal builder but don't add any paths - self.create_offer_builder_intern(&entropy_source, |_, _, _| Ok(core::iter::empty())) - .map(|(builder, _)| builder) - }, - Some(node_id) => { - // Delegate to create_offer_builder with a single-peer list to reuse the router logic - self.create_offer_builder( - entropy_source, - vec![MessageForwardNode { node_id, short_channel_id: None }], - ) - }, - } + &self, entropy_source: ES, intro_node_id: PublicKey, + ) -> Result<(OfferBuilder<'_, DerivedMetadata, secp256k1::All>, Nonce), Bolt12SemanticError> { + self.create_offer_builder_intern(&entropy_source, |_, context, _| { + let peers = vec![MessageForwardNode { node_id: intro_node_id, short_channel_id: None }]; + self.create_blinded_paths(peers, context) + .map(|paths| paths.into_iter().take(1)) + .map_err(|_| Bolt12SemanticError::MissingPaths) + }) } /// Computes the BLIP 42 contact secret shared between us and a contact, deterministically /// derived from one of our offers and the contact's offer. /// - /// `our_offer` must have been created by this flow (e.g., via [`Self::create_offer_builder`] - /// or [`Self::create_compact_offer_builder`]) so that the keys behind its signing pubkey can - /// be re-derived. Use this when adding a contact that hasn't paid us before; when they paid - /// us first, use [`ContactSecrets::from_remote_secret`] with the secret they sent instead. + /// `our_offer` must have been created by this flow with blinded paths and `our_offer_nonce` + /// (e.g., via [`Self::create_compact_offer_builder`]) so that the keys behind its signing + /// pubkey can be re-derived. Use this when adding a contact that hasn't paid us before; when + /// they paid us first, use [`ContactSecrets::from_remote_secret`] with the secret they sent + /// instead. /// /// # Errors /// /// Returns [`Bolt12SemanticError::InvalidMetadata`] if `our_offer` was not created by this - /// flow, and [`Bolt12SemanticError::MissingSigningPubkey`] if `their_offer` has neither an - /// issuer signing pubkey nor a blinded path. + /// flow using `our_offer_nonce`, and [`Bolt12SemanticError::MissingSigningPubkey`] if + /// `their_offer` has neither an issuer signing pubkey nor a blinded path. pub fn compute_contact_secret( - &self, our_offer: &Offer, their_offer: &Offer, + &self, our_offer: &Offer, our_offer_nonce: Nonce, their_offer: &Offer, ) -> Result { let expanded_key = &self.inbound_payment_key; let secp_ctx = &self.secp_ctx; let keys = our_offer - .derive_issuer_signing_keys(expanded_key, secp_ctx) + .derive_issuer_signing_keys(our_offer_nonce, expanded_key, secp_ctx) .map_err(|()| Bolt12SemanticError::InvalidMetadata)?; compute_contact_secret(secp_ctx, &keys.secret_key(), their_offer) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 2cdce7c3147..2034fdd3251 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -765,13 +765,13 @@ impl Offer { } /// Re-derives the keys used to sign invoices for this offer, if the offer's signing pubkey - /// was derived from `key` (i.e., the offer was built using `deriving_signing_pubkey` with - /// metadata included). + /// was derived from `key` and `nonce` (i.e., the offer was built using + /// `deriving_signing_pubkey` and contains blinded paths). pub(super) fn derive_issuer_signing_keys( - &self, key: &ExpandedKey, secp_ctx: &Secp256k1, + &self, nonce: Nonce, key: &ExpandedKey, secp_ctx: &Secp256k1, ) -> Result { self.contents - .verify_using_metadata(&self.bytes, key, secp_ctx) + .verify_using_recipient_data(&self.bytes, nonce, key, secp_ctx) .and_then(|(_, keys)| keys.ok_or(())) } } From bb2ed7c37becc7397e8c58eadbfcabbca53c6882 Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:37:56 +0200 Subject: [PATCH 09/10] offers: enforce BLIP 42 receive rules for BIP 353 names and contacts Parse the invreq_payer_bip_353_name (2000001733) and invreq_payer_bip_353_signature (2000001735) records, rejecting invoice requests whose name comes without a signature or whose BIP 340 signature doesn't cover the invoice request's merkle root (sans the top-level signature and the record itself) under the lightning/invoice_request/invreq_payer_bip_353_signature tag. Sending names is left for follow-up work. The verified name and committed offer signing key are surfaced through InvoiceRequestFields so recipients can later check the resolved offer via PayerBip353Name::matches_offer. Also add InvoiceRequestFields::payer_contact, which applies the spec's receive rules against the caller's contacts: when the received secret matches a known contact the payer's offer and BIP 353 name are withheld by construction, preventing a leaked secret from redirecting future payments to an impersonator's offer. Wire-format coverage comes from lightning-kmp (the BLIP 42 reference implementation): LDK parses its encoded invoice requests, verifies the payer name signature, and re-serializes byte-for-byte. Co-Authored-By: Claude Fable 5 --- fuzz/src/invoice_request_deser.rs | 1 + lightning/src/ln/offers_tests.rs | 9 + lightning/src/offers/contacts.rs | 121 ++++++++- lightning/src/offers/invoice.rs | 4 + lightning/src/offers/invoice_request.rs | 335 +++++++++++++++++++++++- lightning/src/offers/refund.rs | 6 + 6 files changed, 466 insertions(+), 10 deletions(-) diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index fd3077f73a3..343ebbf0b41 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -100,6 +100,7 @@ fn build_response( human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, } }; diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 93d29500d6a..20608cf2955 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -579,6 +579,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -740,6 +741,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -824,6 +826,7 @@ fn router_modifies_payment_metadata_in_blinded_path() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: Some(expected_metadata), }); @@ -909,6 +912,7 @@ fn pays_for_offer_with_payment_metadata_in_invoice_request_context() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: Some(expected_metadata), }); @@ -1020,6 +1024,7 @@ fn pays_for_offer_without_blinded_paths() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1291,6 +1296,7 @@ fn creates_and_pays_for_offer_with_retry() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1359,6 +1365,7 @@ fn pays_bolt12_invoice_asynchronously() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1459,6 +1466,7 @@ fn creates_offer_with_blinded_path_using_unannounced_introduction_node() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -2673,6 +2681,7 @@ fn creates_and_pays_for_phantom_offer() { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); diff --git a/lightning/src/offers/contacts.rs b/lightning/src/offers/contacts.rs index 4db7372d9a7..b49a5557993 100644 --- a/lightning/src/offers/contacts.rs +++ b/lightning/src/offers/contacts.rs @@ -38,11 +38,13 @@ use crate::io::{self, Read}; use crate::ln::msgs::DecodeError; use crate::offers::offer::Offer; use crate::offers::parse::Bolt12SemanticError; +use crate::onion_message::dns_resolution::HumanReadableName; use crate::util::ser::{Readable, Writeable, Writer}; use bitcoin::hashes::cmp::fixed_time_eq; use bitcoin::hashes::{sha256, Hash, HashEngine}; +use bitcoin::secp256k1::schnorr; use bitcoin::secp256k1::Scalar; -use bitcoin::secp256k1::{self, Secp256k1, SecretKey}; +use bitcoin::secp256k1::{self, PublicKey, Secp256k1, SecretKey}; #[allow(unused_imports)] use crate::prelude::*; @@ -55,6 +57,22 @@ pub(super) const INVREQ_CONTACT_SECRET_TYPE: u64 = 2_000_001_729; /// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). pub(super) const INVREQ_PAYER_OFFER_TYPE: u64 = 2_000_001_731; +/// TLV record type for the `invreq_payer_bip_353_name` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_PAYER_BIP_353_NAME_TYPE: u64 = 2_000_001_733; + +/// TLV record type for the `invreq_payer_bip_353_signature` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md). +pub(super) const INVREQ_PAYER_BIP_353_SIGNATURE_TYPE: u64 = 2_000_001_735; + +/// Tag of the [`TaggedHash`] signed by `invreq_payer_bip_353_signature`, covering all invoice +/// request TLV records except the top-level signature and the `invreq_payer_bip_353_signature` +/// record itself. +/// +/// [`TaggedHash`]: crate::offers::merkle::TaggedHash +pub(super) const PAYER_BIP_353_SIGNATURE_TAG: &'static str = + concat!("lightning", "invoice_request", "invreq_payer_bip_353_signature"); + /// The maximum encoded size of an [`Offer`] used as a payer offer. /// /// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md) recommends keeping @@ -185,6 +203,101 @@ impl ContactSecrets { } } +/// The result of matching the BLIP 42 contact fields of a received payment against the +/// recipient's known contacts, returned by [`InvoiceRequestFields::payer_contact`]. +/// +/// [`InvoiceRequestFields::payer_contact`]: crate::offers::invoice_request::InvoiceRequestFields::payer_contact +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PayerContact<'a> { + /// The payment came from the contact whose [`ContactSecrets`] sits at the given index of the + /// list the fields were matched against. + /// + /// Per BLIP 42, the payer offer and BIP 353 name possibly included in the payment have been + /// discarded: acting on them could let a malicious node that obtained the contact's secret + /// redirect future payments to its own offer. + Known { + /// Index into the list of known contacts passed to + /// [`InvoiceRequestFields::payer_contact`]. + /// + /// [`InvoiceRequestFields::payer_contact`]: crate::offers::invoice_request::InvoiceRequestFields::payer_contact + index: usize, + }, + /// The payment came from a payer not found among the known contacts who revealed their + /// contact details, which may be used to add them as a new contact after user authorization. + New { + /// The contact secret included by the payer. If the user attributes this payment to an + /// existing contact, store the secret with that contact via + /// [`ContactSecrets::add_remote_secret`]; when adding a new contact instead, use + /// [`ContactSecrets::from_remote_secret`]. + contact_secret: ContactSecret, + /// The payer's own offer, which can be used to pay them back. + payer_offer: Option<&'a Offer>, + /// The payer's BIP 353 name, which can be used to pay them back once verified via + /// [`PayerBip353Name::matches_offer`]. + payer_bip_353_name: Option<&'a PayerBip353Name>, + }, +} + +/// The BIP 353 human-readable name a payer revealed in an invoice request, along with the offer +/// signing key they committed to in the accompanying `invreq_payer_bip_353_signature`. +/// +/// While the signature proves that the payer controls `offer_signing_key`, it does NOT prove +/// that the name actually belongs to them. To verify ownership, resolve the name via BIP 353 +/// and check that the resulting offer is signed with the committed key using +/// [`Self::matches_offer`]. The resolution is intentionally deferred until after the payment is +/// received so that unpaid invoice requests cannot be used to trigger DNS queries as a DoS +/// vector. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PayerBip353Name { + /// The BIP 353 human-readable name of the payer. + pub name: HumanReadableName, + /// The key the payer used to sign the invoice request, expected to be the signing key of + /// the offer obtained by resolving [`Self::name`]. + pub offer_signing_key: PublicKey, +} + +impl PayerBip353Name { + /// Verifies that the offer obtained by resolving [`Self::name`] via BIP 353 is controlled by + /// the key that signed the invoice request. + /// + /// If this returns `false`, either the name doesn't belong to the payer or they changed the + /// signing key of the offer associated with it. Since the latter should be infrequent, the + /// payer is more likely to be malicious and should not be stored as a contact. + pub fn matches_offer(&self, offer: &Offer) -> bool { + if offer.issuer_signing_pubkey() == Some(self.offer_signing_key) { + return true; + } + offer.paths().iter().any(|path| { + path.blinded_hops().last().map(|hop| hop.blinded_node_id) + == Some(self.offer_signing_key) + }) + } +} + +/// The contents of the `invreq_payer_bip_353_signature` field defined in +/// [BLIP 42](https://github.com/lightning/blips/blob/master/blip-0042.md): the offer signing +/// key the payer claims to control and a signature of the invoice request made with it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct PayerBip353Signature { + pub(super) offer_signing_key: PublicKey, + pub(super) signature: schnorr::Signature, +} + +impl Readable for PayerBip353Signature { + fn read(r: &mut R) -> Result { + let offer_signing_key = Readable::read(r)?; + let signature = Readable::read(r)?; + Ok(PayerBip353Signature { offer_signing_key, signature }) + } +} + +impl Writeable for PayerBip353Signature { + fn write(&self, w: &mut W) -> Result<(), io::Error> { + self.offer_signing_key.write(w)?; + self.signature.write(w) + } +} + /// We derive our contact secret deterministically based on our offer and our contact's offer. /// /// This provides a few interesting properties: @@ -202,16 +315,16 @@ impl ContactSecrets { /// * `our_offer_signing_key` - The private key behind our offer's `offer_node_id`, i.e. its /// issuer signing pubkey if set, otherwise the final `blinded_node_id` of its first path. /// For offers whose signing pubkey was derived (e.g. ones built by -/// [`OffersMessageFlow::create_offer_builder`]), use +/// [`OffersMessageFlow::create_compact_offer_builder`]), use /// [`OffersMessageFlow::compute_contact_secret`] instead, which re-derives this key -/// internally. +/// internally from the offer's nonce. /// * `their_offer` - The offer from the contact /// /// # Errors /// Returns [`Bolt12SemanticError::MissingSigningPubkey`] if their offer has neither an /// issuer signing key nor a blinded path. /// -/// [`OffersMessageFlow::create_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_offer_builder +/// [`OffersMessageFlow::create_compact_offer_builder`]: crate::offers::flow::OffersMessageFlow::create_compact_offer_builder /// [`OffersMessageFlow::compute_contact_secret`]: crate::offers::flow::OffersMessageFlow::compute_contact_secret pub fn compute_contact_secret( secp_ctx: &Secp256k1, our_offer_signing_key: &SecretKey, their_offer: &Offer, diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 36649c95a62..bfdcfb63e66 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -2045,6 +2045,8 @@ mod tests { experimental_bar: None, invreq_contact_secret: None, invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), @@ -2152,6 +2154,8 @@ mod tests { experimental_bar: None, invreq_contact_secret: None, invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index c7ccbeb748f..5193911fbcb 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -72,7 +72,10 @@ use crate::ln::channelmanager::PaymentId; use crate::ln::inbound_payment::{ExpandedKey, IV_LEN}; use crate::ln::msgs::DecodeError; use crate::offers::contacts::{ - ContactSecret, INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_OFFER_TYPE, PAYER_OFFER_MAX_BYTES, + ContactSecret, ContactSecrets, PayerBip353Name, PayerBip353Signature, PayerContact, + INVREQ_CONTACT_SECRET_TYPE, INVREQ_PAYER_BIP_353_NAME_TYPE, + INVREQ_PAYER_BIP_353_SIGNATURE_TYPE, INVREQ_PAYER_OFFER_TYPE, PAYER_BIP_353_SIGNATURE_TAG, + PAYER_OFFER_MAX_BYTES, }; use crate::offers::invoice::{DerivedSigningPubkey, ExplicitSigningPubkey, SigningPubkeyStrategy}; use crate::offers::merkle::{ @@ -190,6 +193,9 @@ macro_rules! invoice_request_builder_methods { ( payer: PayerContents(metadata), offer, chain: None, amount_msats: None, features: InvoiceRequestFeatures::empty(), quantity: None, payer_note: None, offer_from_hrn: None, invreq_contact_secret: None, invreq_payer_offer: None, + // Always `None` when building: LDK supports receiving BIP 353 payer names but not + // yet revealing its own. + invreq_payer_bip_353_name: None, invreq_payer_bip_353_signature: None, #[cfg(test)] experimental_bar: None, } @@ -728,6 +734,8 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { offer_from_hrn: Option, invreq_contact_secret: Option, invreq_payer_offer: Option, + invreq_payer_bip_353_name: Option, + invreq_payer_bip_353_signature: Option, #[cfg(test)] experimental_bar: Option, } @@ -800,6 +808,16 @@ macro_rules! invoice_request_accessors { ($self: ident, $contents: expr) => { pub fn payer_offer(&$self) -> Option<&crate::offers::offer::Offer> { $contents.payer_offer() } + + /// Returns the BIP 353 name the payer revealed in the invoice request, allowing us to pay + /// them back and thereby establish a mutual contact relationship. + /// + /// The accompanying signature has already been verified against the contained + /// [`PayerBip353Name::offer_signing_key`], but the name itself is unverified until the offer + /// it resolves to is checked with [`PayerBip353Name::matches_offer`]. + pub fn payer_bip_353_name(&$self) -> Option { + $contents.payer_bip_353_name() + } } } impl UnsignedInvoiceRequest { @@ -1118,6 +1136,7 @@ macro_rules! fields_accessor { human_readable_name: $self.offer_from_hrn().clone(), contact_secret: $self.contact_secret(), payer_offer: $self.payer_offer().cloned(), + payer_bip_353_name: $self.payer_bip_353_name(), } } }; @@ -1242,6 +1261,12 @@ impl InvoiceRequestContents { self.inner.invreq_payer_offer.as_ref() } + pub(super) fn payer_bip_353_name(&self) -> Option { + let name = self.inner.invreq_payer_bip_353_name.clone()?; + let signature = self.inner.invreq_payer_bip_353_signature.as_ref()?; + Some(PayerBip353Name { name, offer_signing_key: signature.offer_signing_key }) + } + pub(super) fn as_tlv_stream(&self) -> PartialInvoiceRequestTlvStreamRef<'_> { let (payer, offer, mut invoice_request, experimental_offer, experimental_invoice_request) = self.inner.as_tlv_stream(); @@ -1290,6 +1315,8 @@ impl InvoiceRequestContentsWithoutPayerSigningPubkey { let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: self.invreq_contact_secret.as_ref(), invreq_payer_offer: self.invreq_payer_offer.as_ref().map(|offer| &offer.bytes), + invreq_payer_bip_353_name: self.invreq_payer_bip_353_name.as_ref(), + invreq_payer_bip_353_signature: self.invreq_payer_bip_353_signature.as_ref(), #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -1361,6 +1388,8 @@ tlv_stream!( { (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_PAYER_BIP_353_NAME_TYPE, invreq_payer_bip_353_name: HumanReadableName), + (INVREQ_PAYER_BIP_353_SIGNATURE_TYPE, invreq_payer_bip_353_signature: PayerBip353Signature), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1372,6 +1401,8 @@ tlv_stream!( EXPERIMENTAL_INVOICE_REQUEST_TYPES, { (INVREQ_CONTACT_SECRET_TYPE, invreq_contact_secret: ContactSecret), (INVREQ_PAYER_OFFER_TYPE, invreq_payer_offer: (Vec, WithoutLength)), + (INVREQ_PAYER_BIP_353_NAME_TYPE, invreq_payer_bip_353_name: HumanReadableName), + (INVREQ_PAYER_BIP_353_SIGNATURE_TYPE, invreq_payer_bip_353_signature: PayerBip353Signature), (2_999_999_999, experimental_bar: (u64, HighZeroBytesDroppedBigSize)), } ); @@ -1483,6 +1514,20 @@ impl TryFrom> for InvoiceRequest { let message = TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &bytes); merkle::verify_signature(&signature, &message, contents.payer_signing_pubkey)?; + // Per BLIP 42, `invreq_payer_bip_353_signature` covers every invoice request record + // except the top-level signature (excluded by the merkle root computation) and the + // `invreq_payer_bip_353_signature` record itself. + if let Some(payer_bip_353_signature) = &contents.inner.invreq_payer_bip_353_signature { + let tlv_stream = TlvStream::new(&bytes) + .filter(|record| record.r#type != INVREQ_PAYER_BIP_353_SIGNATURE_TYPE); + let message = TaggedHash::from_tlv_stream(PAYER_BIP_353_SIGNATURE_TAG, tlv_stream); + merkle::verify_signature( + &payer_bip_353_signature.signature, + &message, + payer_bip_353_signature.offer_signing_key, + )?; + } + Ok(InvoiceRequest { bytes, contents, signature }) } } @@ -1508,6 +1553,8 @@ impl TryFrom for InvoiceRequestContents { ExperimentalInvoiceRequestTlvStream { invreq_contact_secret, invreq_payer_offer, + invreq_payer_bip_353_name, + invreq_payer_bip_353_signature, #[cfg(test)] experimental_bar, }, @@ -1546,6 +1593,12 @@ impl TryFrom for InvoiceRequestContents { .transpose() .map_err(|_| Bolt12SemanticError::InvalidPayerOffer)?; + // Per BLIP 42, an invoice request revealing a BIP 353 name MUST be ignored if it doesn't + // prove ownership of an offer signing key via `invreq_payer_bip_353_signature`. + if invreq_payer_bip_353_name.is_some() && invreq_payer_bip_353_signature.is_none() { + return Err(Bolt12SemanticError::MissingSignature); + } + Ok(InvoiceRequestContents { inner: InvoiceRequestContentsWithoutPayerSigningPubkey { payer, @@ -1558,6 +1611,8 @@ impl TryFrom for InvoiceRequestContents { offer_from_hrn, invreq_contact_secret, invreq_payer_offer, + invreq_payer_bip_353_name, + invreq_payer_bip_353_signature, #[cfg(test)] experimental_bar, }, @@ -1588,14 +1643,23 @@ pub struct InvoiceRequestFields { /// This allows the recipient to establish a contact relationship with the payer. /// /// Per BLIP 42, if this matches an existing contact of the recipient, the recipient MUST - /// ignore [`Self::payer_offer`] (and any future `bip_353_name` field) to prevent a leaked - /// `contact_secret` from being used to redirect future payments. Enforcement of this rule - /// is the responsibility of the application that owns the contacts list. + /// ignore [`Self::payer_offer`] and [`Self::payer_bip_353_name`] to prevent a leaked + /// `contact_secret` from being used to redirect future payments. [`Self::payer_contact`] + /// applies this rule against the contacts list owned by the application. pub contact_secret: Option, /// BLIP-42: The payer's own offer included in the invoice request, which can be used to pay /// them back and thereby establish a mutual contact relationship. pub payer_offer: Option, + + /// BLIP-42: The BIP 353 name the payer revealed in the invoice request, which can be used to + /// pay them back and thereby establish a mutual contact relationship. + /// + /// The signature committing to [`PayerBip353Name::offer_signing_key`] was verified when the + /// invoice request was parsed, but the name remains unverified until the offer it resolves to + /// is checked with [`PayerBip353Name::matches_offer`]. As with [`Self::payer_offer`], this + /// MUST be ignored if [`Self::contact_secret`] matches an existing contact. + pub payer_bip_353_name: Option, } /// The maximum number of characters included in [`InvoiceRequestFields::payer_note_truncated`]. @@ -1606,11 +1670,36 @@ pub const PAYER_NOTE_LIMIT: usize = 512; #[cfg(fuzzing)] pub const PAYER_NOTE_LIMIT: usize = 8; +impl InvoiceRequestFields { + /// Matches the BLIP 42 contact fields included by the payer against `known_contacts`, + /// applying the spec's rules for handling them. + /// + /// Returns `None` if the payer chose not to reveal their identity. Otherwise, identifies the + /// known contact the payment came from, or surfaces the payer's details for adding them as a + /// new contact. In the former case the payer's offer and BIP 353 name are deliberately + /// withheld: per BLIP 42 they MUST be ignored so that a leaked contact secret cannot be used + /// to redirect future payments to an impersonator's offer. + pub fn payer_contact<'a>( + &'a self, known_contacts: &[ContactSecrets], + ) -> Option> { + let contact_secret = self.contact_secret?; + match known_contacts.iter().position(|contact| contact.matches(&contact_secret)) { + Some(index) => Some(PayerContact::Known { index }), + None => Some(PayerContact::New { + contact_secret, + payer_offer: self.payer_offer.as_ref(), + payer_bip_353_name: self.payer_bip_353_name.as_ref(), + }), + } + } +} + impl Writeable for InvoiceRequestFields { fn write(&self, writer: &mut W) -> Result<(), io::Error> { - // BLIP 42 fields use odd TLV types (7, 9) so older LDK nodes reading newer + // BLIP 42 fields use odd TLV types (7, 9, 11, 13) so older LDK nodes reading newer // `Bolt12OfferContext.path_id` blobs silently ignore unknown fields per BOLT 1 - // "odd, it's OK". TLVs 11 and 13 are reserved for invreq_payer_bip_353_{name,signature}. + // "odd, it's OK". The payer's BIP 353 signature is dropped after verification; only the + // offer signing key it committed to is kept (13) alongside the name (11). write_tlv_fields!(writer, { (0, self.payer_signing_pubkey, required), (1, self.human_readable_name, option), @@ -1618,6 +1707,8 @@ impl Writeable for InvoiceRequestFields { (4, self.payer_note_truncated.as_ref().map(|s| WithoutLength(&s.0)), option), (7, self.contact_secret, option), (9, self.payer_offer.as_ref().map(|offer| WithoutLength(offer.as_ref())), option), + (11, self.payer_bip_353_name.as_ref().map(|pn| &pn.name), option), + (13, self.payer_bip_353_name.as_ref().map(|pn| pn.offer_signing_key), option), }); Ok(()) } @@ -1632,6 +1723,8 @@ impl Readable for InvoiceRequestFields { (4, payer_note_truncated, (option, encoding: (String, WithoutLength))), (7, contact_secret, option), (9, payer_offer_bytes, (option, encoding: (Vec, WithoutLength))), + (11, payer_bip_353_name, option), + (13, payer_bip_353_offer_signing_key, option), }); // These bytes were validated as an `Offer` when the `InvoiceRequest` containing them was @@ -1641,6 +1734,16 @@ impl Readable for InvoiceRequestFields { .transpose() .map_err(|_| DecodeError::InvalidValue)?; + // Both fields were written together, so a lone one indicates the stored data was + // corrupted. + let payer_bip_353_name = match (payer_bip_353_name, payer_bip_353_offer_signing_key) { + (Some(name), Some(offer_signing_key)) => { + Some(PayerBip353Name { name, offer_signing_key }) + }, + (None, None) => None, + _ => return Err(DecodeError::InvalidValue), + }; + Ok(InvoiceRequestFields { payer_signing_pubkey: payer_signing_pubkey.0.unwrap(), quantity, @@ -1648,6 +1751,7 @@ impl Readable for InvoiceRequestFields { human_readable_name, contact_secret, payer_offer, + payer_bip_353_name, }) } } @@ -1668,6 +1772,7 @@ mod tests { use crate::offers::invoice_request::string_truncate_safe; use crate::offers::merkle::{self, SignatureTlvStreamRef, TaggedHash, TlvStream}; use crate::offers::nonce::Nonce; + use crate::offers::offer::Offer; #[cfg(not(c_bindings))] use crate::offers::offer::OfferBuilder; #[cfg(c_bindings)] @@ -1685,6 +1790,7 @@ mod tests { use bitcoin::network::Network; use bitcoin::secp256k1::{self, Keypair, Secp256k1, SecretKey}; use core::num::NonZeroU64; + use core::str::FromStr; #[cfg(feature = "std")] use core::time::Duration; @@ -1771,6 +1877,8 @@ mod tests { ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: None, invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, experimental_bar: None, }, ), @@ -3232,6 +3340,7 @@ mod tests { human_readable_name: None, contact_secret: None, payer_offer: None, + payer_bip_353_name: None, } ); @@ -3341,6 +3450,220 @@ mod tests { } } + /// Decodes one of the BOLT 12 bech32 strings (without checksum) used by the cross-compat + /// vectors below. + fn bech32_decode(encoded: &str) -> Vec { + use bech32::primitives::decode::CheckedHrpstring; + use bech32::NoChecksum; + CheckedHrpstring::new::(encoded).unwrap().byte_iter().collect() + } + + /// An invoice request carrying `invreq_contact_secret` and `invreq_payer_offer`, taken from + /// lightning-kmp's `OfferTypesTestsCommon` (the BLIP 42 reference implementation). + const KMP_INVREQ_WITH_CONTACT_OFFER: &str = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqgn3qkppqfufxgalt7nkrherkhnepnxn65z9yn7mknwtcf4d35gjj8q5zu26duzq2leeneh9myzzspr3wdx89vlfdtqc0w3w83j0tw8f73yvzcnzh5c5ecllf8s57unc7rud9zvy88gxd5nanxt6rjeata6dcnzarvacx9l7wu6e4sfq766scfgzvlp0fvp5v86230hwz99zuc5xywscek562j7hxjx0qzz0uae4ntplqq3qgdyhl4lcy62hzz855v8annkr46a8n9eqsn5satgpagesjqqqqqqppnqrjvugf2h366csswt7tml9ep4u7tvv4rf0wq8d4xwmjg20cfcjky6q9q7uccqs0l3etux0vcuzgpje7mye73a3k7hxysg694jj8rlmmgwzqgpwh4nf23k53cppx0ahd38nca0aujvcrkhrmv8aeax2lpkrc6ua0lqqxv3lmpuk78jqdrjlya0v8avapm7zagkvzu6aa7j787wecd20xml5zteu4erklvnlk0vtxp4y8pe4hjjh9x7syd98rehawsuwq8pfxxq0t56e5felm3s8ly68m33azdheystd29slqqgg4kgw54epcrx5gyzfxrshmgm7v"; + + /// The payer offer embedded in [`KMP_INVREQ_WITH_CONTACT_OFFER`]. + const KMP_PAYER_OFFER: &str = "lno1qgsyxjtl6luzd9t3pr62xr7eemp6awnejusgf6gw45q75vcfqqqqqqqsespexwyy4tcadvgg89l9aljus6709kx235hhqrk6n8dey98uyuftzdqzs0wvvqg8lcu47r8kvwpyqevldjvlg7cm0tnzgydz6efr3laa58pqyqht6e54gm2guqsn87mkcneuwh77fxvpmt3akr7u7n90smpudwwhlsqrxglas7t0reqx3e0jwhkr7kwsalpw5txpwdw7lf0rl8vux48ndl6p9u72u3m0kflm8k9nq6jrsu6meftjn0gzxjn3um7hgw8qrs5nrq846dv6yulaccrljdracc73xmujg9k4zc0sqyy2my822usupn2yzpynpcta5dlx"; + + /// An invoice request carrying `invreq_contact_secret`, `invreq_payer_bip_353_name`, and + /// `invreq_payer_bip_353_signature`, taken from lightning-kmp's `OfferTypesTestsCommon` (the + /// BLIP 42 reference implementation). + const KMP_INVREQ_WITH_CONTACT_ADDRESS: &str = "lnr1qqs2xatpv5dg977k3wwzgdv473dfhwm2jp5qscyyj7zzm6cwy3vg6cgkyypsmuhrtwfzm85mht4a3vcp0yrlgua3u3m5uqpc6kf7nqjz6v70qw2jqy49sggrsehtg7l3jphg6z9mymtz7vrun08h7y40nr3cfqytdswkmax83nc0qs9agk3m5459qfcj566q2hjmla5vvguasm8rvgch64had2gxkqttpzvx360kyvyav4l0gvxlqd5rmjm99shhyazvt26qzn7t4g4g2cfgmlnhxkdvzg8l9dmwedtfcdlvjzgv5485hyemqxrwuj82ksamgrsh4axcwu9ya8l8wdv6c5gswurgdajku6tcppskx6twwyhxxml7wu6e43mpqgjyrc6tvlnz8j96sfh0redhhykftsmu88mtqnlkk79uz5lwjhujn7tyga42vxqfw3qhrc338p694334cktpw5fkkl26xale4uhslhh2aq4cjrfdxp279m44q3k96ly54m6lquwqm9ndfffwyc8ru53d6djq"; + + /// The private key behind the payer signing pubkey of [`KMP_INVREQ_WITH_CONTACT_ADDRESS`]. + const KMP_CONTACT_ADDRESS_PAYER_KEY: &str = + "bc8c43b545f07b95a57577a4725065a657fa4831cb95d910970a50eb88949a7e"; + + /// The private key behind the offer signing key committed in the BIP 353 signature of + /// [`KMP_INVREQ_WITH_CONTACT_ADDRESS`]. + const KMP_CONTACT_ADDRESS_OFFER_KEY: &str = + "2eb661efb156b9fd7f4b8cf3b13cd6ed809d18cf6a38b593ff8d8ec9be2a4db5"; + + #[test] + fn parses_invoice_request_with_contact_offer_cross_compat_vector() { + use bitcoin::hex::FromHex; + + let bytes = bech32_decode(KMP_INVREQ_WITH_CONTACT_OFFER); + let invoice_request = InvoiceRequest::try_from(bytes.clone()).unwrap(); + + let expected_secret = <[u8; 32]>::from_hex( + "f6b50c250267c2f4b03461f4a8beee114a2e628623a18cda9a54bd7348cf0084", + ) + .unwrap(); + assert_eq!(invoice_request.contact_secret(), Some(ContactSecret::new(expected_secret))); + + let payer_offer = KMP_PAYER_OFFER.parse::().unwrap(); + assert_eq!(invoice_request.payer_offer(), Some(&payer_offer)); + assert_eq!(invoice_request.payer_bip_353_name(), None); + + // The parsed invoice request must re-serialize to the exact bytes it was parsed from. + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + assert_eq!(buffer, bytes); + } + + #[test] + fn parses_invoice_request_with_contact_address_cross_compat_vector() { + use bitcoin::hex::FromHex; + + let secp_ctx = Secp256k1::new(); + let bytes = bech32_decode(KMP_INVREQ_WITH_CONTACT_ADDRESS); + let invoice_request = InvoiceRequest::try_from(bytes.clone()).unwrap(); + + let expected_secret = <[u8; 32]>::from_hex( + "ff2b76ecb569c37ec9090ca54f4b933b0186ee48eab43bb40e17af4d8770a4e9", + ) + .unwrap(); + assert_eq!(invoice_request.contact_secret(), Some(ContactSecret::new(expected_secret))); + assert_eq!(invoice_request.payer_offer(), None); + + let offer_key = + SecretKey::from_str(KMP_CONTACT_ADDRESS_OFFER_KEY).unwrap().public_key(&secp_ctx); + let payer_bip_353_name = invoice_request.payer_bip_353_name().unwrap(); + assert_eq!(payer_bip_353_name.name.user(), "phoenix"); + assert_eq!(payer_bip_353_name.name.domain(), "acinq.co"); + assert_eq!(payer_bip_353_name.offer_signing_key, offer_key); + + // The parsed invoice request must re-serialize to the exact bytes it was parsed from. + let mut buffer = Vec::new(); + invoice_request.write(&mut buffer).unwrap(); + assert_eq!(buffer, bytes); + } + + /// Replaces the BIP 353 signature of [`KMP_INVREQ_WITH_CONTACT_ADDRESS`] and signs the result + /// with the vector's payer key, producing an otherwise valid invoice request. + fn invoice_request_with_modified_bip_353_signature( + bip_353_signature: Option<&crate::offers::contacts::PayerBip353Signature>, + ) -> Result { + let secp_ctx = Secp256k1::new(); + let bytes = bech32_decode(KMP_INVREQ_WITH_CONTACT_ADDRESS); + let invoice_request = InvoiceRequest::try_from(bytes).unwrap(); + + let mut tlv_stream = invoice_request.as_tlv_stream(); + tlv_stream.5.invreq_payer_bip_353_signature = bip_353_signature; + + let mut unsigned_bytes = Vec::new(); + tlv_stream.0.write(&mut unsigned_bytes).unwrap(); + tlv_stream.1.write(&mut unsigned_bytes).unwrap(); + tlv_stream.2.write(&mut unsigned_bytes).unwrap(); + tlv_stream.4.write(&mut unsigned_bytes).unwrap(); + tlv_stream.5.write(&mut unsigned_bytes).unwrap(); + + let payer_key = SecretKey::from_str(KMP_CONTACT_ADDRESS_PAYER_KEY).unwrap(); + let keys = Keypair::from_secret_key(&secp_ctx, &payer_key); + let message = TaggedHash::from_valid_tlv_stream_bytes(SIGNATURE_TAG, &unsigned_bytes); + let signature = secp_ctx.sign_schnorr_no_aux_rand(message.as_digest(), &keys); + + let mut buffer = Vec::new(); + tlv_stream.0.write(&mut buffer).unwrap(); + tlv_stream.1.write(&mut buffer).unwrap(); + tlv_stream.2.write(&mut buffer).unwrap(); + SignatureTlvStreamRef { signature: Some(&signature) }.write(&mut buffer).unwrap(); + tlv_stream.4.write(&mut buffer).unwrap(); + tlv_stream.5.write(&mut buffer).unwrap(); + + InvoiceRequest::try_from(buffer) + } + + #[test] + fn fails_parsing_invoice_request_with_invalid_bip_353_signature() { + let secp_ctx = Secp256k1::new(); + let offer_key = SecretKey::from_str(KMP_CONTACT_ADDRESS_OFFER_KEY).unwrap(); + + // Sign an unrelated digest with the committed key, yielding a well-formed signature that + // doesn't cover the invoice request. + let keys = Keypair::from_secret_key(&secp_ctx, &offer_key); + let digest = bitcoin::secp256k1::Message::from_digest([42; 32]); + let bogus_signature = secp_ctx.sign_schnorr_no_aux_rand(&digest, &keys); + let bogus = crate::offers::contacts::PayerBip353Signature { + offer_signing_key: offer_key.public_key(&secp_ctx), + signature: bogus_signature, + }; + + match invoice_request_with_modified_bip_353_signature(Some(&bogus)) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSignature(secp256k1::Error::IncorrectSignature) + ), + } + } + + #[test] + fn fails_parsing_invoice_request_with_missing_bip_353_signature() { + match invoice_request_with_modified_bip_353_signature(None) { + Ok(_) => panic!("expected error"), + Err(e) => assert_eq!( + e, + Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingSignature) + ), + } + } + + #[test] + fn invoice_request_fields_round_trip_with_bip_353_name() { + use crate::io; + use crate::offers::contacts::PayerBip353Name; + use crate::onion_message::dns_resolution::HumanReadableName; + + let secp_ctx = Secp256k1::new(); + let offer_key = SecretKey::from_str(KMP_CONTACT_ADDRESS_OFFER_KEY).unwrap(); + let fields = InvoiceRequestFields { + payer_signing_pubkey: payer_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + contact_secret: Some(ContactSecret::new([3; 32])), + payer_offer: None, + payer_bip_353_name: Some(PayerBip353Name { + name: HumanReadableName::new("phoenix", "acinq.co").unwrap(), + offer_signing_key: offer_key.public_key(&secp_ctx), + }), + }; + + let mut buffer = Vec::new(); + fields.write(&mut buffer).unwrap(); + + let deserialized = InvoiceRequestFields::read(&mut io::Cursor::new(&buffer)).unwrap(); + assert_eq!(deserialized, fields); + } + + #[test] + fn applies_known_contact_rule_to_invoice_request_fields() { + use crate::offers::contacts::PayerContact; + + let payer_offer = KMP_PAYER_OFFER.parse::().unwrap(); + let contact_secret = ContactSecret::new([3; 32]); + let fields = InvoiceRequestFields { + payer_signing_pubkey: payer_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + contact_secret: Some(contact_secret), + payer_offer: Some(payer_offer.clone()), + payer_bip_353_name: None, + }; + + // Without a matching contact, the payer's details are surfaced for adding a new contact. + let other_contact = ContactSecrets::new(ContactSecret::new([1; 32])); + assert_eq!( + fields.payer_contact(core::slice::from_ref(&other_contact)), + Some(PayerContact::New { + contact_secret, + payer_offer: Some(&payer_offer), + payer_bip_353_name: None, + }) + ); + + // With a matching contact, the payer's offer MUST be withheld per BLIP 42. + let known_contacts = [other_contact, ContactSecrets::new(contact_secret)]; + assert_eq!(fields.payer_contact(&known_contacts), Some(PayerContact::Known { index: 1 })); + + // Without a contact secret, the payer didn't reveal their identity. + let anonymous = InvoiceRequestFields { contact_secret: None, ..fields }; + assert_eq!(anonymous.payer_contact(&known_contacts), None); + } + #[test] fn test_string_truncate_safe() { // We'll correctly truncate to the nearest UTF-8 code point boundary: diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 7495c559261..c3de3b4e133 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -811,6 +811,8 @@ impl RefundContents { let experimental_invoice_request = ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: None, invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, #[cfg(test)] experimental_bar: self.experimental_bar, }; @@ -931,6 +933,8 @@ impl TryFrom for RefundContents { ExperimentalInvoiceRequestTlvStream { invreq_contact_secret: _, invreq_payer_offer: _, + invreq_payer_bip_353_name: _, + invreq_payer_bip_353_signature: _, #[cfg(test)] experimental_bar, }, @@ -1120,6 +1124,8 @@ mod tests { ExperimentalInvoiceRequestTlvStreamRef { invreq_contact_secret: None, invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, experimental_bar: None, }, ), From 798e9396d2045157f5c99a3c446a91c57cb2d35b Mon Sep 17 00:00:00 2001 From: Vincenzo Palazzo Date: Wed, 10 Jun 2026 18:38:04 +0200 Subject: [PATCH 10/10] offers: exercise the BLIP 42 contact flow end-to-end Pay an offer with contact fields attached and assert they survive the trip through the invoice request, the invoice's blinded path data, and the recipient's PaymentClaimable context. The reverse payment reuses the same deterministically derived secret, letting both sides attribute each other's payments to the same contact pair. Co-Authored-By: Claude Fable 5 --- lightning/src/ln/offers_tests.rs | 146 ++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 20608cf2955..8a6567ab201 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -52,7 +52,7 @@ use crate::blinded_path::message::BlindedMessagePath; use crate::blinded_path::payment::{Bolt12OfferContext, Bolt12RefundContext, DummyTlvs, PaymentContext}; use crate::blinded_path::message::{MessageContext, OffersContext}; use crate::events::{ClosureReason, Event, HTLCHandlingFailureType, PaidBolt12Invoice, PaymentFailureReason, PaymentPurpose}; -use crate::ln::channelmanager::{PaymentId, RecentPaymentDetails, self}; +use crate::ln::channelmanager::{OptionalOfferPaymentParams, PaymentId, RecentPaymentDetails, self}; use crate::ln::outbound_payment::{Bolt12PaymentError, RecipientOnionFields, Retry}; use crate::types::features::Bolt12InvoiceFeatures; use crate::ln::functional_test_utils::*; @@ -60,6 +60,7 @@ use crate::ln::msgs::{BaseMessageHandler, ChannelMessageHandler, Init, OnionMess use crate::ln::outbound_payment::IDEMPOTENCY_TIMEOUT_TICKS; use crate::offers::invoice::Bolt12Invoice; use crate::offers::invoice_error::InvoiceError; +use crate::offers::contacts::{PayerContact, PAYER_OFFER_MAX_BYTES}; use crate::offers::invoice_request::{InvoiceRequest, InvoiceRequestFields, InvoiceRequestVerifiedFromOffer}; use crate::offers::nonce::Nonce; use crate::offers::offer::OfferBuilder; @@ -768,6 +769,149 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); } +/// Checks that the BLIP 42 contact fields a payer reveals reach the recipient through the payment +/// flow, and that payments in the opposite direction are mutually attributed to the same contact +/// pair via the deterministically derived contact secret. +#[test] +fn pays_for_offer_with_blip42_contact_fields() { + let chanmon_cfgs = create_chanmon_cfgs(2); + let node_cfgs = create_node_cfgs(2, &chanmon_cfgs); + let node_chanmgrs = create_node_chanmgrs(2, &node_cfgs, &[None, None]); + let nodes = create_network(2, &node_cfgs, &node_chanmgrs); + + create_announced_chan_between_nodes_with_value(&nodes, 0, 1, 10_000_000, 1_000_000_000); + + let alice = &nodes[0]; + let alice_id = alice.node.get_our_node_id(); + let bob = &nodes[1]; + let bob_id = bob.node.get_our_node_id(); + + let (alice_offer_builder, alice_offer_nonce) = + alice.node.create_compact_offer_builder(bob_id).unwrap(); + let alice_offer = alice_offer_builder.amount_msats(10_000_000).build().unwrap(); + + // Bob saves Alice as a contact before paying her, deriving the shared contact secret from + // his own compact offer and hers. + let (bob_offer_builder, bob_offer_nonce) = + bob.node.create_compact_offer_builder(alice_id).unwrap(); + let bob_offer = bob_offer_builder.build().unwrap(); + assert!(bob_offer.as_ref().len() <= PAYER_OFFER_MAX_BYTES); + let bob_secrets = + bob.node.compute_contact_secret(&bob_offer, bob_offer_nonce, &alice_offer).unwrap(); + + // Alice independently derives the same secret from her offer and Bob's, as both will when + // concurrently adding each other. + let alice_secrets = + alice.node.compute_contact_secret(&alice_offer, alice_offer_nonce, &bob_offer).unwrap(); + assert_eq!(alice_secrets, bob_secrets); + + let payment_id = PaymentId([1; 32]); + let optional_params = OptionalOfferPaymentParams { + contact_secrets: Some(bob_secrets.clone()), + payer_offer: Some(bob_offer.clone()), + ..Default::default() + }; + bob.node.pay_for_offer(&alice_offer, None, payment_id, optional_params).unwrap(); + expect_recent_payment!(bob, RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(alice, &onion_message); + assert_eq!(invoice_request.contact_secret(), Some(*bob_secrets.primary_secret())); + assert_eq!(invoice_request.payer_offer(), Some(&bob_offer)); + + let invoice_request_fields = InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + contact_secret: Some(*bob_secrets.primary_secret()), + payer_offer: Some(bob_offer.clone()), + payer_bip_353_name: None, + }; + + // Alice recognizes the payment as coming from her stored contact, so Bob's payer offer is + // withheld; had she not known him yet, it would have been surfaced to add him as a contact. + assert_eq!( + invoice_request_fields.payer_contact(core::slice::from_ref(&alice_secrets)), + Some(PayerContact::Known { index: 0 }) + ); + assert_eq!( + invoice_request_fields.payer_contact(&[]), + Some(PayerContact::New { + contact_secret: *bob_secrets.primary_secret(), + payer_offer: Some(&bob_offer), + payer_bip_353_name: None, + }) + ); + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: alice_offer.id(), + invoice_request: invoice_request_fields, + payment_metadata: None, + }); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice, _) = extract_invoice(bob, &onion_message); + route_bolt12_payment(bob, &[alice], &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Pending, payment_id); + + // Claiming checks that the contact fields round-trip through the blinded path data of the + // invoice's payment paths into Alice's PaymentClaimable event. + claim_bolt12_payment(bob, &[alice], payment_context, &invoice); + expect_recent_payment!(bob, RecentPaymentDetails::Fulfilled, payment_id); + + // Bob pays Alice back through the payer offer she received, reusing the same contact secret + // so that she can attribute the payment to him. + let payment_id = PaymentId([2; 32]); + let optional_params = OptionalOfferPaymentParams { + contact_secrets: Some(alice_secrets.clone()), + ..Default::default() + }; + alice.node.pay_for_offer(&bob_offer, Some(5_000_000), payment_id, optional_params).unwrap(); + expect_recent_payment!(alice, RecentPaymentDetails::AwaitingInvoice, payment_id); + + let onion_message = alice.onion_messenger.next_onion_message_for_peer(bob_id).unwrap(); + bob.onion_messenger.handle_onion_message(alice_id, &onion_message); + + let (invoice_request, _) = extract_invoice_request(bob, &onion_message); + assert_eq!(invoice_request.contact_secret(), Some(*alice_secrets.primary_secret())); + assert_eq!(invoice_request.payer_offer(), None); + + let invoice_request_fields = InvoiceRequestFields { + payer_signing_pubkey: invoice_request.payer_signing_pubkey(), + quantity: None, + payer_note_truncated: None, + human_readable_name: None, + contact_secret: Some(*alice_secrets.primary_secret()), + payer_offer: None, + payer_bip_353_name: None, + }; + assert_eq!( + invoice_request_fields.payer_contact(core::slice::from_ref(&bob_secrets)), + Some(PayerContact::Known { index: 0 }) + ); + + let payment_context = PaymentContext::Bolt12Offer(Bolt12OfferContext { + offer_id: bob_offer.id(), + invoice_request: invoice_request_fields, + payment_metadata: None, + }); + + let onion_message = bob.onion_messenger.next_onion_message_for_peer(alice_id).unwrap(); + alice.onion_messenger.handle_onion_message(bob_id, &onion_message); + + let (invoice, _) = extract_invoice(alice, &onion_message); + route_bolt12_payment(alice, &[bob], &invoice); + expect_recent_payment!(alice, RecentPaymentDetails::Pending, payment_id); + + claim_bolt12_payment(alice, &[bob], payment_context, &invoice); + expect_recent_payment!(alice, RecentPaymentDetails::Fulfilled, payment_id); +} + /// Checks that a `Router` can attach `payment_metadata` to the [`PaymentContext`] of a blinded /// payment path while building it in response to an invoice request, and that the metadata is /// surfaced back via [`Event::PaymentClaimable`] when the payment is received.