From e8341e4115af1c8555d2dea1dc7070be3aa8b499 Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Fri, 26 Jun 2026 12:05:23 +0200 Subject: [PATCH] Bind invoice hashes to encoded bytes Previously, deserialized invoices recomputed their signature hash by re-encoding the parsed invoice. Non-canonical amount digits could then be dropped, letting distinct encodings share a hash. Hash deserialized invoices from the HRP and unsigned data bytes accepted by the parser so the cached hash remains bound to the encoded invoice. Reported by Project Loupe. Co-Authored-By: HAL 9000 --- lightning-invoice/src/de.rs | 41 +++++++++++++++++++++++++++++++++--- lightning-invoice/src/lib.rs | 2 +- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/lightning-invoice/src/de.rs b/lightning-invoice/src/de.rs index f1bbe29440a..b236492f19c 100644 --- a/lightning-invoice/src/de.rs +++ b/lightning-invoice/src/de.rs @@ -401,10 +401,15 @@ impl FromStr for SignedRawBolt11Invoice { return Err(Bolt11ParseError::TooShortDataPart); } - let raw_hrp: RawHrp = hrp.to_string().to_lowercase().parse()?; - let data_part = RawDataPart::from_base32(&data[..data.len() - SIGNATURE_LEN_5])?; + let raw_hrp_str = hrp.to_string().to_lowercase(); + let raw_hrp: RawHrp = raw_hrp_str.parse()?; + let data_without_signature = &data[..data.len() - SIGNATURE_LEN_5]; + let data_part = RawDataPart::from_base32(data_without_signature)?; let raw_invoice = RawBolt11Invoice { hrp: raw_hrp, data: data_part }; - let hash = raw_invoice.signable_hash(); + let hash = RawBolt11Invoice::hash_from_parts( + raw_hrp_str.as_bytes(), + data_without_signature.iter().copied(), + ); Ok(SignedRawBolt11Invoice { raw_invoice, @@ -1459,4 +1464,34 @@ mod test { let input = vec![Fe32::try_from(0).unwrap(); 53]; assert!(PaymentHash::from_base32(&input).is_err()); } + + #[test] + fn test_deserialized_signable_hash_binds_to_original_bytes() { + use crate::{Bolt11Bech32, SignedRawBolt11Invoice}; + use bech32::primitives::decode::CheckedHrpstring; + use bech32::{Fe32IterExt, Hrp}; + + let canonical_str = "lnbc25m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5vdhkven9v5sxyetpdeessp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q5sqqqqqqqqqqqqqqqpqsq67gye39hfg3zd8rgc80k32tvy9xk2xunwm5lzexnvpx6fd77en8qaq424dxgt56cag2dpt359k3ssyhetktkpqh24jqnjyw6uqd08sgptq44qu"; + let parsed = CheckedHrpstring::new::(canonical_str).unwrap(); + let data_fes = parsed.fe32_iter::<&mut dyn Iterator>().collect::>(); + + let malleated_hrp = Hrp::parse_unchecked("lnbc025m"); + let malleated_str = data_fes + .iter() + .copied() + .with_checksum::(&malleated_hrp) + .chars() + .collect::(); + assert_ne!(canonical_str, malleated_str.as_str()); + + let canonical: SignedRawBolt11Invoice = canonical_str.parse().unwrap(); + let malleated: SignedRawBolt11Invoice = malleated_str.parse().unwrap(); + + assert_eq!(canonical.signature(), malleated.signature()); + assert_ne!(canonical.signable_hash(), malleated.signable_hash()); + assert_ne!( + canonical.recover_payee_pub_key().unwrap(), + malleated.recover_payee_pub_key().unwrap() + ); + } } diff --git a/lightning-invoice/src/lib.rs b/lightning-invoice/src/lib.rs index 2dfd752bc81..f48b4e81dd7 100644 --- a/lightning-invoice/src/lib.rs +++ b/lightning-invoice/src/lib.rs @@ -1089,7 +1089,7 @@ macro_rules! find_all_extract { #[allow(missing_docs)] impl RawBolt11Invoice { /// Hash the HRP (as bytes) and signatureless data part (as Fe32 iterator) - fn hash_from_parts<'s, I: Iterator + 's>( + pub(crate) fn hash_from_parts<'s, I: Iterator + 's>( hrp_bytes: &[u8], data_without_signature: I, ) -> [u8; 32] { use crate::bech32::Fe32IterExt;