lsp_plugin: add client side check for zero_conf
What changed, and why it matters
This commit adds a security check in Core Lightning's LSPS (Lightning Service Provider Specification) plugin so that the client only accepts 'zero confirmation' channels from LSPs it has previously approved for a JIT (just-in-time) channel. Before this change, the plugin blindly allowed any zero-conf channel opening, which could let a malicious or mistaken LSP open a channel the user never requested. The patch stores an approval record when the user approves a JIT channel, then checks that record when an open-channel request arrives, deleting it after use. A new test confirms that unapproved zero-conf channels are rejected.
Review and merge if the LSPS2 plugin is in use; ensure the datastore record deletion failure path does not leave stale approvals that could be reused, and consider completing the FIXME to verify client_trusts_lsp mode before granting zero-conf.
Security signals we found
Missing authorization check before patch: on_openchannel previously allowed all zero-conf channels with a hardcoded continue/mindepth=0
Added allowlist/datastore lookup to gate zero-conf acceptance on prior user approval
Changed datastore key from jit_channel_scid to lsp_id, aligning lookup with peer identity
Channel type bits now explicitly include zero-conf/SCID-alias features
Regression test added for unapproved zero-conf channel rejection
Evidence from the diff
The change modifies plugins/lsps-plugin/src/client.rs and lsps2/handler.rs. The on_lsps_lsps2_approve hook now writes a DatastoreRecord keyed by lsp_id (previously keyed by jit_channel_scid and also stored lsp_id inside the record). The on_openchannel hook now deserializes the openchannel request, lists the datastore under [‘lsps’,’client’,
Changed components
plugins/lsps-plugin/src/client.rsplugins/lsps-plugin/src/lsps2/handler.rstests/test_cln_lsps.pyLSPS2 / JIT channel client-side zero-conf acceptance logicInspect captured patch +105 / −21
diff --git a/plugins/lsps-plugin/src/client.rs b/plugins/lsps-plugin/src/client.rs
index 578a5b5c..484facc2 100644
--- a/plugins/lsps-plugin/src/client.rs
+++ b/plugins/lsps-plugin/src/client.rs
@@ -17,7 +17,9 @@ use cln_lsps::lsps2::model::{
use cln_lsps::util;
use cln_lsps::LSP_FEATURE_BIT;
use cln_plugin::options;
-use cln_rpc::model::requests::{DatastoreMode, DatastoreRequest, ListpeersRequest};
+use cln_rpc::model::requests::{
+ DatastoreMode, DatastoreRequest, DeldatastoreRequest, ListdatastoreRequest, ListpeersRequest,
+};
use cln_rpc::model::responses::InvoiceResponse;
use cln_rpc::primitives::{AmountOrAny, PublicKey, ShortChannelId};
use cln_rpc::ClnRpc;
@@ -238,7 +240,6 @@ async fn on_lsps_lsps2_approve(
) -> Result<serde_json::Value, anyhow::Error> {
let req: ClnRpcLsps2Approve = serde_json::from_value(v)?;
let ds_rec = DatastoreRecord {
- lsp_id: req.lsp_id,
jit_channel_scid: req.jit_channel_scid,
client_trusts_lsp: req.client_trusts_lsp.unwrap_or_default(),
};
@@ -253,11 +254,7 @@ async fn on_lsps_lsps2_approve(
hex: None,
mode: Some(DatastoreMode::CREATE_OR_REPLACE),
string: Some(ds_rec_json),
- key: vec![
- "lsps".to_string(),
- "client".to_string(),
- req.jit_channel_scid.to_string(),
- ],
+ key: vec!["lsps".to_string(), "client".to_string(), req.lsp_id],
};
let _ds_res = cln_client.call_typed(&ds_req).await?;
Ok(serde_json::Value::default())
@@ -494,19 +491,52 @@ async fn on_htlc_accepted(
Ok(value)
}
+/// Allows `zero_conf` channels to the client if the LSP is on the allowlist.
async fn on_openchannel(
- _p: cln_plugin::Plugin<State>,
- _v: serde_json::Value,
+ p: cln_plugin::Plugin<State>,
+ v: serde_json::Value,
) -> Result<serde_json::Value, anyhow::Error> {
- // Fixme: Register a list of trusted LSPs and check if LSP is allowlisted.
- // And if we expect a channel to be opened.
- // - either datastore or invoice label possible.
- info!("Allowing zero-conf channel from LSP");
- Ok(serde_json::json!({
- "result": "continue",
- "reserve": "0msat",
- "mindepth": 0,
- }))
+ #[derive(Deserialize)]
+ struct Request {
+ id: String,
+ }
+
+ let req: Request = serde_json::from_value(v.get("openchannel").unwrap().clone())
+ .context("Failed to parse request JSON")?;
+ let dir = p.configuration().lightning_dir;
+ let rpc_path = Path::new(&dir).join(&p.configuration().rpc_file);
+ let mut cln_client = cln_rpc::ClnRpc::new(rpc_path.clone()).await?;
+
+ let ds_req = ListdatastoreRequest {
+ key: Some(vec![
+ "lsps".to_string(),
+ "client".to_string(),
+ req.id.clone(),
+ ]),
+ };
+ let ds_res = cln_client.call_typed(&ds_req).await?;
+ if let Some(_rec) = ds_res.datastore.iter().next() {
+ info!("Allowing zero-conf channel from LSP {}", &req.id);
+ let ds_req = DeldatastoreRequest {
+ generation: None,
+ key: vec!["lsps".to_string(), "client".to_string(), req.id.clone()],
+ };
+ if let Some(err) = cln_client.call_typed(&ds_req).await.err() {
+ // We can do nothing but report that there was an issue deleting the
+ // datastore record.
+ warn!("Failed to delete LSP record from datastore: {}", err);
+ }
+ // Fixme: Check that we actually use client-trusts-LSP mode - can be
+ // found in the ds record.
+ return Ok(serde_json::json!({
+ "result": "continue",
+ "reserve": "0msat",
+ "mindepth": 0,
+ }));
+ } else {
+ // Not a requested JIT-channel opening, continue.
+ Ok(serde_json::json!({"result": "continue"}))
+ }
}
async fn on_lsps_listprotocols(
@@ -659,7 +689,6 @@ struct ClnRpcLsps2Approve {
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DatastoreRecord {
- lsp_id: String,
jit_channel_scid: ShortChannelId,
client_trusts_lsp: bool,
}
diff --git a/plugins/lsps-plugin/src/lsps2/handler.rs b/plugins/lsps-plugin/src/lsps2/handler.rs
index 02e8a62f..4b2d4f84 100644
--- a/plugins/lsps-plugin/src/lsps2/handler.rs
+++ b/plugins/lsps-plugin/src/lsps2/handler.rs
@@ -507,8 +507,7 @@ impl<A: ClnApi> HtlcAcceptedHookHandler<A> {
push_msat: None,
request_amt: None,
reserve: None,
- channel_type: None, // Fimxe: Core-Lightning is complaining that it doesn't support these channel_types
- // channel_type: Some(vec![46, 50]), // Sets `option_zeroconf` and `option_scid_alias`
+ channel_type: Some(vec![12, 22, 50]),
utxos: None,
amount: AmountOrAll::Amount(Amount::from_msat(cap)),
id: ds_rec.peer_id,
diff --git a/tests/test_cln_lsps.py b/tests/test_cln_lsps.py
index 776c1a28..15f1df33 100644
--- a/tests/test_cln_lsps.py
+++ b/tests/test_cln_lsps.py
@@ -161,3 +161,59 @@ def test_lsps2_buyjitchannel_no_mpp_var_invoice(node_factory, bitcoind):
# l1 should have gotten a jit-channel.
chs = l1.rpc.listpeerchannels()['channels']
assert len(chs) == 1
+
+
+def test_lsps2_non_approved_zero_conf(node_factory, bitcoind):
+ """ Checks that we don't allow zerof_conf channels from an LSP if we did
+ not approve it first.
+ """
+ # We need a policy service to fetch from.
+ plugin = os.path.join(os.path.dirname(__file__), 'plugins/lsps2_policy.py')
+
+ l1, l2, l3= node_factory.get_nodes(3, opts=[
+ {"dev-lsps-client-enabled": None},
+ {
+ "dev-lsps-service-enabled": None,
+ "dev-lsps2-service-enabled": None,
+ "dev-lsps2-promise-secret": "00" * 32,
+ "plugin": plugin,
+ "fee-base": 0, # We are going to deduct our fee anyways,
+ "fee-per-satoshi": 0, # We are going to deduct our fee anyways,
+ },
+ {"disable-mpp": None},
+ ])
+
+ # Give the LSP some funds to open jit-channels
+ addr = l2.rpc.newaddr()['bech32']
+ bitcoind.rpc.sendtoaddress(addr, 1)
+ bitcoind.generate_block(1)
+
+ node_factory.join_nodes([l3, l2], fundchannel=True, wait_for_announce=True)
+ node_factory.join_nodes([l1, l2], fundchannel=False)
+
+ chanid = only_one(l3.rpc.listpeerchannels(l2.info['id'])['channels'])['short_channel_id']
+
+ fee_opt = l1.rpc.lsps_lsps2_getinfo(lsp_id=l2.info['id'])['opening_fee_params_menu'][0]
+ buy_res = l1.rpc.lsps_lsps2_buy(lsp_id=l2.info['id'], opening_fee_params=fee_opt)
+
+ hint = [[{
+ "id": l2.info['id'],
+ "short_channel_id": buy_res['jit_channel_scid'],
+ "fee_base_msat": 0,
+ "fee_proportional_millionths": 0,
+ "cltv_expiry_delta": buy_res['lsp_cltv_expiry_delta'],
+ }]]
+
+ bolt11 = l1.dev_invoice(
+ amount_msat="any",
+ description="lsp-invoice-1",
+ label="lsp-invoice-1",
+ dev_routes=hint,
+ )['bolt11']
+
+ with pytest.raises(ValueError):
+ l3.rpc.pay(bolt11, amount_msat=10000000)
+
+ # l1 shouldn't have a new channel.
+ chs = l1.rpc.listpeerchannels()['channels']
+ assert len(chs) == 0
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.