From 520fecf8fcbe1d162e8a1075d316ca994b9d2fd6 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Apr 2024 17:38:13 -0500 Subject: [PATCH 1/4] Optional OfferContents::signing_pubkey If an Offer contains a path, the blinded_node_id of the path's final hop can be used as the signing pubkey. Make Offer::signing_pubkey and OfferContents::signing_pubkey return an Option to support this. Upcoming commits will implement this behavior. --- lightning/src/ln/channelmanager.rs | 19 +++++++++------- lightning/src/ln/offers_tests.rs | 12 +++++----- lightning/src/offers/invoice.rs | 5 ++--- lightning/src/offers/invoice_request.rs | 18 +++++++++++---- lightning/src/offers/offer.rs | 30 ++++++++++++++----------- 5 files changed, 50 insertions(+), 34 deletions(-) diff --git a/lightning/src/ln/channelmanager.rs b/lightning/src/ln/channelmanager.rs index 130eab49384..d10c627916a 100644 --- a/lightning/src/ln/channelmanager.rs +++ b/lightning/src/ln/channelmanager.rs @@ -8765,14 +8765,7 @@ where .map_err(|_| Bolt12SemanticError::DuplicatePaymentId)?; let mut pending_offers_messages = self.pending_offers_messages.lock().unwrap(); - if offer.paths().is_empty() { - let message = new_pending_onion_message( - OffersMessage::InvoiceRequest(invoice_request), - Destination::Node(offer.signing_pubkey()), - Some(reply_path), - ); - pending_offers_messages.push(message); - } else { + if !offer.paths().is_empty() { // Send as many invoice requests as there are paths in the offer (with an upper bound). // Using only one path could result in a failure if the path no longer exists. But only // one invoice for a given payment id will be paid, even if more than one is received. @@ -8785,6 +8778,16 @@ where ); pending_offers_messages.push(message); } + } else if let Some(signing_pubkey) = offer.signing_pubkey() { + let message = new_pending_onion_message( + OffersMessage::InvoiceRequest(invoice_request), + Destination::Node(signing_pubkey), + Some(reply_path), + ); + pending_offers_messages.push(message); + } else { + debug_assert!(false); + return Err(Bolt12SemanticError::MissingSigningPubkey); } Ok(()) diff --git a/lightning/src/ln/offers_tests.rs b/lightning/src/ln/offers_tests.rs index 75a2e290f39..e89e113e8ea 100644 --- a/lightning/src/ln/offers_tests.rs +++ b/lightning/src/ln/offers_tests.rs @@ -271,7 +271,7 @@ fn prefers_non_tor_nodes_in_blinded_paths() { .create_offer_builder("coffee".to_string()).unwrap() .amount_msats(10_000_000) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), bob_id); + assert_ne!(offer.signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_ne!(path.introduction_node, IntroductionNode::NodeId(bob_id)); @@ -286,7 +286,7 @@ fn prefers_non_tor_nodes_in_blinded_paths() { .create_offer_builder("coffee".to_string()).unwrap() .amount_msats(10_000_000) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), bob_id); + assert_ne!(offer.signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_eq!(path.introduction_node, IntroductionNode::NodeId(bob_id)); @@ -336,7 +336,7 @@ fn prefers_more_connected_nodes_in_blinded_paths() { .create_offer_builder("coffee".to_string()).unwrap() .amount_msats(10_000_000) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), bob_id); + assert_ne!(offer.signing_pubkey(), Some(bob_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_eq!(path.introduction_node, IntroductionNode::NodeId(nodes[4].node.get_our_node_id())); @@ -386,7 +386,7 @@ fn creates_and_pays_for_offer_using_two_hop_blinded_path() { .unwrap() .amount_msats(10_000_000) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), alice_id); + assert_ne!(offer.signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_eq!(path.introduction_node, IntroductionNode::NodeId(bob_id)); @@ -547,7 +547,7 @@ fn creates_and_pays_for_offer_using_one_hop_blinded_path() { .create_offer_builder("coffee".to_string()).unwrap() .amount_msats(10_000_000) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), alice_id); + assert_ne!(offer.signing_pubkey(), Some(alice_id)); assert!(!offer.paths().is_empty()); for path in offer.paths() { assert_eq!(path.introduction_node, IntroductionNode::NodeId(alice_id)); @@ -672,7 +672,7 @@ fn pays_for_offer_without_blinded_paths() { .clear_paths() .amount_msats(10_000_000) .build().unwrap(); - assert_eq!(offer.signing_pubkey(), alice_id); + assert_eq!(offer.signing_pubkey(), Some(alice_id)); assert!(offer.paths().is_empty()); let payment_id = PaymentId([1; 32]); diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index ee5e6deb408..ab9e7bf8e64 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -210,10 +210,9 @@ macro_rules! invoice_explicit_signing_pubkey_builder_methods { ($self: ident, $s #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn for_offer( invoice_request: &'a InvoiceRequest, payment_paths: Vec<(BlindedPayInfo, BlindedPath)>, - created_at: Duration, payment_hash: PaymentHash + created_at: Duration, payment_hash: PaymentHash, signing_pubkey: PublicKey ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; - let signing_pubkey = invoice_request.contents.inner.offer.signing_pubkey(); let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( @@ -272,7 +271,7 @@ macro_rules! invoice_derived_signing_pubkey_builder_methods { ($self: ident, $se created_at: Duration, payment_hash: PaymentHash, keys: KeyPair ) -> Result { let amount_msats = Self::amount_msats(invoice_request)?; - let signing_pubkey = invoice_request.contents.inner.offer.signing_pubkey(); + let signing_pubkey = keys.public_key(); let contents = InvoiceContents::ForOffer { invoice_request: invoice_request.contents.clone(), fields: Self::fields( diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 9157613fcd9..a858e5afb2c 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -747,7 +747,12 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( return Err(Bolt12SemanticError::UnknownRequiredFeatures); } - <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash) + let signing_pubkey = match $contents.contents.inner.offer.signing_pubkey() { + Some(signing_pubkey) => signing_pubkey, + None => return Err(Bolt12SemanticError::MissingSigningPubkey), + }; + + <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) } } } @@ -855,6 +860,11 @@ macro_rules! invoice_request_respond_with_derived_signing_pubkey_methods { ( Some(keys) => keys, }; + match $contents.contents.inner.offer.signing_pubkey() { + Some(signing_pubkey) => debug_assert_eq!(signing_pubkey, keys.public_key()), + None => return Err(Bolt12SemanticError::MissingSigningPubkey), + } + <$builder>::for_offer_using_keys( &$self.inner, payment_paths, created_at, payment_hash, keys ) @@ -1233,7 +1243,7 @@ mod tests { assert_eq!(unsigned_invoice_request.paths(), &[]); assert_eq!(unsigned_invoice_request.issuer(), None); assert_eq!(unsigned_invoice_request.supported_quantity(), Quantity::One); - assert_eq!(unsigned_invoice_request.signing_pubkey(), recipient_pubkey()); + assert_eq!(unsigned_invoice_request.signing_pubkey(), Some(recipient_pubkey())); assert_eq!(unsigned_invoice_request.chain(), ChainHash::using_genesis_block(Network::Bitcoin)); assert_eq!(unsigned_invoice_request.amount_msats(), None); assert_eq!(unsigned_invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty()); @@ -1265,7 +1275,7 @@ mod tests { assert_eq!(invoice_request.paths(), &[]); assert_eq!(invoice_request.issuer(), None); assert_eq!(invoice_request.supported_quantity(), Quantity::One); - assert_eq!(invoice_request.signing_pubkey(), recipient_pubkey()); + assert_eq!(invoice_request.signing_pubkey(), Some(recipient_pubkey())); assert_eq!(invoice_request.chain(), ChainHash::using_genesis_block(Network::Bitcoin)); assert_eq!(invoice_request.amount_msats(), None); assert_eq!(invoice_request.invoice_request_features(), &InvoiceRequestFeatures::empty()); @@ -2260,7 +2270,7 @@ mod tests { .amount_msats(1000) .supported_quantity(Quantity::Unbounded) .build().unwrap(); - assert_eq!(offer.signing_pubkey(), node_id); + assert_eq!(offer.signing_pubkey(), Some(node_id)); let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap() .chain(Network::Testnet).unwrap() diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index 3dedc6cd35d..b7852a0fc4b 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -226,7 +226,7 @@ macro_rules! offer_explicit_metadata_builder_methods { ( offer: OfferContents { chains: None, metadata: None, amount: None, description, features: OfferFeatures::empty(), absolute_expiry: None, issuer: None, paths: None, - supported_quantity: Quantity::One, signing_pubkey, + supported_quantity: Quantity::One, signing_pubkey: Some(signing_pubkey), }, metadata_strategy: core::marker::PhantomData, secp_ctx: None, @@ -265,7 +265,7 @@ macro_rules! offer_derived_metadata_builder_methods { ($secp_context: ty) => { offer: OfferContents { chains: None, metadata: Some(metadata), amount: None, description, features: OfferFeatures::empty(), absolute_expiry: None, issuer: None, paths: None, - supported_quantity: Quantity::One, signing_pubkey: node_id, + supported_quantity: Quantity::One, signing_pubkey: Some(node_id), }, metadata_strategy: core::marker::PhantomData, secp_ctx: Some(secp_ctx), @@ -391,7 +391,7 @@ macro_rules! offer_builder_methods { ( let (derived_metadata, keys) = metadata.derive_from(tlv_stream, $self.secp_ctx); metadata = derived_metadata; if let Some(keys) = keys { - $self.offer.signing_pubkey = keys.public_key(); + $self.offer.signing_pubkey = Some(keys.public_key()); } } @@ -542,7 +542,7 @@ pub(super) struct OfferContents { issuer: Option, paths: Option>, supported_quantity: Quantity, - signing_pubkey: PublicKey, + signing_pubkey: Option, } macro_rules! offer_accessors { ($self: ident, $contents: expr) => { @@ -604,7 +604,7 @@ macro_rules! offer_accessors { ($self: ident, $contents: expr) => { } /// The public key used by the recipient to sign invoices. - pub fn signing_pubkey(&$self) -> bitcoin::secp256k1::PublicKey { + pub fn signing_pubkey(&$self) -> Option { $contents.signing_pubkey() } } } @@ -886,7 +886,7 @@ impl OfferContents { } } - pub(super) fn signing_pubkey(&self) -> PublicKey { + pub(super) fn signing_pubkey(&self) -> Option { self.signing_pubkey } @@ -905,8 +905,12 @@ impl OfferContents { _ => true, } }); + let signing_pubkey = match self.signing_pubkey() { + Some(signing_pubkey) => signing_pubkey, + None => return Err(()), + }; let keys = signer::verify_recipient_metadata( - metadata, key, IV_BYTES, self.signing_pubkey(), tlv_stream, secp_ctx + metadata, key, IV_BYTES, signing_pubkey, tlv_stream, secp_ctx )?; let offer_id = OfferId::from_valid_invreq_tlv_stream(bytes); @@ -941,7 +945,7 @@ impl OfferContents { paths: self.paths.as_ref(), issuer: self.issuer.as_ref(), quantity_max: self.supported_quantity.to_tlv_record(), - node_id: Some(&self.signing_pubkey), + node_id: self.signing_pubkey.as_ref(), } } } @@ -1091,7 +1095,7 @@ impl TryFrom for OfferContents { let signing_pubkey = match node_id { None => return Err(Bolt12SemanticError::MissingSigningPubkey), - Some(node_id) => node_id, + Some(node_id) => Some(node_id), }; Ok(OfferContents { @@ -1154,7 +1158,7 @@ mod tests { assert_eq!(offer.paths(), &[]); assert_eq!(offer.issuer(), None); assert_eq!(offer.supported_quantity(), Quantity::One); - assert_eq!(offer.signing_pubkey(), pubkey(42)); + assert_eq!(offer.signing_pubkey(), Some(pubkey(42))); assert_eq!( offer.as_tlv_stream(), @@ -1251,7 +1255,7 @@ mod tests { ::deriving_signing_pubkey(desc, node_id, &expanded_key, &entropy, &secp_ctx) .amount_msats(1000) .build().unwrap(); - assert_eq!(offer.signing_pubkey(), node_id); + assert_eq!(offer.signing_pubkey(), Some(node_id)); let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap() .build().unwrap() @@ -1313,7 +1317,7 @@ mod tests { .amount_msats(1000) .path(blinded_path) .build().unwrap(); - assert_ne!(offer.signing_pubkey(), node_id); + assert_ne!(offer.signing_pubkey(), Some(node_id)); let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap() .build().unwrap() @@ -1471,7 +1475,7 @@ mod tests { .unwrap(); let tlv_stream = offer.as_tlv_stream(); assert_eq!(offer.paths(), paths.as_slice()); - assert_eq!(offer.signing_pubkey(), pubkey(42)); + assert_eq!(offer.signing_pubkey(), Some(pubkey(42))); assert_ne!(pubkey(42), pubkey(44)); assert_eq!(tlv_stream.paths, Some(&paths)); assert_eq!(tlv_stream.node_id, Some(&pubkey(42))); From 94d5af663d74177079124f15b40b65e16fbb1b27 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Wed, 24 Apr 2024 09:03:56 -0500 Subject: [PATCH 2/4] Allow parsing Offer without signing_pubkey If an offer has at least one path, it may omit the signing pubkey and use the blinded node id of the last hop of a path to sign an invoice. Allow parsing such offers but not yet creating them. --- lightning/src/offers/offer.rs | 36 ++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index b7852a0fc4b..8e93a93f873 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -436,6 +436,12 @@ macro_rules! offer_builder_test_methods { ( $return_value } + #[cfg_attr(c_bindings, allow(dead_code))] + pub(crate) fn clear_signing_pubkey($($self_mut)* $self: $self_type) -> $return_type { + $self.offer.signing_pubkey = None; + $return_value + } + #[cfg_attr(c_bindings, allow(dead_code))] pub(super) fn build_unchecked($self: $self_type) -> Offer { $self.build_without_checks() @@ -1093,9 +1099,10 @@ impl TryFrom for OfferContents { Some(n) => Quantity::Bounded(NonZeroU64::new(n).unwrap()), }; - let signing_pubkey = match node_id { - None => return Err(Bolt12SemanticError::MissingSigningPubkey), - Some(node_id) => Some(node_id), + let (signing_pubkey, paths) = match (node_id, paths) { + (None, None) => return Err(Bolt12SemanticError::MissingSigningPubkey), + (_, Some(paths)) if paths.is_empty() => return Err(Bolt12SemanticError::MissingPaths), + (node_id, paths) => (node_id, paths), }; Ok(OfferContents { @@ -1662,12 +1669,31 @@ mod tests { panic!("error parsing offer: {:?}", e); } + let offer = OfferBuilder::new("foo".into(), pubkey(42)) + .path(BlindedPath { + introduction_node: IntroductionNode::NodeId(pubkey(40)), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + }) + .clear_signing_pubkey() + .build() + .unwrap(); + if let Err(e) = offer.to_string().parse::() { + panic!("error parsing offer: {:?}", e); + } + let mut builder = OfferBuilder::new("foo".into(), pubkey(42)); builder.offer.paths = Some(vec![]); let offer = builder.build().unwrap(); - if let Err(e) = offer.to_string().parse::() { - panic!("error parsing offer: {:?}", e); + match offer.to_string().parse::() { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::MissingPaths)); + }, } } From 61e4fcea8e19eba1f6b1299c8415031c3a031a94 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Apr 2024 18:30:59 -0500 Subject: [PATCH 3/4] Add InvoiceRequestTlvStream::paths Instead of reusing OfferTlvStream::paths, add a dedicated paths TLV to InvoiceRequestTlvStream such that it can be used in Refund. This allows for an Offer without a signing_pubkey and still be able to differentiate whether an invoice is for an offer or a refund. --- lightning/src/offers/invoice.rs | 2 ++ lightning/src/offers/invoice_request.rs | 14 +++++++++- lightning/src/offers/parse.rs | 2 ++ lightning/src/offers/refund.rs | 35 ++++++++++++++++--------- 4 files changed, 39 insertions(+), 14 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index ab9e7bf8e64..7dd57365321 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1632,6 +1632,7 @@ mod tests { quantity: None, payer_id: Some(&payer_pubkey()), payer_note: None, + paths: None, }, InvoiceTlvStreamRef { paths: Some(Iterable(payment_paths.iter().map(|(_, path)| path))), @@ -1724,6 +1725,7 @@ mod tests { quantity: None, payer_id: Some(&payer_pubkey()), payer_note: None, + paths: None, }, InvoiceTlvStreamRef { paths: Some(Iterable(payment_paths.iter().map(|(_, path)| path))), diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index a858e5afb2c..be4696be91c 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -971,6 +971,7 @@ impl InvoiceRequestContentsWithoutPayerId { quantity: self.quantity, payer_id: None, payer_note: self.payer_note.as_ref(), + paths: None, }; (payer, offer, invoice_request) @@ -1003,6 +1004,8 @@ pub(super) const INVOICE_REQUEST_TYPES: core::ops::Range = 80..160; /// [`Refund::payer_id`]: crate::offers::refund::Refund::payer_id pub(super) const INVOICE_REQUEST_PAYER_ID_TYPE: u64 = 88; +// This TLV stream is used for both InvoiceRequest and Refund, but not all TLV records are valid for +// InvoiceRequest as noted below. tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, INVOICE_REQUEST_TYPES, { (80, chain: ChainHash), (82, amount: (u64, HighZeroBytesDroppedBigSize)), @@ -1010,6 +1013,8 @@ tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, INVOICE_REQUEST (86, quantity: (u64, HighZeroBytesDroppedBigSize)), (INVOICE_REQUEST_PAYER_ID_TYPE, payer_id: PublicKey), (89, payer_note: (String, WithoutLength)), + // Only used for Refund since the onion message of an InvoiceRequest has a reply path. + (90, paths: (Vec, WithoutLength)), }); type FullInvoiceRequestTlvStream = @@ -1092,7 +1097,9 @@ impl TryFrom for InvoiceRequestContents { let ( PayerTlvStream { metadata }, offer_tlv_stream, - InvoiceRequestTlvStream { chain, amount, features, quantity, payer_id, payer_note }, + InvoiceRequestTlvStream { + chain, amount, features, quantity, payer_id, payer_note, paths, + }, ) = tlv_stream; let payer = match metadata { @@ -1119,6 +1126,10 @@ impl TryFrom for InvoiceRequestContents { Some(payer_id) => payer_id, }; + if paths.is_some() { + return Err(Bolt12SemanticError::UnexpectedPaths); + } + Ok(InvoiceRequestContents { inner: InvoiceRequestContentsWithoutPayerId { payer, offer, chain, amount_msats: amount, features, quantity, payer_note, @@ -1310,6 +1321,7 @@ mod tests { quantity: None, payer_id: Some(&payer_pubkey()), payer_note: None, + paths: None, }, SignatureTlvStreamRef { signature: Some(&invoice_request.signature()) }, ), diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs index 72c4c380d52..3b9b04a5c06 100644 --- a/lightning/src/offers/parse.rs +++ b/lightning/src/offers/parse.rs @@ -183,6 +183,8 @@ pub enum Bolt12SemanticError { DuplicatePaymentId, /// Blinded paths were expected but were missing. MissingPaths, + /// Blinded paths were provided but were not expected. + UnexpectedPaths, /// The blinded payinfo given does not match the number of blinded path hops. InvalidPayInfo, /// An invoice creation time was expected but was missing. diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 03253fb6400..f9aa90ba050 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -170,8 +170,8 @@ macro_rules! refund_explicit_metadata_builder_methods { () => { Ok(Self { refund: RefundContents { payer: PayerContents(metadata), description, absolute_expiry: None, issuer: None, - paths: None, chain: None, amount_msats, features: InvoiceRequestFeatures::empty(), - quantity: None, payer_id, payer_note: None, + chain: None, amount_msats, features: InvoiceRequestFeatures::empty(), + quantity: None, payer_id, payer_note: None, paths: None, }, secp_ctx: None, }) @@ -209,8 +209,8 @@ macro_rules! refund_builder_methods { ( Ok(Self { refund: RefundContents { payer: PayerContents(metadata), description, absolute_expiry: None, issuer: None, - paths: None, chain: None, amount_msats, features: InvoiceRequestFeatures::empty(), - quantity: None, payer_id: node_id, payer_note: None, + chain: None, amount_msats, features: InvoiceRequestFeatures::empty(), + quantity: None, payer_id: node_id, payer_note: None, paths: None, }, secp_ctx: Some(secp_ctx), }) @@ -410,7 +410,6 @@ pub(super) struct RefundContents { description: String, absolute_expiry: Option, issuer: Option, - paths: Option>, // invoice_request fields chain: Option, amount_msats: u64, @@ -418,6 +417,7 @@ pub(super) struct RefundContents { quantity: Option, payer_id: PublicKey, payer_note: Option, + paths: Option>, } impl Refund { @@ -734,7 +734,7 @@ impl RefundContents { description: Some(&self.description), features: None, absolute_expiry: self.absolute_expiry.map(|duration| duration.as_secs()), - paths: self.paths.as_ref(), + paths: None, issuer: self.issuer.as_ref(), quantity_max: None, node_id: None, @@ -752,6 +752,7 @@ impl RefundContents { quantity: self.quantity, payer_id: Some(&self.payer_id), payer_note: self.payer_note.as_ref(), + paths: self.paths.as_ref(), }; (payer, offer, invoice_request) @@ -820,9 +821,12 @@ impl TryFrom for RefundContents { PayerTlvStream { metadata: payer_metadata }, OfferTlvStream { chains, metadata, currency, amount: offer_amount, description, - features: offer_features, absolute_expiry, paths, issuer, quantity_max, node_id, + features: offer_features, absolute_expiry, paths: offer_paths, issuer, quantity_max, + node_id, + }, + InvoiceRequestTlvStream { + chain, amount, features, quantity, payer_id, payer_note, paths }, - InvoiceRequestTlvStream { chain, amount, features, quantity, payer_id, payer_note }, ) = tlv_stream; let payer = match payer_metadata { @@ -853,6 +857,10 @@ impl TryFrom for RefundContents { let absolute_expiry = absolute_expiry.map(Duration::from_secs); + if offer_paths.is_some() { + return Err(Bolt12SemanticError::UnexpectedPaths); + } + if quantity_max.is_some() { return Err(Bolt12SemanticError::UnexpectedQuantity); } @@ -877,8 +885,8 @@ impl TryFrom for RefundContents { }; Ok(RefundContents { - payer, description, absolute_expiry, issuer, paths, chain, amount_msats, features, - quantity, payer_id, payer_note, + payer, description, absolute_expiry, issuer, chain, amount_msats, features, quantity, + payer_id, payer_note, paths, }) } } @@ -980,6 +988,7 @@ mod tests { quantity: None, payer_id: Some(&payer_pubkey()), payer_note: None, + paths: None, }, ), ); @@ -1173,12 +1182,12 @@ mod tests { .path(paths[1].clone()) .build() .unwrap(); - let (_, offer_tlv_stream, invoice_request_tlv_stream) = refund.as_tlv_stream(); - assert_eq!(refund.paths(), paths.as_slice()); + let (_, _, invoice_request_tlv_stream) = refund.as_tlv_stream(); assert_eq!(refund.payer_id(), pubkey(42)); + assert_eq!(refund.paths(), paths.as_slice()); assert_ne!(pubkey(42), pubkey(44)); - assert_eq!(offer_tlv_stream.paths, Some(&paths)); assert_eq!(invoice_request_tlv_stream.payer_id, Some(&pubkey(42))); + assert_eq!(invoice_request_tlv_stream.paths, Some(&paths)); } #[test] From b7635c4dc2a5202e64305d65eff36bd74df528d1 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 23 Apr 2024 18:35:05 -0500 Subject: [PATCH 4/4] Bolt12Invoice for Offer without signing_pubkey When parsing a Bolt12Invoice use both the Offer's signing_pubkey and paths to determine if it is for an Offer or a Refund. Previously, an Offer was required to have a signing_pubkey. But now that it is optional, the Offers paths can be used to make the determination. Additionally, check that the invoice matches one of the blinded node ids from the paths' last hops. --- lightning/src/offers/invoice.rs | 97 ++++++++++++++++++++++++- lightning/src/offers/invoice_request.rs | 15 ++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 7dd57365321..75bbcab93f8 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -1434,8 +1434,8 @@ impl TryFrom for InvoiceContents { features, signing_pubkey, }; - match offer_tlv_stream.node_id { - Some(expected_signing_pubkey) => { + match (offer_tlv_stream.node_id, &offer_tlv_stream.paths) { + (Some(expected_signing_pubkey), _) => { if fields.signing_pubkey != expected_signing_pubkey { return Err(Bolt12SemanticError::InvalidSigningPubkey); } @@ -1445,7 +1445,21 @@ impl TryFrom for InvoiceContents { )?; Ok(InvoiceContents::ForOffer { invoice_request, fields }) }, - None => { + (None, Some(paths)) => { + if !paths + .iter() + .filter_map(|path| path.blinded_hops.last()) + .any(|last_hop| fields.signing_pubkey == last_hop.blinded_node_id) + { + return Err(Bolt12SemanticError::InvalidSigningPubkey); + } + + let invoice_request = InvoiceRequestContents::try_from( + (payer_tlv_stream, offer_tlv_stream, invoice_request_tlv_stream) + )?; + Ok(InvoiceContents::ForOffer { invoice_request, fields }) + }, + (None, None) => { let refund = RefundContents::try_from( (payer_tlv_stream, offer_tlv_stream, invoice_request_tlv_stream) )?; @@ -1463,7 +1477,7 @@ mod tests { use bitcoin::blockdata::script::ScriptBuf; use bitcoin::hashes::Hash; use bitcoin::network::constants::Network; - use bitcoin::secp256k1::{Message, Secp256k1, XOnlyPublicKey, self}; + use bitcoin::secp256k1::{KeyPair, Message, Secp256k1, SecretKey, XOnlyPublicKey, self}; use bitcoin::address::{Address, Payload, WitnessProgram, WitnessVersion}; use bitcoin::key::TweakedPublicKey; @@ -2366,6 +2380,81 @@ mod tests { } } + #[test] + fn parses_invoice_with_node_id_from_blinded_path() { + let paths = vec![ + BlindedPath { + introduction_node: IntroductionNode::NodeId(pubkey(40)), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] }, + BlindedHop { blinded_node_id: pubkey(44), encrypted_payload: vec![0; 44] }, + ], + }, + BlindedPath { + introduction_node: IntroductionNode::NodeId(pubkey(40)), + blinding_point: pubkey(41), + blinded_hops: vec![ + BlindedHop { blinded_node_id: pubkey(45), encrypted_payload: vec![0; 45] }, + BlindedHop { blinded_node_id: pubkey(46), encrypted_payload: vec![0; 46] }, + ], + }, + ]; + + let blinded_node_id_sign = |message: &UnsignedBolt12Invoice| { + let secp_ctx = Secp256k1::new(); + let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[46; 32]).unwrap()); + Ok(secp_ctx.sign_schnorr_no_aux_rand(message.as_ref().as_digest(), &keys)) + }; + + let invoice = OfferBuilder::new("foo".into(), recipient_pubkey()) + .clear_signing_pubkey() + .amount_msats(1000) + .path(paths[0].clone()) + .path(paths[1].clone()) + .build().unwrap() + .request_invoice(vec![1; 32], payer_pubkey()).unwrap() + .build().unwrap() + .sign(payer_sign).unwrap() + .respond_with_no_std_using_signing_pubkey( + payment_paths(), payment_hash(), now(), pubkey(46) + ).unwrap() + .build().unwrap() + .sign(blinded_node_id_sign).unwrap(); + + let mut buffer = Vec::new(); + invoice.write(&mut buffer).unwrap(); + + if let Err(e) = Bolt12Invoice::try_from(buffer) { + panic!("error parsing invoice: {:?}", e); + } + + let invoice = OfferBuilder::new("foo".into(), recipient_pubkey()) + .clear_signing_pubkey() + .amount_msats(1000) + .path(paths[0].clone()) + .path(paths[1].clone()) + .build().unwrap() + .request_invoice(vec![1; 32], payer_pubkey()).unwrap() + .build().unwrap() + .sign(payer_sign).unwrap() + .respond_with_no_std_using_signing_pubkey( + payment_paths(), payment_hash(), now(), recipient_pubkey() + ).unwrap() + .build().unwrap() + .sign(recipient_sign).unwrap(); + + let mut buffer = Vec::new(); + invoice.write(&mut buffer).unwrap(); + + match Bolt12Invoice::try_from(buffer) { + Ok(_) => panic!("expected error"), + Err(e) => { + assert_eq!(e, Bolt12ParseError::InvalidSemantics(Bolt12SemanticError::InvalidSigningPubkey)); + }, + } + } + #[test] fn fails_parsing_invoice_without_signature() { let mut buffer = Vec::new(); diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index be4696be91c..60d1e3c02a2 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -754,6 +754,21 @@ macro_rules! invoice_request_respond_with_explicit_signing_pubkey_methods { ( <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) } + + #[cfg(test)] + #[allow(dead_code)] + pub(super) fn respond_with_no_std_using_signing_pubkey( + &$self, payment_paths: Vec<(BlindedPayInfo, BlindedPath)>, payment_hash: PaymentHash, + created_at: core::time::Duration, signing_pubkey: PublicKey + ) -> Result<$builder, Bolt12SemanticError> { + debug_assert!($contents.contents.inner.offer.signing_pubkey().is_none()); + + if $contents.invoice_request_features().requires_unknown_bits() { + return Err(Bolt12SemanticError::UnknownRequiredFeatures); + } + + <$builder>::for_offer(&$contents, payment_paths, created_at, payment_hash, signing_pubkey) + } } } macro_rules! invoice_request_verify_method { ($self: ident, $self_type: ty) => {