Stateless verification of InvoiceRequest
authorJeffrey Czyz <jkczyz@gmail.com>
Wed, 8 Feb 2023 01:15:44 +0000 (19:15 -0600)
committerJeffrey Czyz <jkczyz@gmail.com>
Thu, 20 Apr 2023 02:30:40 +0000 (21:30 -0500)
Verify that an InvoiceRequest was produced from an Offer constructed by
the recipient using the Offer metadata reflected in the InvoiceRequest.
The Offer metadata consists of a 128-bit encrypted nonce and possibly a
256-bit HMAC over the nonce and Offer TLV records (excluding the signing
pubkey) using an ExpandedKey.

Thus, the HMAC can be reproduced from the offer 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 signing
pubkey.

lightning/src/ln/inbound_payment.rs
lightning/src/offers/invoice_request.rs
lightning/src/offers/offer.rs
lightning/src/offers/signer.rs
lightning/src/offers/test_utils.rs

index e6668a33cee2d12cf2710a0cab4088523e156ab9..2d15876bf95ccad75fb90233b135ffd0fabd17ad 100644 (file)
@@ -23,7 +23,7 @@ use crate::util::crypto::hkdf_extract_expand_4x;
 use crate::util::errors::APIError;
 use crate::util::logger::Logger;
 
-use core::convert::TryInto;
+use core::convert::{TryFrom, TryInto};
 use core::ops::Deref;
 
 pub(crate) const IV_LEN: usize = 16;
