diff --git a/fuzz/src/invoice_request_deser.rs b/fuzz/src/invoice_request_deser.rs index c4b31942843..343ebbf0b41 100644 --- a/fuzz/src/invoice_request_deser.rs +++ b/fuzz/src/invoice_request_deser.rs @@ -98,6 +98,9 @@ fn build_response( .payer_note() .map(|s| UntrustedString(s.to_string())), human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, } }; diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 0ae4c87d511..33261611735 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,45 @@ pub struct OptionalOfferPaymentParams { /// will ultimately fail once all pending paths have failed (generating an /// [`Event::PaymentFailed`]). pub retry_strategy: Retry, + /// 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, + /// 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 `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`]. + /// + /// 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 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() + /// }; + /// ``` + /// + /// [`PAYER_OFFER_MAX_BYTES`]: crate::offers::contacts::PAYER_OFFER_MAX_BYTES + pub payer_offer: Option, } impl Default for OptionalOfferPaymentParams { @@ -784,6 +824,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 +14774,37 @@ macro_rules! create_offer_builder { ($self: ident, $builder: ty) => { Ok(builder.into()) } + + /// 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. + /// + /// 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. + /// + /// 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. The intro node learns + /// that we are the offer's recipient, so choose a trusted peer. + /// + /// # Errors + /// + /// 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: PublicKey, + ) -> Result<($builder, Nonce), Bolt12SemanticError> { + let (builder, nonce) = $self.flow.create_compact_offer_builder( + &$self.entropy_source, intro_node_id + )?; + + Ok((builder.into(), nonce)) + } } } macro_rules! create_refund_builder { ($self: ident, $builder: ty) => { @@ -14967,6 +15040,8 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -14996,6 +15071,8 @@ impl< payment_id, Some(offer.hrn), create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } @@ -15038,14 +15115,34 @@ impl< payment_id, None, create_pending_payment_fn, + optional_params.contact_secrets, + optional_params.payer_offer, ) } + /// 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`] 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. + /// + /// The returned secrets may be included in an invoice request via + /// [`OptionalOfferPaymentParams::contact_secrets`]. + pub fn compute_contact_secret( + &self, our_offer: &Offer, our_offer_nonce: Nonce, their_offer: &Offer, + ) -> Result { + self.flow.compute_contact_secret(our_offer, our_offer_nonce, their_offer) + } + #[rustfmt::skip] fn pay_for_offer_intern Result<(), Bolt12SemanticError>>( &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 +15168,15 @@ impl< Some(hrn) => builder.sourced_from_human_readable_name(hrn), }; + let builder = match contacts { + None => builder, + Some(secrets) => builder.contact_secrets(secrets), + }; + let builder = match payer_offer { + None => builder, + Some(offer) => builder.payer_offer(&offer), + }; + 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 8f073168465..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; @@ -577,6 +578,9 @@ 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, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -736,6 +740,9 @@ 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, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -762,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. @@ -818,6 +968,9 @@ fn router_modifies_payment_metadata_in_blinded_path() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: Some(expected_metadata), }); @@ -901,6 +1054,9 @@ 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, + payer_bip_353_name: None, }, payment_metadata: Some(expected_metadata), }); @@ -1010,6 +1166,9 @@ fn pays_for_offer_without_blinded_paths() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1279,6 +1438,9 @@ fn creates_and_pays_for_offer_with_retry() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1345,6 +1507,9 @@ fn pays_bolt12_invoice_asynchronously() { quantity: None, payer_note_truncated: None, human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -1443,6 +1608,9 @@ 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, + payer_bip_353_name: None, }, payment_metadata: None, }); @@ -2655,6 +2823,9 @@ fn creates_and_pays_for_phantom_offer() { quantity: None, payer_note_truncated: None, 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 new file mode 100644 index 00000000000..b49a5557993 --- /dev/null +++ b/lightning/src/offers/contacts.rs @@ -0,0 +1,455 @@ +// 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. +//! +//! 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::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, PublicKey, 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; + +/// 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 +/// 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 +/// 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`] 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, + ) -> 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. + /// + /// Comparisons are made in constant time to avoid leaking which stored secret matched. + pub fn matches(&self, secret: &ContactSecret) -> bool { + 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 + } +} + +/// 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: +/// - 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 +/// [`ContactSecrets::from_remote_secret`]). +/// +/// # Arguments +/// * `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_compact_offer_builder`]), use +/// [`OffersMessageFlow::compute_contact_secret`] instead, which re-derives this key +/// 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_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, +) -> Result { + let offer_node_id = if let Some(issuer) = their_offer.issuer_signing_pubkey() { + issuer + } else { + // Otherwise, use the last node in the first blinded path (if any) + their_offer + .paths() + .first() + .and_then(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .ok_or(Bolt12SemanticError::MissingSigningPubkey)? + }; + // Compute ECDH shared secret (multiply their public key by our private key) + 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"); + engine.input(&ecdh.serialize()); + let primary_secret = ContactSecret::new(sha256::Hash::from_engine(engine).to_byte_array()); + + Ok(ContactSecrets::new(primary_secret)) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitcoin::hex::DisplayHex; + use bitcoin::secp256k1::Secp256k1; + use core::str::FromStr; + + 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", + }, + ]; + + #[test] + 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() + .first() + .and_then(|path| path.blinded_hops().last()) + .map(|hop| hop.blinded_node_id) + .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); + } + } + + #[test] + 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 bdc3475b554..f4cf542d230 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, @@ -575,6 +576,62 @@ impl OffersMessageFlow { Ok((builder.into(), nonce)) } + /// 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. + /// + /// 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. + /// + /// 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 + /// + /// 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: 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 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 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, 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(our_offer_nonce, 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/invoice.rs b/lightning/src/offers/invoice.rs index fd77595ca7d..bfdcfb63e66 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,13 @@ 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, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, + }, ExperimentalInvoiceTlvStreamRef { experimental_baz: None }, ), ); @@ -2144,7 +2150,13 @@ 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, + 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 7805882ef73..5193911fbcb 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -71,6 +71,12 @@ 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, 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::{ self, SignError, SignFn, SignatureTlvStream, SignatureTlvStreamRef, TaggedHash, TlvStream, @@ -186,7 +192,10 @@ 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, + // 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, } @@ -255,6 +264,34 @@ 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()); + $return_value + } + + /// 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. + /// + /// 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.clone()); + $return_value + } + fn build_with_checks($($self_mut)* $self: $self_type) -> Result< (UnsignedInvoiceRequest, Option, Option<&'b Secp256k1<$secp_context>>), Bolt12SemanticError @@ -283,6 +320,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()) } @@ -500,7 +543,10 @@ impl UnsignedInvoiceRequest { invoice_request_tlv_stream.write(&mut bytes).unwrap(); - const EXPERIMENTAL_TLV_ALLOCATION_SIZE: usize = 0; + // 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 = @@ -686,6 +732,10 @@ pub(super) struct InvoiceRequestContentsWithoutPayerSigningPubkey { quantity: Option, payer_note: Option, 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, } @@ -747,6 +797,27 @@ 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 { + $contents.contact_secret() + } + + /// 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() + } + + /// 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 { @@ -1063,6 +1134,9 @@ 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: $self.contact_secret(), + payer_offer: $self.payer_offer().cloned(), + payer_bip_353_name: $self.payer_bip_353_name(), } } }; @@ -1179,6 +1253,20 @@ impl InvoiceRequestContents { &self.inner.offer_from_hrn } + pub(super) fn contact_secret(&self) -> Option { + self.inner.invreq_contact_secret + } + + pub(super) fn payer_offer(&self) -> Option<&Offer> { + 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(); @@ -1225,6 +1313,10 @@ 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, }; @@ -1291,9 +1383,13 @@ pub(super) const EXPERIMENTAL_INVOICE_REQUEST_TYPES: core::ops::Range = #[cfg(not(test))] tlv_stream!( ExperimentalInvoiceRequestTlvStream, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, 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), // When adding experimental TLVs, update EXPERIMENTAL_TLV_ALLOCATION_SIZE accordingly in // UnsignedInvoiceRequest::new to avoid unnecessary allocations. } @@ -1301,8 +1397,12 @@ tlv_stream!( #[cfg(test)] tlv_stream!( - ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStream, ExperimentalInvoiceRequestTlvStreamRef<'a>, 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)), } ); @@ -1322,7 +1422,7 @@ type FullInvoiceRequestTlvStreamRef<'a> = ( InvoiceRequestTlvStreamRef<'a>, SignatureTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for FullInvoiceRequestTlvStream { @@ -1358,7 +1458,7 @@ type PartialInvoiceRequestTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl TryFrom> for UnsignedInvoiceRequest { @@ -1414,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 }) } } @@ -1437,6 +1551,10 @@ impl TryFrom for InvoiceRequestContents { }, experimental_offer_tlv_stream, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret, + invreq_payer_offer, + invreq_payer_bip_353_name, + invreq_payer_bip_353_signature, #[cfg(test)] experimental_bar, }, @@ -1470,6 +1588,17 @@ impl TryFrom for InvoiceRequestContents { return Err(Bolt12SemanticError::UnexpectedPaths); } + let invreq_payer_offer = invreq_payer_offer + .map(Offer::try_from) + .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, @@ -1480,6 +1609,10 @@ impl TryFrom for InvoiceRequestContents { quantity, payer_note, offer_from_hrn, + invreq_contact_secret, + invreq_payer_offer, + invreq_payer_bip_353_name, + invreq_payer_bip_353_signature, #[cfg(test)] experimental_bar, }, @@ -1505,6 +1638,28 @@ 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. + /// + /// Per BLIP 42, if this matches an existing contact of the recipient, the recipient MUST + /// 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`]. @@ -1515,13 +1670,45 @@ 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, 11, 13) so older LDK nodes reading newer + // `Bolt12OfferContext.path_id` blobs silently ignore unknown fields per BOLT 1 + // "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), (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, 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(()) } @@ -1534,13 +1721,37 @@ impl Readable for InvoiceRequestFields { (1, human_readable_name, option), (2, quantity, (option, encoding: (u64, HighZeroBytesDroppedBigSize))), (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 + // 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)?; + + // 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, payer_note_truncated: payer_note_truncated.map(|s| UntrustedString(s)), human_readable_name, + contact_secret, + payer_offer, + payer_bip_353_name, }) } } @@ -1556,10 +1767,12 @@ 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}; use crate::offers::nonce::Nonce; + use crate::offers::offer::Offer; #[cfg(not(c_bindings))] use crate::offers::offer::OfferBuilder; #[cfg(c_bindings)] @@ -1577,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; @@ -1660,7 +1874,13 @@ 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, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, + experimental_bar: None, + }, ), ); @@ -3118,6 +3338,9 @@ mod tests { quantity: Some(1), payer_note_truncated: Some(UntrustedString(expected_payer_note)), human_readable_name: None, + contact_secret: None, + payer_offer: None, + payer_bip_353_name: None, } ); @@ -3132,6 +3355,315 @@ 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) + ), + } + } + + /// 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/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; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b2703454169..2034fdd3251 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` and `nonce` (i.e., the offer was built using + /// `deriving_signing_pubkey` and contains blinded paths). + pub(super) fn derive_issuer_signing_keys( + &self, nonce: Nonce, key: &ExpandedKey, secp_ctx: &Secp256k1, + ) -> Result { + self.contents + .verify_using_recipient_data(&self.bytes, nonce, key, secp_ctx) + .and_then(|(_, keys)| keys.ok_or(())) + } } macro_rules! request_invoice_derived_signing_pubkey { ($self: ident, $offer: expr, $builder: ty, $hrn: expr) => { 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 { diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index c0fd9dfdd3e..c3de3b4e133 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -809,6 +809,10 @@ 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, }; @@ -854,7 +858,7 @@ type RefundTlvStreamRef<'a> = ( OfferTlvStreamRef<'a>, InvoiceRequestTlvStreamRef<'a>, ExperimentalOfferTlvStreamRef, - ExperimentalInvoiceRequestTlvStreamRef, + ExperimentalInvoiceRequestTlvStreamRef<'a>, ); impl CursorReadable for RefundTlvStream { @@ -927,6 +931,10 @@ impl TryFrom for RefundContents { experimental_foo, }, ExperimentalInvoiceRequestTlvStream { + invreq_contact_secret: _, + invreq_payer_offer: _, + invreq_payer_bip_353_name: _, + invreq_payer_bip_353_signature: _, #[cfg(test)] experimental_bar, }, @@ -1113,7 +1121,13 @@ mod tests { offer_from_hrn: None, }, ExperimentalOfferTlvStreamRef { experimental_foo: None }, - ExperimentalInvoiceRequestTlvStreamRef { experimental_bar: None }, + ExperimentalInvoiceRequestTlvStreamRef { + invreq_contact_secret: None, + invreq_payer_offer: None, + invreq_payer_bip_353_name: None, + invreq_payer_bip_353_signature: None, + experimental_bar: None, + }, ), );