Commit to client's node id in bLIP-52/LSPS2 promise
What changed, and why it matters
This change fixes a security issue in the LSPS2 (Lightning Service Provider Specification 2) implementation used by Lightning Dev Kit. Previously, a service's fee-quote 'promise' could be reused by any client who obtained it, potentially letting someone else claim another client's reserved channel-opening terms. The patch now includes the client's unique node identifier in the cryptographic promise, so only the originally quoted client can redeem it.
Review whether any deployed services or clients rely on the old unbound promise format; if so, coordinate an upgrade because old quotes will fail validation after this change. Consider whether the protocol spec (bLIP-52/LSPS2) needs a corresponding update to mandate node-id binding, and monitor for any CVE assignment if this is treated as a security fix.
Security signals we found
HMAC now binds OpeningFeeParams to a specific counterparty node id
Prevents out-of-bounds reuse of fee quotes by other clients
Adds explicit regression test for client-node-id mismatch
Updates integration test to validate with correct client_node_id
Evidence from the diff
The promise HMAC in LSPS2 OpeningFeeParams previously committed only to the promise_secret and the fee parameters (min_fee_msat, proportional, valid_until, etc.). The patch adds the counterparty_node_id (client public key) as the first input to the HMAC both when generating the promise in into_opening_fee_params and when validating it in is_valid_opening_fee_params. This binds each OpeningFeeParams quote to a single client node, preventing cross-client reuse. Tests and integration tests are updated accordingly, including a new client_mismatch_produced_invalid_params test that verifies a quote validated under one node id fails under another.
Changed components
lightning-liquidity/src/lsps2/msgs.rslightning-liquidity/src/lsps2/service.rslightning-liquidity/src/lsps2/utils.rslightning-liquidity/tests/lsps2_integration_tests.rsInspect captured patch +109 / −20
diff --git a/lightning-liquidity/src/lsps2/msgs.rs b/lightning-liquidity/src/lsps2/msgs.rs
index 2a01d6e..ff8f53d 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::<Sha256>::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 114ed8b..74f58ca 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 e462004..9f75a86 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::<Sha256>::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 6ea42e1..854d6e2 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"),
Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.