From e4aece2595fe2336892a103710fa2651c3193c0a Mon Sep 17 00:00:00 2001 From: Elias Rohrer Date: Tue, 2 Sep 2025 09:28:52 +0200 Subject: [PATCH] Commit to client's node id in bLIP-52/LSPS2 promise Previously, the promise HMAC would only commit to the promise secret and the `OpeningFeeParams` fields, leaving room for other clients to reuse the same `OpeningFeeParams` in `BuyRequests` if they'd acquire it somehow out-of-bounds. While this flexibility also has some benefits, we here have the service commit to the client's node id, making sure only the original client can redeem a specific `OpeningFeeParams`. --- lightning-liquidity/src/lsps2/msgs.rs | 108 +++++++++++++++--- lightning-liquidity/src/lsps2/service.rs | 11 +- lightning-liquidity/src/lsps2/utils.rs | 4 +- .../tests/lsps2_integration_tests.rs | 6 +- 4 files changed, 109 insertions(+), 20 deletions(-) diff --git a/lightning-liquidity/src/lsps2/msgs.rs b/lightning-liquidity/src/lsps2/msgs.rs index 2a01d6ee32f..ff8f53d7f7e 100644 --- a/lightning-liquidity/src/lsps2/msgs.rs +++ b/lightning-liquidity/src/lsps2/msgs.rs @@ -17,6 +17,8 @@ use core::convert::TryFrom; use bitcoin::hashes::hmac::{Hmac, HmacEngine}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; +use bitcoin::secp256k1::PublicKey; + use serde::{Deserialize, Serialize}; use lightning::util::scid_utils; @@ -66,9 +68,10 @@ pub struct LSPS2RawOpeningFeeParams { impl LSPS2RawOpeningFeeParams { pub(crate) fn into_opening_fee_params( - self, promise_secret: &[u8; 32], + self, promise_secret: &[u8; 32], counterparty_node_id: &PublicKey, ) -> LSPS2OpeningFeeParams { let mut hmac = HmacEngine::::new(promise_secret); + hmac.input(&counterparty_node_id.serialize()); hmac.input(&self.min_fee_msat.to_be_bytes()); hmac.input(&self.proportional.to_be_bytes()); hmac.input(self.valid_until.to_rfc3339().as_bytes()); @@ -229,6 +232,8 @@ mod tests { use crate::alloc::string::ToString; use crate::lsps2::utils::is_valid_opening_fee_params; + use bitcoin::secp256k1::{Secp256k1, SecretKey}; + use core::str::FromStr; #[test] @@ -252,8 +257,12 @@ mod tests { }; let promise_secret = [1u8; 32]; + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); - let opening_fee_params = raw.into_opening_fee_params(&promise_secret); + let opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); assert_eq!(opening_fee_params.min_fee_msat, min_fee_msat); assert_eq!(opening_fee_params.proportional, proportional); @@ -263,7 +272,7 @@ mod tests { assert_eq!(opening_fee_params.min_payment_size_msat, min_payment_size_msat); assert_eq!(opening_fee_params.max_payment_size_msat, max_payment_size_msat); - assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret)); + assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret, &client_node_id)); } #[test] @@ -287,10 +296,18 @@ mod tests { }; let promise_secret = [1u8; 32]; + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); - let mut opening_fee_params = raw.into_opening_fee_params(&promise_secret); + let mut opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); opening_fee_params.min_fee_msat = min_fee_msat + 1; - assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret)); + assert!(!is_valid_opening_fee_params( + &opening_fee_params, + &promise_secret, + &client_node_id + )); } #[test] @@ -316,8 +333,54 @@ mod tests { let promise_secret = [1u8; 32]; let other_secret = [2u8; 32]; - let opening_fee_params = raw.into_opening_fee_params(&promise_secret); - assert!(!is_valid_opening_fee_params(&opening_fee_params, &other_secret)); + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); + + let opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); + assert!(!is_valid_opening_fee_params(&opening_fee_params, &other_secret, &client_node_id)); + } + + #[test] + fn client_mismatch_produced_invalid_params() { + let min_fee_msat = 100; + let proportional = 21; + let valid_until = LSPSDateTime::from_str("2035-05-20T08:30:45Z").unwrap(); + let min_lifetime = 144; + let max_client_to_self_delay = 128; + let min_payment_size_msat = 1; + let max_payment_size_msat = 100_000_000; + + let raw = LSPS2RawOpeningFeeParams { + min_fee_msat, + proportional, + valid_until, + min_lifetime, + max_client_to_self_delay, + min_payment_size_msat, + max_payment_size_msat, + }; + + let promise_secret = [1u8; 32]; + + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); + + let other_public_key = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcf; 32]).unwrap(), + ); + + let opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); + assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret, &client_node_id)); + assert!(!is_valid_opening_fee_params( + &opening_fee_params, + &promise_secret, + &other_public_key + )); } #[test] @@ -343,9 +406,17 @@ mod tests { }; let promise_secret = [1u8; 32]; - - let opening_fee_params = raw.into_opening_fee_params(&promise_secret); - assert!(!is_valid_opening_fee_params(&opening_fee_params, &promise_secret)); + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); + + let opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); + assert!(!is_valid_opening_fee_params( + &opening_fee_params, + &promise_secret, + &client_node_id + )); } #[test] @@ -369,16 +440,21 @@ mod tests { }; let promise_secret = [1u8; 32]; - - let opening_fee_params = raw.into_opening_fee_params(&promise_secret); - let json_str = r#"{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"1134a5c51e3ba2e8f4259610d5e12c1bf4c50ddcd3f8af563e0a00d1fff41dea","proportional":21,"valid_until":"2023-05-20T08:30:45Z"}"#; + let client_node_id = PublicKey::from_secret_key( + &Secp256k1::new(), + &SecretKey::from_slice(&[0xcd; 32]).unwrap(), + ); + + let opening_fee_params = raw.into_opening_fee_params(&promise_secret, &client_node_id); + println!("SERIALIZATION: {}", serde_json::json!(opening_fee_params).to_string()); + let json_str = r#"{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"75eb57db4c37dc092a37f1d2e0026c5ff36a7834a717ea97c41d91a8d5b50ce8","proportional":21,"valid_until":"2023-05-20T08:30:45Z"}"#; assert_eq!(json_str, serde_json::json!(opening_fee_params).to_string()); assert_eq!(opening_fee_params, serde_json::from_str(json_str).unwrap()); let payment_size_msat = Some(1234); let buy_request_fixed = LSPS2BuyRequest { opening_fee_params: opening_fee_params.clone(), payment_size_msat }; - let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"1134a5c51e3ba2e8f4259610d5e12c1bf4c50ddcd3f8af563e0a00d1fff41dea","proportional":21,"valid_until":"2023-05-20T08:30:45Z"},"payment_size_msat":"1234"}"#; + let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"75eb57db4c37dc092a37f1d2e0026c5ff36a7834a717ea97c41d91a8d5b50ce8","proportional":21,"valid_until":"2023-05-20T08:30:45Z"},"payment_size_msat":"1234"}"#; assert_eq!(json_str, serde_json::json!(buy_request_fixed).to_string()); assert_eq!(buy_request_fixed, serde_json::from_str(json_str).unwrap()); @@ -386,12 +462,12 @@ mod tests { let buy_request_variable = LSPS2BuyRequest { opening_fee_params, payment_size_msat }; // Check we skip serialization if payment_size_msat is None. - let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"1134a5c51e3ba2e8f4259610d5e12c1bf4c50ddcd3f8af563e0a00d1fff41dea","proportional":21,"valid_until":"2023-05-20T08:30:45Z"}}"#; + let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"75eb57db4c37dc092a37f1d2e0026c5ff36a7834a717ea97c41d91a8d5b50ce8","proportional":21,"valid_until":"2023-05-20T08:30:45Z"}}"#; assert_eq!(json_str, serde_json::json!(buy_request_variable).to_string()); assert_eq!(buy_request_variable, serde_json::from_str(json_str).unwrap()); // Check we still deserialize correctly if payment_size_msat is 'null'. - let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"1134a5c51e3ba2e8f4259610d5e12c1bf4c50ddcd3f8af563e0a00d1fff41dea","proportional":21,"valid_until":"2023-05-20T08:30:45Z"},"payment_size_msat":null}"#; + let json_str = r#"{"opening_fee_params":{"max_client_to_self_delay":128,"max_payment_size_msat":"100000000","min_fee_msat":"100","min_lifetime":144,"min_payment_size_msat":"1","promise":"75eb57db4c37dc092a37f1d2e0026c5ff36a7834a717ea97c41d91a8d5b50ce8","proportional":21,"valid_until":"2023-05-20T08:30:45Z"},"payment_size_msat":null}"#; assert_eq!(buy_request_variable, serde_json::from_str(json_str).unwrap()); } diff --git a/lightning-liquidity/src/lsps2/service.rs b/lightning-liquidity/src/lsps2/service.rs index 114ed8b250d..74f58ca0312 100644 --- a/lightning-liquidity/src/lsps2/service.rs +++ b/lightning-liquidity/src/lsps2/service.rs @@ -630,7 +630,10 @@ where opening_fee_params_menu .into_iter() .map(|param| { - param.into_opening_fee_params(&self.config.promise_secret) + param.into_opening_fee_params( + &self.config.promise_secret, + counterparty_node_id, + ) }) .collect(); opening_fee_params_menu.sort_by(|a, b| { @@ -1252,7 +1255,11 @@ where } // TODO: if payment_size_msat is specified, make sure our node has sufficient incoming liquidity from public network to receive it. - if !is_valid_opening_fee_params(¶ms.opening_fee_params, &self.config.promise_secret) { + if !is_valid_opening_fee_params( + ¶ms.opening_fee_params, + &self.config.promise_secret, + counterparty_node_id, + ) { let response = LSPS2Response::BuyError(LSPSResponseError { code: LSPS2_BUY_REQUEST_INVALID_OPENING_FEE_PARAMS_ERROR_CODE, message: "valid_until is already past OR the promise did not match the provided parameters".to_string(), diff --git a/lightning-liquidity/src/lsps2/utils.rs b/lightning-liquidity/src/lsps2/utils.rs index e4620043424..9f75a869a0e 100644 --- a/lightning-liquidity/src/lsps2/utils.rs +++ b/lightning-liquidity/src/lsps2/utils.rs @@ -13,15 +13,17 @@ use crate::utils; use bitcoin::hashes::hmac::{Hmac, HmacEngine}; use bitcoin::hashes::sha256::Hash as Sha256; use bitcoin::hashes::{Hash, HashEngine}; +use bitcoin::secp256k1::PublicKey; /// Determines if the given parameters are valid given the secret used to generate the promise. pub fn is_valid_opening_fee_params( - fee_params: &LSPS2OpeningFeeParams, promise_secret: &[u8; 32], + fee_params: &LSPS2OpeningFeeParams, promise_secret: &[u8; 32], counterparty_node_id: &PublicKey, ) -> bool { if is_expired_opening_fee_params(fee_params) { return false; } let mut hmac = HmacEngine::::new(promise_secret); + hmac.input(&counterparty_node_id.serialize()); hmac.input(&fee_params.min_fee_msat.to_be_bytes()); hmac.input(&fee_params.proportional.to_be_bytes()); hmac.input(fee_params.valid_until.to_rfc3339().as_bytes()); diff --git a/lightning-liquidity/tests/lsps2_integration_tests.rs b/lightning-liquidity/tests/lsps2_integration_tests.rs index 6ea42e17532..854d6e22136 100644 --- a/lightning-liquidity/tests/lsps2_integration_tests.rs +++ b/lightning-liquidity/tests/lsps2_integration_tests.rs @@ -192,7 +192,11 @@ fn invoice_generation_flow() { assert_eq!(request_id, get_info_request_id); assert_eq!(counterparty_node_id, service_node_id); let opening_fee_params = opening_fee_params_menu.first().unwrap().clone(); - assert!(is_valid_opening_fee_params(&opening_fee_params, &promise_secret)); + assert!(is_valid_opening_fee_params( + &opening_fee_params, + &promise_secret, + &client_node_id + )); opening_fee_params }, _ => panic!("Unexpected event"),