From: Jeffrey Czyz Date: Thu, 11 Aug 2022 21:51:06 +0000 (-0500) Subject: Offer parsing from bech32 strings X-Git-Tag: v0.0.113~36^2~4 X-Git-Url: http://git.bitcoin.ninja/?a=commitdiff_plain;h=60d7ffce10a322ed5c6d93d23c6657ebdd87d389;p=rust-lightning Offer parsing from bech32 strings Add common bech32 parsing for BOLT 12 messages. The encoding is similar to bech32 only without a checksum and with support for continuing messages across multiple parts. Messages implementing Bech32Encode are parsed into a TLV stream, which is converted to the desired message content while performing semantic checks. Checking after conversion allows for more elaborate checks of data composed of multiple TLV records and for more meaningful error messages. The parsed bytes are also saved to allow creating messages with mirrored data, even if TLV records are unknown. --- diff --git a/lightning/src/offers/mod.rs b/lightning/src/offers/mod.rs index 2f961a0bb..273650285 100644 --- a/lightning/src/offers/mod.rs +++ b/lightning/src/offers/mod.rs @@ -13,3 +13,4 @@ //! Offers are a flexible protocol for Lightning payments. pub mod offer; +pub mod parse; diff --git a/lightning/src/offers/offer.rs b/lightning/src/offers/offer.rs index bf569fbc0..d941b6937 100644 --- a/lightning/src/offers/offer.rs +++ b/lightning/src/offers/offer.rs @@ -18,13 +18,15 @@ //! extern crate core; //! extern crate lightning; //! +//! use core::convert::TryFrom; //! use core::num::NonZeroU64; //! use core::time::Duration; //! //! use bitcoin::secp256k1::{KeyPair, PublicKey, Secp256k1, SecretKey}; -//! use lightning::offers::offer::{OfferBuilder, Quantity}; +//! use lightning::offers::offer::{Offer, OfferBuilder, Quantity}; +//! use lightning::offers::parse::ParseError; +//! use lightning::util::ser::{Readable, Writeable}; //! -//! # use bitcoin::secp256k1; //! # use lightning::onion_message::BlindedPath; //! # #[cfg(feature = "std")] //! # use std::time::SystemTime; @@ -33,9 +35,9 @@ //! # fn create_another_blinded_path() -> BlindedPath { unimplemented!() } //! # //! # #[cfg(feature = "std")] -//! # fn build() -> Result<(), secp256k1::Error> { +//! # fn build() -> Result<(), ParseError> { //! let secp_ctx = Secp256k1::new(); -//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32])?); +//! let keys = KeyPair::from_secret_key(&secp_ctx, &SecretKey::from_slice(&[42; 32]).unwrap()); //! let pubkey = PublicKey::from(keys); //! //! let expiration = SystemTime::now() + Duration::from_secs(24 * 60 * 60); @@ -48,6 +50,19 @@ //! .path(create_another_blinded_path()) //! .build() //! .unwrap(); +//! +//! // Encode as a bech32 string for use in a QR code. +//! let encoded_offer = offer.to_string(); +//! +//! // Parse from a bech32 string after scanning from a QR code. +//! let offer = encoded_offer.parse::()?; +//! +//! // Encode offer as raw bytes. +//! let mut bytes = Vec::new(); +//! offer.write(&mut bytes).unwrap(); +//! +//! // Decode raw bytes into an offer. +//! let offer = Offer::try_from(bytes)?; //! # Ok(()) //! # } //! ``` @@ -55,13 +70,16 @@ use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; use bitcoin::secp256k1::PublicKey; +use core::convert::TryFrom; use core::num::NonZeroU64; +use core::str::FromStr; use core::time::Duration; use crate::io; use crate::ln::features::OfferFeatures; use crate::ln::msgs::MAX_VALUE_MSAT; +use crate::offers::parse::{Bech32Encode, ParseError, SemanticError}; use crate::onion_message::BlindedPath; -use crate::util::ser::{HighZeroBytesDroppedBigSize, WithoutLength, Writeable, Writer}; +use crate::util::ser::{HighZeroBytesDroppedBigSize, Readable, WithoutLength, Writeable, Writer}; use crate::util::string::PrintableString; use crate::prelude::*; @@ -321,6 +339,12 @@ impl Offer { } } +impl AsRef<[u8]> for Offer { + fn as_ref(&self) -> &[u8] { + &self.bytes + } +} + impl OfferContents { pub fn implied_chain(&self) -> ChainHash { ChainHash::using_genesis_block(Network::Bitcoin) @@ -359,12 +383,27 @@ impl OfferContents { } } +impl Writeable for Offer { + fn write(&self, writer: &mut W) -> Result<(), io::Error> { + WithoutLength(&self.bytes).write(writer) + } +} + impl Writeable for OfferContents { fn write(&self, writer: &mut W) -> Result<(), io::Error> { self.as_tlv_stream().write(writer) } } +impl TryFrom> for Offer { + type Error = ParseError; + + fn try_from(bytes: Vec) -> Result { + let tlv_stream: OfferTlvStream = Readable::read(&mut &bytes[..])?; + Offer::try_from((bytes, tlv_stream)) + } +} + /// The minimum amount required for an item in an [`Offer`], denominated in either bitcoin or /// another currency. #[derive(Clone, Debug, PartialEq)] @@ -425,13 +464,91 @@ tlv_stream!(OfferTlvStream, OfferTlvStreamRef, { (22, node_id: PublicKey), }); +impl Bech32Encode for Offer { + const BECH32_HRP: &'static str = "lno"; +} + +type ParsedOffer = (Vec, OfferTlvStream); + +impl FromStr for Offer { + type Err = ParseError; + + fn from_str(s: &str) -> Result::Err> { + Self::from_bech32_str(s) + } +} + +impl TryFrom for Offer { + type Error = ParseError; + + fn try_from(offer: ParsedOffer) -> Result { + let (bytes, tlv_stream) = offer; + let contents = OfferContents::try_from(tlv_stream)?; + Ok(Offer { bytes, contents }) + } +} + +impl TryFrom for OfferContents { + type Error = SemanticError; + + fn try_from(tlv_stream: OfferTlvStream) -> Result { + let OfferTlvStream { + chains, metadata, currency, amount, description, features, absolute_expiry, paths, + issuer, quantity_max, node_id, + } = tlv_stream; + + let amount = match (currency, amount) { + (None, None) => None, + (None, Some(amount_msats)) if amount_msats > MAX_VALUE_MSAT => { + return Err(SemanticError::InvalidAmount); + }, + (None, Some(amount_msats)) => Some(Amount::Bitcoin { amount_msats }), + (Some(_), None) => return Err(SemanticError::MissingAmount), + (Some(iso4217_code), Some(amount)) => Some(Amount::Currency { iso4217_code, amount }), + }; + + let description = match description { + None => return Err(SemanticError::MissingDescription), + Some(description) => description, + }; + + let features = features.unwrap_or_else(OfferFeatures::empty); + + let absolute_expiry = absolute_expiry + .map(|seconds_from_epoch| Duration::from_secs(seconds_from_epoch)); + + let supported_quantity = match quantity_max { + None => Quantity::one(), + Some(0) => Quantity::Unbounded, + Some(1) => return Err(SemanticError::InvalidQuantity), + Some(n) => Quantity::Bounded(NonZeroU64::new(n).unwrap()), + }; + + if node_id.is_none() { + return Err(SemanticError::MissingSigningPubkey); + } + + Ok(OfferContents { + chains, metadata, amount, description, features, absolute_expiry, issuer, paths, + supported_quantity, signing_pubkey: node_id, + }) + } +} + +impl core::fmt::Display for Offer { + fn fmt(&self, f: &mut core::fmt::Formatter) -> Result<(), core::fmt::Error> { + self.fmt_bech32_str(f) + } +} + #[cfg(test)] mod tests { - use super::{Amount, OfferBuilder, Quantity}; + use super::{Amount, Offer, OfferBuilder, Quantity}; use bitcoin::blockdata::constants::ChainHash; use bitcoin::network::constants::Network; use bitcoin::secp256k1::{PublicKey, Secp256k1, SecretKey}; + use core::convert::TryFrom; use core::num::NonZeroU64; use core::time::Duration; use crate::ln::features::OfferFeatures; @@ -454,7 +571,7 @@ mod tests { let offer = OfferBuilder::new("foo".into(), pubkey(42)).build().unwrap(); let tlv_stream = offer.as_tlv_stream(); let mut buffer = Vec::new(); - offer.contents.write(&mut buffer).unwrap(); + offer.write(&mut buffer).unwrap(); assert_eq!(offer.bytes, buffer.as_slice()); assert_eq!(offer.chains(), vec![ChainHash::using_genesis_block(Network::Bitcoin)]); @@ -481,6 +598,10 @@ mod tests { assert_eq!(tlv_stream.issuer, None); assert_eq!(tlv_stream.quantity_max, None); assert_eq!(tlv_stream.node_id, Some(&pubkey(42))); + + if let Err(e) = Offer::try_from(buffer) { + panic!("error parsing offer: {:?}", e); + } } #[test] @@ -707,3 +828,88 @@ mod tests { assert_eq!(tlv_stream.quantity_max, None); } } + +#[cfg(test)] +mod bech32_tests { + use super::{Offer, ParseError}; + use bitcoin::bech32; + use crate::ln::msgs::DecodeError; + + // TODO: Remove once test vectors are updated. + #[ignore] + #[test] + fn encodes_offer_as_bech32_without_checksum() { + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; + let offer = dbg!(encoded_offer.parse::().unwrap()); + let reencoded_offer = offer.to_string(); + dbg!(reencoded_offer.parse::().unwrap()); + assert_eq!(reencoded_offer, encoded_offer); + } + + // TODO: Remove once test vectors are updated. + #[ignore] + #[test] + fn parses_bech32_encoded_offers() { + let offers = [ + // BOLT 12 test vectors + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + "l+no1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + "lno1qcp4256ypqpq+86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qs+y", + "lno1qcp4256ypqpq+ 86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn0+ 0fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0+\nsqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43l+\r\nastpwuh73k29qs+\r y", + // Two blinded paths + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0yg06qg2qdd7t628sgykwj5kuc837qmlv9m9gr7sq8ap6erfgacv26nhp8zzcqgzhdvttlk22pw8fmwqqrvzst792mj35ypylj886ljkcmug03wg6heqqsqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq6muh550qsfva9fdes0ruph7ctk2s8aqq06r4jxj3msc448wzwy9sqs9w6ckhlv55zuwnkuqqxc9qhu24h9rggzflyw04l9d3hcslzu340jqpqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + ]; + for encoded_offer in &offers { + if let Err(e) = encoded_offer.parse::() { + panic!("Invalid offer ({:?}): {}", e, encoded_offer); + } + } + } + + #[test] + fn fails_parsing_bech32_encoded_offers_with_invalid_continuations() { + let offers = [ + // BOLT 12 test vectors + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+", + "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy+ ", + "+lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + "+ lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + "ln++o1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy", + ]; + for encoded_offer in &offers { + match encoded_offer.parse::() { + Ok(_) => panic!("Valid offer: {}", encoded_offer), + Err(e) => assert_eq!(e, ParseError::InvalidContinuation), + } + } + + } + + #[test] + fn fails_parsing_bech32_encoded_offer_with_invalid_hrp() { + let encoded_offer = "lni1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsy"; + match encoded_offer.parse::() { + Ok(_) => panic!("Valid offer: {}", encoded_offer), + Err(e) => assert_eq!(e, ParseError::InvalidBech32Hrp), + } + } + + #[test] + fn fails_parsing_bech32_encoded_offer_with_invalid_bech32_data() { + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qso"; + match encoded_offer.parse::() { + Ok(_) => panic!("Valid offer: {}", encoded_offer), + Err(e) => assert_eq!(e, ParseError::Bech32(bech32::Error::InvalidChar('o'))), + } + } + + #[test] + fn fails_parsing_bech32_encoded_offer_with_invalid_tlv_data() { + let encoded_offer = "lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pqun4wd68jtn00fkxzcnn9ehhyec6qgqsz83qfwdpl28qqmc78ymlvhmxcsywdk5wrjnj36jryg488qwlrnzyjczlqsp9nyu4phcg6dqhlhzgxagfu7zh3d9re0sqp9ts2yfugvnnm9gxkcnnnkdpa084a6t520h5zhkxsdnghvpukvd43lastpwuh73k29qsyqqqqq"; + match encoded_offer.parse::() { + Ok(_) => panic!("Valid offer: {}", encoded_offer), + Err(e) => assert_eq!(e, ParseError::Decode(DecodeError::InvalidValue)), + } + } +} diff --git a/lightning/src/offers/parse.rs b/lightning/src/offers/parse.rs new file mode 100644 index 000000000..c9d568a90 --- /dev/null +++ b/lightning/src/offers/parse.rs @@ -0,0 +1,125 @@ +// 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. + +//! Parsing and formatting for bech32 message encoding. + +use bitcoin::bech32; +use bitcoin::bech32::{FromBase32, ToBase32}; +use core::convert::TryFrom; +use core::fmt; +use crate::ln::msgs::DecodeError; + +use crate::prelude::*; + +/// Indicates a message can be encoded using bech32. +pub(crate) trait Bech32Encode: AsRef<[u8]> + TryFrom, Error=ParseError> { + /// Human readable part of the message's bech32 encoding. + const BECH32_HRP: &'static str; + + /// Parses a bech32-encoded message into a TLV stream. + fn from_bech32_str(s: &str) -> Result { + // Offer encoding may be split by '+' followed by optional whitespace. + let encoded = match s.split('+').skip(1).next() { + Some(_) => { + for chunk in s.split('+') { + let chunk = chunk.trim_start(); + if chunk.is_empty() || chunk.contains(char::is_whitespace) { + return Err(ParseError::InvalidContinuation); + } + } + + let s = s.chars().filter(|c| *c != '+' && !c.is_whitespace()).collect::(); + Bech32String::Owned(s) + }, + None => Bech32String::Borrowed(s), + }; + + let (hrp, data) = bech32::decode_without_checksum(encoded.as_ref())?; + + if hrp != Self::BECH32_HRP { + return Err(ParseError::InvalidBech32Hrp); + } + + let data = Vec::::from_base32(&data)?; + Self::try_from(data) + } + + /// Formats the message using bech32-encoding. + fn fmt_bech32_str(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + bech32::encode_without_checksum_to_fmt(f, Self::BECH32_HRP, self.as_ref().to_base32()) + .expect("HRP is invalid").unwrap(); + + Ok(()) + } +} + +// Used to avoid copying a bech32 string not containing the continuation character (+). +enum Bech32String<'a> { + Borrowed(&'a str), + Owned(String), +} + +impl<'a> AsRef for Bech32String<'a> { + fn as_ref(&self) -> &str { + match self { + Bech32String::Borrowed(s) => s, + Bech32String::Owned(s) => s, + } + } +} + +/// Error when parsing a bech32 encoded message using [`str::parse`]. +#[derive(Debug, PartialEq)] +pub enum ParseError { + /// The bech32 encoding does not conform to the BOLT 12 requirements for continuing messages + /// across multiple parts (i.e., '+' followed by whitespace). + InvalidContinuation, + /// The bech32 encoding's human-readable part does not match what was expected for the message + /// being parsed. + InvalidBech32Hrp, + /// The string could not be bech32 decoded. + Bech32(bech32::Error), + /// The bech32 decoded string could not be decoded as the expected message type. + Decode(DecodeError), + /// The parsed message has invalid semantics. + InvalidSemantics(SemanticError), +} + +/// Error when interpreting a TLV stream as a specific type. +#[derive(Debug, PartialEq)] +pub enum SemanticError { + /// An amount was expected but was missing. + MissingAmount, + /// The amount exceeded the total bitcoin supply. + InvalidAmount, + /// A required description was not provided. + MissingDescription, + /// A signing pubkey was not provided. + MissingSigningPubkey, + /// An unsupported quantity was provided. + InvalidQuantity, +} + +impl From for ParseError { + fn from(error: bech32::Error) -> Self { + Self::Bech32(error) + } +} + +impl From for ParseError { + fn from(error: DecodeError) -> Self { + Self::Decode(error) + } +} + +impl From for ParseError { + fn from(error: SemanticError) -> Self { + Self::InvalidSemantics(error) + } +} diff --git a/lightning/src/util/ser_macros.rs b/lightning/src/util/ser_macros.rs index 3ec084868..129fb8f40 100644 --- a/lightning/src/util/ser_macros.rs +++ b/lightning/src/util/ser_macros.rs @@ -476,7 +476,7 @@ macro_rules! tlv_stream { $(($type:expr, $field:ident : $fieldty:tt)),* $(,)* }) => { #[derive(Debug)] - struct $name { + pub(crate) struct $name { $( $field: Option, )*