From dd2ccd232234d93c482c333b20dc71d53c4b7247 Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 7 Feb 2023 19:15:44 -0600 Subject: [PATCH] Stateless verification of InvoiceRequest 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 | 21 +++- lightning/src/offers/invoice_request.rs | 12 +- lightning/src/offers/offer.rs | 153 ++++++++++++++++++++++-- lightning/src/offers/signer.rs | 51 +++++++- lightning/src/offers/test_utils.rs | 9 ++ 5 files changed, 231 insertions(+), 15 deletions(-) diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index e6668a33..2d15876b 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -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 { + 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, diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index 8bb5737c..79dff614 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -54,15 +54,16 @@ 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( + &self, key: &ExpandedKey, secp_ctx: &Secp256k1 + ) -> 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) = diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index a5935c87..6a8f956a 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -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( 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( + &self, tlv_stream: TlvStream<'_>, key: &ExpandedKey, secp_ctx: &Secp256k1 + ) -> 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 = 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, WithoutLength)), - (4, metadata: (Vec, WithoutLength)), + (OFFER_METADATA_TYPE, metadata: (Vec, 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, 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 }; diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs index e1a1a4df..2ee3d13a 100644 --- a/lightning/src/offers/signer.rs +++ b/lightning/src/offers/signer.rs @@ -10,12 +10,14 @@ //! 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, expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN], + signing_pubkey: PublicKey, tlv_stream: impl core::iter::Iterator>, + secp_ctx: &Secp256k1 +) -> 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 + } +} diff --git a/lightning/src/offers/test_utils.rs b/lightning/src/offers/test_utils.rs index 7447b86f..43664079 100644 --- a/lightning/src/offers/test_utils.rs +++ b/lightning/src/offers/test_utils.rs @@ -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] + } +} -- 2.30.2