Stateless verification of Invoice for Refund
authorJeffrey Czyz <jkczyz@gmail.com>
Mon, 6 Feb 2023 21:30:44 +0000 (15:30 -0600)
committerJeffrey Czyz <jkczyz@gmail.com>
Thu, 20 Apr 2023 02:31:07 +0000 (21:31 -0500)
Stateless verification of Invoice for Offer

Verify that an Invoice was produced from a Refund constructed by the
payer using the payer metadata reflected in the Invoice. The payer
metadata consists of a 128-bit encrypted nonce and possibly a 256-bit
HMAC over the nonce and Refund TLV records (excluding the payer id)
using an ExpandedKey.

Thus, the HMAC can be reproduced from the refund bytes using the nonce
and the original ExpandedKey, and then checked against the metadata. If
metadata does not contain an HMAC, then the reproduced HMAC was used to
form the signing keys, and thus can be checked against the payer id.

lightning/src/offers/invoice.rs
lightning/src/offers/invoice_request.rs
lightning/src/offers/refund.rs

index f5a613aca6510ac5418f8f5c685c7256e2ef7d23..57d37a17b8726c75729e4ff915780cee65853ae5 100644 (file)
@@ -535,7 +535,9 @@ impl InvoiceContents {
                        InvoiceContents::ForOffer { invoice_request, .. } => {
                                invoice_request.verify(tlv_stream, key, secp_ctx)
                        },
-                       _ => todo!(),
+                       InvoiceContents::ForRefund { refund, .. } => {
+                               refund.verify(tlv_stream, key, secp_ctx)
+                       },
                }
        }
 
index a7cdbfc0f155157cfa7b7f8e1976953ee750d080..2294fc458853c6f4afb223df823a0845f2c6f1e2 100644 (file)
@@ -602,12 +602,12 @@ impl Writeable for InvoiceRequestContents {
 }
 
 /// Valid type range for invoice_request TLV records.
-const INVOICE_REQUEST_TYPES: core::ops::Range<u64> = 80..160;
+pub(super) const INVOICE_REQUEST_TYPES: core::ops::Range<u64> = 80..160;
 
 /// TLV record type for [`InvoiceRequest::payer_id`] and [`Refund::payer_id`].
 ///
 /// [`Refund::payer_id`]: crate::offers::refund::Refund::payer_id
-const INVOICE_REQUEST_PAYER_ID_TYPE: u64 = 88;
+pub(super) const INVOICE_REQUEST_PAYER_ID_TYPE: u64 = 88;
 
 tlv_stream!(InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef, INVOICE_REQUEST_TYPES, {
        (80, chain: ChainHash),
index b87febc7f6d53a430421618f954133f3395f9735..8f91db597f62527ab52f9569ae8ce2182c53162f 100644 (file)
@@ -85,11 +85,12 @@ use crate::ln::features::InvoiceRequestFeatures;
 use crate::ln::inbound_payment::{ExpandedKey, IV_LEN, Nonce};
 use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT};
 use crate::offers::invoice::{BlindedPayInfo, InvoiceBuilder};
-use crate::offers::invoice_request::{InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef};
-use crate::offers::offer::{OfferTlvStream, OfferTlvStreamRef};
+use crate::offers::invoice_request::{INVOICE_REQUEST_PAYER_ID_TYPE, INVOICE_REQUEST_TYPES, InvoiceRequestTlvStream, InvoiceRequestTlvStreamRef};
+use crate::offers::merkle::TlvStream;
+use crate::offers::offer::{OFFER_TYPES, OfferTlvStream, OfferTlvStreamRef};
 use crate::offers::parse::{Bech32Encode, ParseError, ParsedMessage, SemanticError};
-use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef};
-use crate::offers::signer::{Metadata, MetadataMaterial};
+use crate::offers::payer::{PAYER_METADATA_TYPE, PayerContents, PayerTlvStream, PayerTlvStreamRef};
+use crate::offers::signer::{Metadata, MetadataMaterial, self};
 use crate::onion_message::BlindedPath;
 use crate::util::ser::{SeekReadable, WithoutLength, Writeable, Writer};
 use crate::util::string::PrintableString;
@@ -343,7 +344,7 @@ impl Refund {
        ///
        /// [`payer_id`]: Self::payer_id
        pub fn metadata(&self) -> &[u8] {
-               self.contents.payer.0.as_bytes().map(|bytes| bytes.as_slice()).unwrap_or(&[])
+               self.contents.metadata()
        }
 
        /// A chain that the refund is valid for.
@@ -455,6 +456,10 @@ impl RefundContents {
                }
        }
 
+       fn metadata(&self) -> &[u8] {
+               self.payer.0.as_bytes().map(|bytes| bytes.as_slice()).unwrap_or(&[])
+       }
+
        pub(super) fn chain(&self) -> ChainHash {
                self.chain.unwrap_or_else(|| self.implied_chain())
        }
@@ -463,6 +468,22 @@ impl RefundContents {
                ChainHash::using_genesis_block(Network::Bitcoin)
        }
 