@@ -89,8 +89,8 @@ impl ExpandedKey {
 /// [`Offer::metadata`]: crate::offers::offer::Offer::metadata
 /// [`Offer::signing_pubkey`]: crate::offers::offer::Offer::signing_pubkey
 #[allow(unused)]
-#[derive(Clone, Copy)]
-pub(crate) struct Nonce([u8; Self::LENGTH]);
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Nonce(pub(crate) [u8; Self::LENGTH]);
 
 impl Nonce {
        /// Number of bytes in the nonce.
@@ -114,6 +114,21 @@ impl Nonce {
        }
 }
 
+impl TryFrom<&[u8]> for Nonce {
+       type Error = ();
+
+       fn try_from(bytes: &[u8]) -> Result<Self, ()> {
+               if bytes.len() != Self::LENGTH {
+                       return Err(());
+               }
+
+               let mut copied_bytes = [0u8; Self::LENGTH];
+               copied_bytes.copy_from_slice(bytes);
+
+               Ok(Self(copied_bytes))
+       }
+}
+
 enum Method {
        LdkPaymentHash = 0,
        UserPaymentHash = 1,
index 8bb5737c3f789b1f473872320697a9dc7fe3fb41..79dff614b68f2772a40834e72cec72cd4fd4c49e 100644 (file)
 
 use bitcoin::blockdata::constants::ChainHash;
 use bitcoin::network::constants::Network;
-use bitcoin::secp256k1::{Message, PublicKey};
+use bitcoin::secp256k1::{Message, PublicKey, Secp256k1, self};
 use bitcoin::secp256k1::schnorr::Signature;
 use core::convert::TryFrom;
 use crate::io;
 use crate::ln::PaymentHash;
 use crate::ln::features::InvoiceRequestFeatures;
+use crate::ln::inbound_payment::ExpandedKey;
 use crate::ln::msgs::DecodeError;
 use crate::offers::invoice::{BlindedPayInfo, InvoiceBuilder};
-use crate::offers::merkle::{SignError, SignatureTlvStream, SignatureTlvStreamRef, self};
+use crate::offers::merkle::{SignError, SignatureTlvStream, SignatureTlvStreamRef, TlvStream, self};
 use crate::offers::offer::{Offer, OfferContents, OfferTlvStream, OfferTlvStreamRef};
 use crate::offers::parse::{ParseError, ParsedMessage, SemanticError};
 use crate::offers::payer::{PayerContents, PayerTlvStream, PayerTlvStreamRef};
@@ -372,6 +373,13 @@ impl InvoiceRequest {
                InvoiceBuilder::for_offer(self, payment_paths, created_at, payment_hash)
        }
 
+       /// Verifies that the request was for an offer created using the given key.
+       pub fn verify<T: secp256k1::Signing>(
+               &self, key: &ExpandedKey, secp_ctx: &Secp256k1<T>
+       ) -> bool {
+               self.contents.offer.verify(TlvStream::new(&self.bytes), key, secp_ctx)
+       }
+
        #[cfg(test)]
        fn as_tlv_stream(&self) -> FullInvoiceRequestTlvStreamRef {
                let (payer_tlv_stream, offer_tlv_stream, invoice_request_tlv_stream) =
index a5935c87b8ab0482144a4df710a77343943d6ef8..6a8f956ae635eb8f3278ee85f48893a9fb8f5aaf 100644 (file)
@@ -80,8 +80,9 @@ use crate::ln::features::OfferFeatures;
 use crate::ln::inbound_payment::{ExpandedKey, IV_LEN, Nonce};
 use crate::ln::msgs::MAX_VALUE_MSAT;
 use crate::offers::invoice_request::InvoiceRequestBuilder;
+use crate::offers::merkle::TlvStream;
 use crate::offers::parse::{Bech32Encode, ParseError, ParsedMessage, SemanticError};
-use crate::offers::signer::{Metadata, MetadataMaterial};
+use crate::offers::signer::{Metadata, MetadataMaterial, self};
 use crate::onion_message::BlindedPath;
 use crate::util::ser::{HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer};
 use crate::util::string::PrintableString;
@@ -149,10 +150,11 @@ impl<'a, T: secp256k1::Signing> OfferBuilder<'a, DerivedMetadata, T> {
        /// recipient privacy by using a different signing pubkey for each offer. Otherwise, the
        /// provided `node_id` is used for the signing pubkey.
        ///
-       /// Also, sets the metadata when [`OfferBuilder::build`] is called such that it can be used to
-       /// verify that an [`InvoiceRequest`] was produced for the offer given an [`ExpandedKey`].
+       /// Also, sets the metadata when [`OfferBuilder::build`] is called such that it can be used by
+       /// [`InvoiceRequest::verify`] to determine if the request was produced for the offer given an
+       /// [`ExpandedKey`].
        ///
-       /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest
+       /// [`InvoiceRequest::verify`]: crate::offers::invoice_request::InvoiceRequest::verify
        /// [`ExpandedKey`]: crate::ln::inbound_payment::ExpandedKey
        pub fn deriving_signing_pubkey<ES: Deref>(
                description: String, node_id: PublicKey, expanded_key: &ExpandedKey, entropy_source: ES,
@@ -566,6 +568,27 @@ impl OfferContents {
                self.signing_pubkey
        }
 
+       /// Verifies that the offer metadata was produced from the offer in the TLV stream.
+       pub(super) fn verify<T: secp256k1::Signing>(
+               &self, tlv_stream: TlvStream<'_>, key: &ExpandedKey, secp_ctx: &Secp256k1<T>
+       ) -> bool {
+               match self.metadata() {
+                       Some(metadata) => {
+                               let tlv_stream = tlv_stream.range(OFFER_TYPES).filter(|record| {
+                                       match record.r#type {
+                                               OFFER_METADATA_TYPE => false,
+                                               OFFER_NODE_ID_TYPE => !self.metadata.as_ref().unwrap().derives_keys(),
+                                               _ => true,
+                                       }
+                               });
+                               signer::verify_metadata(
+                                       metadata, key, IV_BYTES, self.signing_pubkey(), tlv_stream, secp_ctx
+                               )
+                       },
+                       None => false,
+               }
+       }
+
        pub(super) fn as_tlv_stream(&self) -> OfferTlvStreamRef {
                let (currency, amount) = match &self.amount {
                        None => (None, None),
@@ -653,9 +676,18 @@ impl Quantity {
        }
 }
 
-tlv_stream!(OfferTlvStream, OfferTlvStreamRef, 1..80, {
+/// Valid type range for offer TLV records.
+const OFFER_TYPES: core::ops::Range<u64> = 1..80;
+
+/// TLV record type for [`Offer::metadata`].
+const OFFER_METADATA_TYPE: u64 = 4;
+
+/// TLV record type for [`Offer::signing_pubkey`].
+const OFFER_NODE_ID_TYPE: u64 = 22;
+
+tlv_stream!(OfferTlvStream, OfferTlvStreamRef, OFFER_TYPES, {
        (2, chains: (Vec<ChainHash>, WithoutLength)),
-       (4, metadata: (Vec<u8>, WithoutLength)),
+       (OFFER_METADATA_TYPE, metadata: (Vec<u8>, WithoutLength)),
        (6, currency: CurrencyCode),
        (8, amount: (u64, HighZeroBytesDroppedBigSize)),
        (10, description: (String, WithoutLength)),
@@ -664,7 +696,7 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, 1..80, {
        (16, paths: (Vec<BlindedPath>, WithoutLength)),
        (18, issuer: (String, WithoutLength)),
        (20, quantity_max: (u64, HighZeroBytesDroppedBigSize)),
-       (22, node_id: PublicKey),
+       (OFFER_NODE_ID_TYPE, node_id: PublicKey),
 });
 
 impl Bech32Encode for Offer {
@@ -751,10 +783,13 @@ mod tests {
 
        use bitcoin::blockdata::constants::ChainHash;
        use bitcoin::network::constants::Network;
+       use bitcoin::secp256k1::Secp256k1;
        use core::convert::TryFrom;
        use core::num::NonZeroU64;
        use core::time::Duration;
+       use crate::chain::keysinterface::KeyMaterial;
        use crate::ln::features::OfferFeatures;
+       use crate::ln::inbound_payment::ExpandedKey;
        use crate::ln::msgs::{DecodeError, MAX_VALUE_MSAT};
        use crate::offers::parse::{ParseError, SemanticError};
        use crate::offers::test_utils::*;
@@ -865,6 +900,110 @@ mod tests {
                assert_eq!(offer.as_tlv_stream().metadata, Some(&vec![43; 32]));
        }
 
+       #[test]
+       fn builds_offer_with_metadata_derived() {
+               let desc = "foo".to_string();
+               let node_id = recipient_pubkey();
+               let expanded_key = ExpandedKey::new(&KeyMaterial([42; 32]));
+               let entropy = FixedEntropy {};
+               let secp_ctx = Secp256k1::new();
+
+               let offer = OfferBuilder
+                       ::deriving_signing_pubkey(desc, node_id, &expanded_key, &entropy, &secp_ctx)
+                       .amount_msats(1000)
+                       .build().unwrap();
+               assert_eq!(offer.signing_pubkey(), node_id);
+
+               let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(invoice_request.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered offer field
+               let mut tlv_stream = offer.as_tlv_stream();
+               tlv_stream.amount = Some(100);
+
+               let mut encoded_offer = Vec::new();
+               tlv_stream.write(&mut encoded_offer).unwrap();
+
+               let invoice_request = Offer::try_from(encoded_offer).unwrap()
+                       .request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(!invoice_request.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered metadata
+               let mut tlv_stream = offer.as_tlv_stream();
+               let metadata = tlv_stream.metadata.unwrap().iter().copied().rev().collect();
+               tlv_stream.metadata = Some(&metadata);
+
+               let mut encoded_offer = Vec::new();
+               tlv_stream.write(&mut encoded_offer).unwrap();
+
+               let invoice_request = Offer::try_from(encoded_offer).unwrap()
+                       .request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(!invoice_request.verify(&expanded_key, &secp_ctx));
+       }
+
+       #[test]
+       fn builds_offer_with_derived_signing_pubkey() {
+               let desc = "foo".to_string();
+               let node_id = recipient_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(42), encrypted_payload: vec![0; 43] },
+                               BlindedHop { blinded_node_id: node_id, encrypted_payload: vec![0; 44] },
+                       ],
+               };
+
+               let offer = OfferBuilder
+                       ::deriving_signing_pubkey(desc, node_id, &expanded_key, &entropy, &secp_ctx)
+                       .amount_msats(1000)
+                       .path(blinded_path)
+                       .build().unwrap();
+               assert_ne!(offer.signing_pubkey(), node_id);
+
+               let invoice_request = offer.request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(invoice_request.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered offer field
+               let mut tlv_stream = offer.as_tlv_stream();
+               tlv_stream.amount = Some(100);
+
+               let mut encoded_offer = Vec::new();
+               tlv_stream.write(&mut encoded_offer).unwrap();
+
+               let invoice_request = Offer::try_from(encoded_offer).unwrap()
+                       .request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(!invoice_request.verify(&expanded_key, &secp_ctx));
+
+               // Fails verification with altered signing pubkey
+               let mut tlv_stream = offer.as_tlv_stream();
+               let signing_pubkey = pubkey(1);
+               tlv_stream.node_id = Some(&signing_pubkey);
+
+               let mut encoded_offer = Vec::new();
+               tlv_stream.write(&mut encoded_offer).unwrap();
+
+               let invoice_request = Offer::try_from(encoded_offer).unwrap()
+                       .request_invoice(vec![1; 32], payer_pubkey()).unwrap()
+                       .build().unwrap()
+                       .sign(payer_sign).unwrap();
+               assert!(!invoice_request.verify(&expanded_key, &secp_ctx));
+       }
+
        #[test]
        fn builds_offer_with_amount() {
                let bitcoin_amount = Amount::Bitcoin { amount_msats: 1000 };
index e1a1a4dfd6cf0ac07ac4523bc4296c2fbe327cbf..2ee3d13afbb9c173c1c9bf03654d392e7fa5f450 100644 (file)
 //! Utilities for signing offer messages and verifying metadata.
 
 use bitcoin::hashes::{Hash, HashEngine};
+use bitcoin::hashes::cmp::fixed_time_eq;
 use bitcoin::hashes::hmac::{Hmac, HmacEngine};
 use bitcoin::hashes::sha256::Hash as Sha256;
-use bitcoin::secp256k1::{KeyPair, Secp256k1, SecretKey, self};
-use core::convert::TryInto;
+use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey, self};
+use core::convert::TryFrom;
 use core::fmt;
 use crate::ln::inbound_payment::{ExpandedKey, IV_LEN, Nonce};
+use crate::offers::merkle::TlvRecord;
 use crate::util::ser::Writeable;
 
 use crate::prelude::*;
@@ -56,7 +58,12 @@ impl Metadata {
 
        pub fn derives_keys(&self) -> bool {
                match self {
-                       Metadata::Bytes(_) => false,
+                       // Infer whether Metadata::derived_from was called on Metadata::DerivedSigningPubkey to
+                       // produce Metadata::Bytes. This is merely to determine which fields should be included
+                       // when verifying a message. It doesn't necessarily indicate that keys were in fact
+                       // derived, as wouldn't be the case if a Metadata::Bytes with length Nonce::LENGTH had
+                       // been set explicitly.
+                       Metadata::Bytes(bytes) => bytes.len() == Nonce::LENGTH,
                        Metadata::Derived(_) => false,
                        Metadata::DerivedSigningPubkey(_) => true,
                }
@@ -148,3 +155,41 @@ impl MetadataMaterial {
                (self.nonce.as_slice().to_vec(), keys)
        }
 }
+
+/// Verifies data given in a TLV stream was used to produce the given metadata, consisting of:
+/// - a 128-bit [`Nonce`] and possibly
+/// - a [`Sha256`] hash of the nonce and the TLV records using the [`ExpandedKey`].
+///
+/// If the latter is not included in the metadata, the TLV stream is used to check if the given
+/// `signing_pubkey` can be derived from it.
+pub(super) fn verify_metadata<'a, T: secp256k1::Signing>(
+       metadata: &Vec<u8>, expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN],
+       signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator<Item = TlvRecord<'a>>,
+       secp_ctx: &Secp256k1<T>
+) -> bool {
+       if metadata.len() < Nonce::LENGTH {
+               return false;
+       }
+
+       let nonce = match Nonce::try_from(&metadata[..Nonce::LENGTH]) {
+               Ok(nonce) => nonce,
+               Err(_) => return false,
+       };
+       let mut hmac = expanded_key.hmac_for_offer(nonce, iv_bytes);
+
+       for record in tlv_stream {
+               hmac.input(record.record_bytes);
+       }
+
+       if metadata.len() == Nonce::LENGTH {
+               hmac.input(DERIVED_METADATA_AND_KEYS_HMAC_INPUT);
+               let hmac = Hmac::from_engine(hmac);
+               let derived_pubkey = SecretKey::from_slice(hmac.as_inner()).unwrap().public_key(&secp_ctx);
+               fixed_time_eq(&signing_pubkey.serialize(), &derived_pubkey.serialize())
+       } else if metadata[Nonce::LENGTH..].len() == Sha256::LEN {
+               hmac.input(DERIVED_METADATA_HMAC_INPUT);
+               fixed_time_eq(&metadata[Nonce::LENGTH..], &Hmac::from_engine(hmac).into_inner())
+       } else {
+               false
+       }
+}
index 7447b86fb84f3409c758cd485cd291067dad2183..43664079dbd55580b224f1926e017525c85c9249 100644 (file)
@@ -13,6 +13,7 @@ use bitcoin::secp256k1::{KeyPair, Message, PublicKey, Secp256k1, SecretKey};
 use bitcoin::secp256k1::schnorr::Signature;
 use core::convert::Infallible;
 use core::time::Duration;
+use crate::chain::keysinterface::EntropySource;
 use crate::ln::PaymentHash;
 use crate::ln::features::BlindedHopFeatures;
 use crate::offers::invoice::BlindedPayInfo;
@@ -108,3 +109,11 @@ pub(super) fn now() -> Duration {
                .duration_since(std::time::SystemTime::UNIX_EPOCH)
                .expect("SystemTime::now() should come after SystemTime::UNIX_EPOCH")
 }
+
+pub(super) struct FixedEntropy;
+
+impl EntropySource for FixedEntropy {
+       fn get_secure_random_bytes(&self) -> [u8; 32] {
+               [42; 32]
+       }
+}