From 1cad430e14108710c826adebbfab2a5ea64a6a5a Mon Sep 17 00:00:00 2001 From: Jeffrey Czyz Date: Tue, 7 Feb 2023 19:13:08 -0600 Subject: [PATCH] Offer metadata and signing pubkey derivation Add support for deriving a transient signing pubkey for each Offer from an ExpandedKey and a nonce. This facilitates recipient privacy by not tying any Offer to any other nor to the recipient's node id. Additionally, support stateless Offer verification by setting its metadata using an HMAC over the nonce and the remaining TLV records, which will be later verified when receiving an InvoiceRequest. --- lightning/src/ln/inbound_payment.rs | 48 +++++++- lightning/src/offers/invoice.rs | 6 +- lightning/src/offers/invoice_request.rs | 6 +- lightning/src/offers/mod.rs | 2 + lightning/src/offers/offer.rs | 153 ++++++++++++++++++------ lightning/src/offers/payer.rs | 3 +- lightning/src/offers/refund.rs | 6 +- lightning/src/offers/signer.rs | 150 +++++++++++++++++++++++ 8 files changed, 332 insertions(+), 42 deletions(-) create mode 100644 lightning/src/offers/signer.rs diff --git a/lightning/src/ln/inbound_payment.rs b/lightning/src/ln/inbound_payment.rs index 058339cbc..e6668a33c 100644 --- a/lightning/src/ln/inbound_payment.rs +++ b/lightning/src/ln/inbound_payment.rs @@ -26,7 +26,7 @@ use crate::util::logger::Logger; use core::convert::TryInto; use core::ops::Deref; -const IV_LEN: usize = 16; +pub(crate) const IV_LEN: usize = 16; const METADATA_LEN: usize = 16; const METADATA_KEY_LEN: usize = 32; const AMT_MSAT_LEN: usize = 8; @@ -66,6 +66,52 @@ impl ExpandedKey { offers_base_key, } } + + /// Returns an [`HmacEngine`] used to construct [`Offer::metadata`]. + /// + /// [`Offer::metadata`]: crate::offers::offer::Offer::metadata + #[allow(unused)] + pub(crate) fn hmac_for_offer( + &self, nonce: Nonce, iv_bytes: &[u8; IV_LEN] + ) -> HmacEngine { + let mut hmac = HmacEngine::::new(&self.offers_base_key); + hmac.input(iv_bytes); + hmac.input(&nonce.0); + hmac + } +} + +/// A 128-bit number used only once. +/// +/// Needed when constructing [`Offer::metadata`] and deriving [`Offer::signing_pubkey`] from +/// [`ExpandedKey`]. Must not be reused for any other derivation without first hashing. +/// +/// [`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]); + +impl Nonce { + /// Number of bytes in the nonce. + pub const LENGTH: usize = 16; + + /// Creates a `Nonce` from the given [`EntropySource`]. + pub fn from_entropy_source(entropy_source: ES) -> Self + where + ES::Target: EntropySource, + { + let mut bytes = [0u8; Self::LENGTH]; + let rand_bytes = entropy_source.get_secure_random_bytes(); + bytes.copy_from_slice(&rand_bytes[..Self::LENGTH]); + + Nonce(bytes) + } + + /// Returns a slice of the underlying bytes of size [`Nonce::LENGTH`]. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } } enum Method { diff --git a/lightning/src/offers/invoice.rs b/lightning/src/offers/invoice.rs index 2c530760c..b0783a306 100644 --- a/lightning/src/offers/invoice.rs +++ b/lightning/src/offers/invoice.rs @@ -313,7 +313,8 @@ impl<'a> UnsignedInvoice<'a> { /// [`Offer`]: crate::offers::offer::Offer /// [`Refund`]: crate::offers::refund::Refund /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct Invoice { bytes: Vec, contents: InvoiceContents, @@ -324,7 +325,8 @@ pub struct Invoice { /// /// [`Offer`]: crate::offers::offer::Offer /// [`Refund`]: crate::offers::refund::Refund -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] enum InvoiceContents { /// Contents for an [`Invoice`] corresponding to an [`Offer`]. /// diff --git a/lightning/src/offers/invoice_request.rs b/lightning/src/offers/invoice_request.rs index db540ce62..8bb5737c3 100644 --- a/lightning/src/offers/invoice_request.rs +++ b/lightning/src/offers/invoice_request.rs @@ -250,7 +250,8 @@ impl<'a> UnsignedInvoiceRequest<'a> { /// /// [`Invoice`]: crate::offers::invoice::Invoice /// [`Offer`]: crate::offers::offer::Offer -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct InvoiceRequest { pub(super) bytes: Vec, pub(super) contents: InvoiceRequestContents, @@ -260,7 +261,8 @@ pub struct InvoiceRequest { /// The contents of an [`InvoiceRequest`], which may be shared with an [`Invoice`]. /// /// [`Invoice`]: crate::offers::invoice::Invoice -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub(super) struct InvoiceRequestContents { payer: PayerContents, pub(super) offer: OfferContents, diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index c2b0d6aea..0fb20f42d 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -19,5 +19,7 @@ pub mod offer; pub mod parse; mod payer; pub mod refund; +#[allow(unused)] +pub(crate) mod signer; #[cfg(test)] mod test_utils; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index a1445c6f7..a5935c87b 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -68,16 +68,20 @@ use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1::{PublicKey, Secp256k1, self}; use core::convert::TryFrom; use core::num::NonZeroU64; +use core::ops::Deref; use core::str::FromStr; use core::time::Duration; +use crate::chain::keysinterface::EntropySource; use crate::io; 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::parse::{Bech32Encode, ParseError, ParsedMessage, SemanticError}; +use crate::offers::signer::{Metadata, MetadataMaterial}; use crate::onion_message::BlindedPath; use crate::util::ser::{HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer}; use crate::util::string::PrintableString; @@ -87,30 +91,89 @@ use crate::prelude::*; #[cfg(feature = "std")] use std::time::SystemTime; +const IV_BYTES: &[u8; IV_LEN] = b"LDK Offer ~~~~~~"; + /// Builds an [`Offer`] for the "offer to be paid" flow. /// /// See [module-level documentation] for usage. /// /// [module-level documentation]: self -pub struct OfferBuilder { +pub struct OfferBuilder<'a, M: MetadataStrategy, T: secp256k1::Signing> { offer: OfferContents, + metadata_strategy: core::marker::PhantomData, + secp_ctx: Option<&'a Secp256k1>, } -impl OfferBuilder { +/// Indicates how [`Offer::metadata`] may be set. +pub trait MetadataStrategy {} + +/// [`Offer::metadata`] may be explicitly set or left empty. +pub struct ExplicitMetadata {} + +/// [`Offer::metadata`] will be derived. +pub struct DerivedMetadata {} + +impl MetadataStrategy for ExplicitMetadata {} +impl MetadataStrategy for DerivedMetadata {} + +impl<'a> OfferBuilder<'a, ExplicitMetadata, secp256k1::SignOnly> { /// Creates a new builder for an offer setting the [`Offer::description`] and using the /// [`Offer::signing_pubkey`] for signing invoices. The associated secret key must be remembered /// while the offer is valid. /// /// Use a different pubkey per offer to avoid correlating offers. pub fn new(description: String, signing_pubkey: PublicKey) -> Self { - let 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, - }; - OfferBuilder { offer } + OfferBuilder { + 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, + }, + metadata_strategy: core::marker::PhantomData, + secp_ctx: None, + } + } + + /// Sets the [`Offer::metadata`] to the given bytes. + /// + /// Successive calls to this method will override the previous setting. + pub fn metadata(mut self, metadata: Vec) -> Result { + self.offer.metadata = Some(Metadata::Bytes(metadata)); + Ok(self) } +} +impl<'a, T: secp256k1::Signing> OfferBuilder<'a, DerivedMetadata, T> { + /// Similar to [`OfferBuilder::new`] except, if [`OfferBuilder::path`] is called, the signing + /// pubkey is derived from the given [`ExpandedKey`] and [`EntropySource`]. This provides + /// 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`]. + /// + /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest + /// [`ExpandedKey`]: crate::ln::inbound_payment::ExpandedKey + pub fn deriving_signing_pubkey( + description: String, node_id: PublicKey, expanded_key: &ExpandedKey, entropy_source: ES, + secp_ctx: &'a Secp256k1 + ) -> Self where ES::Target: EntropySource { + let nonce = Nonce::from_entropy_source(entropy_source); + let derivation_material = MetadataMaterial::new(nonce, expanded_key, IV_BYTES); + let metadata = Metadata::DerivedSigningPubkey(derivation_material); + OfferBuilder { + 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, + }, + metadata_strategy: core::marker::PhantomData, + secp_ctx: Some(secp_ctx), + } + } +} + +impl<'a, M: MetadataStrategy, T: secp256k1::Signing> OfferBuilder<'a, M, T> { /// Adds the chain hash of the given [`Network`] to [`Offer::chains`]. If not called, /// the chain hash of [`Network::Bitcoin`] is assumed to be the only one supported. /// @@ -127,14 +190,6 @@ impl OfferBuilder { self } - /// Sets the [`Offer::metadata`]. - /// - /// Successive calls to this method will override the previous setting. - pub fn metadata(mut self, metadata: Vec) -> Self { - self.offer.metadata = Some(metadata); - self - } - /// Sets the [`Offer::amount`] as an [`Amount::Bitcoin`]. /// /// Successive calls to this method will override the previous setting. @@ -204,28 +259,50 @@ impl OfferBuilder { } } + Ok(self.build_without_checks()) + } + + fn build_without_checks(mut self) -> Offer { + // Create the metadata for stateless verification of an InvoiceRequest. + if let Some(mut metadata) = self.offer.metadata.take() { + if metadata.has_derivation_material() { + if self.offer.paths.is_none() { + metadata = metadata.without_keys(); + } + + let mut tlv_stream = self.offer.as_tlv_stream(); + debug_assert_eq!(tlv_stream.metadata, None); + tlv_stream.metadata = None; + if metadata.derives_keys() { + tlv_stream.node_id = None; + } + + 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.metadata = Some(metadata); + } + let mut bytes = Vec::new(); self.offer.write(&mut bytes).unwrap(); - Ok(Offer { - bytes, - contents: self.offer, - }) + Offer { bytes, contents: self.offer } } } #[cfg(test)] -impl OfferBuilder { +impl<'a, M: MetadataStrategy, T: secp256k1::Signing> OfferBuilder<'a, M, T> { fn features_unchecked(mut self, features: OfferFeatures) -> Self { self.offer.features = features; self } pub(super) fn build_unchecked(self) -> Offer { - let mut bytes = Vec::new(); - self.offer.write(&mut bytes).unwrap(); - - Offer { bytes, contents: self.offer } + self.build_without_checks() } } @@ -242,7 +319,8 @@ impl OfferBuilder { /// /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`Invoice`]: crate::offers::invoice::Invoice -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct Offer { // The serialized offer. Needed when creating an `InvoiceRequest` if the offer contains unknown // fields. @@ -254,10 +332,11 @@ pub struct Offer { /// /// [`InvoiceRequest`]: crate::offers::invoice_request::InvoiceRequest /// [`Invoice`]: crate::offers::invoice::Invoice -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub(super) struct OfferContents { chains: Option>, - metadata: Option>, + metadata: Option, amount: Option, description: String, features: OfferFeatures, @@ -292,7 +371,7 @@ impl Offer { /// Opaque bytes set by the originator. Useful for authentication and validating fields since it /// is reflected in `invoice_request` messages along with all the other fields from the `offer`. pub fn metadata(&self) -> Option<&Vec> { - self.contents.metadata.as_ref() + self.contents.metadata() } /// The minimum amount required for a successful payment of a single item. @@ -406,6 +485,10 @@ impl OfferContents { self.chains().contains(&chain) } + pub fn metadata(&self) -> Option<&Vec> { + self.metadata.as_ref().and_then(|metadata| metadata.as_bytes()) + } + #[cfg(feature = "std")] pub(super) fn is_expired(&self) -> bool { match self.absolute_expiry { @@ -498,7 +581,7 @@ impl OfferContents { OfferTlvStreamRef { chains: self.chains.as_ref(), - metadata: self.metadata.as_ref(), + metadata: self.metadata(), currency, amount, description: Some(&self.description), @@ -616,6 +699,8 @@ impl TryFrom for OfferContents { issuer, quantity_max, node_id, } = tlv_stream; + let metadata = metadata.map(|metadata| Metadata::Bytes(metadata)); + let amount = match (currency, amount) { (None, None) => None, (None, Some(amount_msats)) if amount_msats > MAX_VALUE_MSAT => { @@ -765,15 +850,15 @@ mod tests { #[test] fn builds_offer_with_metadata() { let offer = OfferBuilder::new("foo".into(), pubkey(42)) - .metadata(vec![42; 32]) + .metadata(vec![42; 32]).unwrap() .build() .unwrap(); assert_eq!(offer.metadata(), Some(&vec![42; 32])); assert_eq!(offer.as_tlv_stream().metadata, Some(&vec![42; 32])); let offer = OfferBuilder::new("foo".into(), pubkey(42)) - .metadata(vec![42; 32]) - .metadata(vec![43; 32]) + .metadata(vec![42; 32]).unwrap() + .metadata(vec![43; 32]).unwrap() .build() .unwrap(); assert_eq!(offer.metadata(), Some(&vec![43; 32])); diff --git a/lightning/src/offers/payer.rs b/lightning/src/offers/payer.rs index 7e1da769e..12b471c6c 100644 --- a/lightning/src/offers/payer.rs +++ b/lightning/src/offers/payer.rs @@ -17,7 +17,8 @@ use crate::prelude::*; /// [`InvoiceRequest::payer_id`]. /// /// [`InvoiceRequest::payer_id`]: crate::offers::invoice_request::InvoiceRequest::payer_id -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub(super) struct PayerContents(pub Vec); tlv_stream!(PayerTlvStream, PayerTlvStreamRef, 0..1, { diff --git a/lightning/src/offers/refund.rs b/lightning/src/offers/refund.rs index 9eda6ecd5..6d44eb3da 100644 --- a/lightning/src/offers/refund.rs +++ b/lightning/src/offers/refund.rs @@ -216,7 +216,8 @@ impl RefundBuilder { /// /// [`Invoice`]: crate::offers::invoice::Invoice /// [`Offer`]: crate::offers::offer::Offer -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub struct Refund { pub(super) bytes: Vec, pub(super) contents: RefundContents, @@ -225,7 +226,8 @@ pub struct Refund { /// The contents of a [`Refund`], which may be shared with an [`Invoice`]. /// /// [`Invoice`]: crate::offers::invoice::Invoice -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] +#[cfg_attr(test, derive(PartialEq))] pub(super) struct RefundContents { payer: PayerContents, // offer fields diff --git a/lightning/src/offers/signer.rs b/lightning/src/offers/signer.rs new file mode 100644 index 000000000..e1a1a4dfd --- /dev/null +++ b/lightning/src/offers/signer.rs @@ -0,0 +1,150 @@ +// 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. + +//! Utilities for signing offer messages and verifying metadata. + +use bitcoin::hashes::{Hash, HashEngine}; +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 core::fmt; +use crate::ln::inbound_payment::{ExpandedKey, IV_LEN, Nonce}; +use crate::util::ser::Writeable; + +use crate::prelude::*; + +const DERIVED_METADATA_HMAC_INPUT: &[u8; 16] = &[1; 16]; +const DERIVED_METADATA_AND_KEYS_HMAC_INPUT: &[u8; 16] = &[2; 16]; + +/// Message metadata which possibly is derived from [`MetadataMaterial`] such that it can be +/// verified. +#[derive(Clone)] +pub(super) enum Metadata { + /// Metadata as parsed, supplied by the user, or derived from the message contents. + Bytes(Vec), + + /// Metadata to be derived from message contents and given material. + Derived(MetadataMaterial), + + /// Metadata and signing pubkey to be derived from message contents and given material. + DerivedSigningPubkey(MetadataMaterial), +} + +impl Metadata { + pub fn as_bytes(&self) -> Option<&Vec> { + match self { + Metadata::Bytes(bytes) => Some(bytes), + Metadata::Derived(_) => None, + Metadata::DerivedSigningPubkey(_) => None, + } + } + + pub fn has_derivation_material(&self) -> bool { + match self { + Metadata::Bytes(_) => false, + Metadata::Derived(_) => true, + Metadata::DerivedSigningPubkey(_) => true, + } + } + + pub fn derives_keys(&self) -> bool { + match self { + Metadata::Bytes(_) => false, + Metadata::Derived(_) => false, + Metadata::DerivedSigningPubkey(_) => true, + } + } + + pub fn without_keys(self) -> Self { + match self { + Metadata::Bytes(_) => self, + Metadata::Derived(_) => self, + Metadata::DerivedSigningPubkey(material) => Metadata::Derived(material), + } + } + + pub fn derive_from( + self, tlv_stream: W, secp_ctx: Option<&Secp256k1> + ) -> (Self, Option) { + match self { + Metadata::Bytes(_) => (self, None), + Metadata::Derived(mut metadata_material) => { + tlv_stream.write(&mut metadata_material.hmac).unwrap(); + (Metadata::Bytes(metadata_material.derive_metadata()), None) + }, + Metadata::DerivedSigningPubkey(mut metadata_material) => { + tlv_stream.write(&mut metadata_material.hmac).unwrap(); + let secp_ctx = secp_ctx.unwrap(); + let (metadata, keys) = metadata_material.derive_metadata_and_keys(secp_ctx); + (Metadata::Bytes(metadata), Some(keys)) + }, + } + } +} + +impl fmt::Debug for Metadata { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Metadata::Bytes(bytes) => bytes.fmt(f), + Metadata::Derived(_) => f.write_str("Derived"), + Metadata::DerivedSigningPubkey(_) => f.write_str("DerivedSigningPubkey"), + } + } +} + +#[cfg(test)] +impl PartialEq for Metadata { + fn eq(&self, other: &Self) -> bool { + match self { + Metadata::Bytes(bytes) => if let Metadata::Bytes(other_bytes) = other { + bytes == other_bytes + } else { + false + }, + Metadata::Derived(_) => false, + Metadata::DerivedSigningPubkey(_) => false, + } + } +} + +/// Material used to create metadata for a message. +#[derive(Clone)] +pub(super) struct MetadataMaterial { + nonce: Nonce, + hmac: HmacEngine, +} + +impl MetadataMaterial { + pub fn new(nonce: Nonce, expanded_key: &ExpandedKey, iv_bytes: &[u8; IV_LEN]) -> Self { + Self { + nonce, + hmac: expanded_key.hmac_for_offer(nonce, iv_bytes), + } + } + + fn derive_metadata(mut self) -> Vec { + self.hmac.input(DERIVED_METADATA_HMAC_INPUT); + + let mut bytes = self.nonce.as_slice().to_vec(); + bytes.extend_from_slice(&Hmac::from_engine(self.hmac).into_inner()); + bytes + } + + fn derive_metadata_and_keys( + mut self, secp_ctx: &Secp256k1 + ) -> (Vec, KeyPair) { + self.hmac.input(DERIVED_METADATA_AND_KEYS_HMAC_INPUT); + + let hmac = Hmac::from_engine(self.hmac); + let privkey = SecretKey::from_slice(hmac.as_inner()).unwrap(); + let keys = KeyPair::from_secret_key(secp_ctx, &privkey); + (self.nonce.as_slice().to_vec(), keys) + } +} -- 2.39.5