+       /// Verifies that the payer metadata was produced from the refund in the TLV stream.
+       pub(super) fn verify<T: secp256k1::Signing>(
+               &self, tlv_stream: TlvStream<'_>, key: &ExpandedKey, secp_ctx: &Secp256k1<T>
+       ) -> bool {
+               let offer_records = tlv_stream.clone().range(OFFER_TYPES);
+               let invreq_records = tlv_stream.range(INVOICE_REQUEST_TYPES).filter(|record| {
+                       match record.r#type {
+                               PAYER_METADATA_TYPE => false, // Should be outside range
+                               INVOICE_REQUEST_PAYER_ID_TYPE => !self.payer.0.derives_keys(),
+                               _ => true,
+                       }
+               });
+               let tlv_stream = offer_records.chain(invreq_records);
+               signer::verify_metadata(self.metadata(), key, IV_BYTES, self.payer_id, tlv_stream, secp_ctx)
+       }
+
        pub(super) fn as_tlv_stream(&self) -> RefundTlvStreamRef {
                let payer = PayerTlvStreamRef {
                        metadata: self.payer.0.as_bytes(),
@@ -640,7 +661,9 @@ mod tests {
        use bitcoin::secp256k1::{KeyPair, Secp256k1, SecretKey};
        use core::convert::TryFrom;
        use core::time::Duration;
+       use crate::chain::keysinterface::KeyMaterial;
        use crate::ln::features::{InvoiceRequestFeatures, OfferFeatures};
+       use crate::ln::inbound_payment::ExpandedKey;
        use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT};
        use crate::offers::invoice_request::InvoiceRequestTlvStreamRef;
        use crate::offers::offer::OfferTlvStreamRef;
@@ -726,6 +749,118 @@ mod tests {
                }
        }
 
+       #[test]
+       fn builds_refund_with_metadata_derived() {
+               let desc = "foo".to_string();
+               let node_id = payer_pubkey();
+               let expanded_key = ExpandedKey::new(&KeyMaterial([42; 32]));
+               let entropy = FixedEntropy {};
+               let secp_ctx = Secp256k1::new();
+
+               let refund = RefundBuilder
+                       ::deriving_payer_id(desc, node_id, &expanded_key, &entropy, &secp_ctx, 1000)
+                       .unwrap()
+                       .build().unwrap();
+               assert_eq!(refund.payer_id(), node_id);
+
+               // Fails verification with altered fields
+               let invoice = refund
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(invoice.verify(&expanded_key, &secp_ctx));
+
+               let mut tlv_stream = refund.as_tlv_stream();
+               tlv_stream.2.amount = Some(2000);
+
+               let mut encoded_refund = Vec::new();
+               tlv_stream.write(&mut encoded_refund).unwrap();
+
+               let invoice = Refund::try_from(encoded_refund).unwrap()
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(!invoice.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered metadata
+               let mut tlv_stream = refund.as_tlv_stream();
+               let metadata = tlv_stream.0.metadata.unwrap().iter().copied().rev().collect();
+               tlv_stream.0.metadata = Some(&metadata);
+
+               let mut encoded_refund = Vec::new();
+               tlv_stream.write(&mut encoded_refund).unwrap();
+
+               let invoice = Refund::try_from(encoded_refund).unwrap()
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(!invoice.verify(&expanded_key, &secp_ctx));
+       }
+
+       #[test]
+       fn builds_refund_with_derived_payer_id() {
+               let desc = "foo".to_string();
+               let node_id = payer_pubkey();
+               let expanded_key = ExpandedKey::new(&KeyMaterial([42; 32]));
+               let entropy = FixedEntropy {};
+               let secp_ctx = Secp256k1::new();
+
+               let blinded_path = BlindedPath {
+                       introduction_node_id: pubkey(40),
+                       blinding_point: pubkey(41),
+                       blinded_hops: vec![
+                               BlindedHop { blinded_node_id: pubkey(43), encrypted_payload: vec![0; 43] },
+                               BlindedHop { blinded_node_id: node_id, encrypted_payload: vec![0; 44] },
+                       ],
+               };
+
+               let refund = RefundBuilder
+                       ::deriving_payer_id(desc, node_id, &expanded_key, &entropy, &secp_ctx, 1000)
+                       .unwrap()
+                       .path(blinded_path)
+                       .build().unwrap();
+               assert_ne!(refund.payer_id(), node_id);
+
+               let invoice = refund
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(invoice.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered fields
+               let mut tlv_stream = refund.as_tlv_stream();
+               tlv_stream.2.amount = Some(2000);
+
+               let mut encoded_refund = Vec::new();
+               tlv_stream.write(&mut encoded_refund).unwrap();
+
+               let invoice = Refund::try_from(encoded_refund).unwrap()
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(!invoice.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered payer_id
+               let mut tlv_stream = refund.as_tlv_stream();
+               let payer_id = pubkey(1);
+               tlv_stream.2.payer_id = Some(&payer_id);
+
+               let mut encoded_refund = Vec::new();
+               tlv_stream.write(&mut encoded_refund).unwrap();
+
+               let invoice = Refund::try_from(encoded_refund).unwrap()
+                       .respond_with_no_std(payment_paths(), payment_hash(), recipient_pubkey(), now())
+                       .unwrap()
+                       .build().unwrap()
+                       .sign(recipient_sign).unwrap();
+               assert!(!invoice.verify(&expanded_key, &secp_ctx));
+       }
+
        #[test]
        fn builds_refund_with_absolute_expiry() {
                let future_expiry = Duration::from_secs(u64::max_value());