From 27f36c47a191d40455fee71fe579417b1132e4fc Mon Sep 17 00:00:00 2001 From: benthecarman Date: Wed, 17 Dec 2025 23:16:55 -0600 Subject: [PATCH] Require HTTP Basic Auth for all RPC/CLI Adds basic auth to protect the RPC from potential attackers. --- Cargo.lock | 1 + README.md | 16 ++- ldk-server-cli/src/main.rs | 8 +- ldk-server-client/src/client.rs | 8 +- ldk-server/Cargo.toml | 1 + ldk-server/ldk-server-config.toml | 5 + ldk-server/src/main.rs | 2 +- ldk-server/src/service.rs | 158 +++++++++++++++++++++++++++++- ldk-server/src/util/config.rs | 61 ++++++++++-- 9 files changed, 238 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ba02fb5..9b6775b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1717,6 +1717,7 @@ name = "ldk-server" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.21.7", "bytes", "chrono", "futures-util", diff --git a/README.md b/README.md index 8ea7071..b72e04c 100644 --- a/README.md +++ b/README.md @@ -21,36 +21,42 @@ a Lightning node while exposing a robust, language-agnostic API via [Protocol Bu - Built on top of LDK-Node, leveraging the modular, reliable, and high-performance architecture of LDK. - **Effortless Integration**: - - Ideal for embedding Lightning functionality into payment processors, self-hosted nodes, custodial wallets, or other Lightning-enabled + - Ideal for embedding Lightning functionality into payment processors, self-hosted nodes, custodial wallets, or + other Lightning-enabled applications. ### Project Status 🚧 **Work in Progress**: + - **APIs Under Development**: Expect breaking changes as the project evolves. - **Potential Bugs and Inconsistencies**: While progress is being made toward stability, unexpected behavior may occur. -- **Improved Logging and Error Handling Coming Soon**: Current error handling is rudimentary (specially for CLI), and usability improvements are actively being worked on. +- **Improved Logging and Error Handling Coming Soon**: Current error handling is rudimentary (specially for CLI), and + usability improvements are actively being worked on. - **Pending Testing**: Not tested, hence don't use it for production! We welcome your feedback and contributions to help shape the future of LDK Server! - ### Configuration + Refer `./ldk-server/ldk-server-config.toml` to see available configuration options. ### Building + ``` git clone https://github.com/lightningdevkit/ldk-server.git cargo build ``` ### Running + ``` cargo run --bin ldk-server ./ldk-server/ldk-server-config.toml ``` Interact with the node using CLI: + ``` -./target/debug/ldk-server-cli -b localhost:3002 onchain-receive # To generate onchain-receive address. -./target/debug/ldk-server-cli -b localhost:3002 help # To print help/available commands. +./target/debug/ldk-server-cli -b localhost:3002 -u user -p pass onchain-receive # To generate onchain-receive address. +./target/debug/ldk-server-cli -b localhost:3002 -u user -p pass help # To print help/available commands. ``` diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 304f3c8..224df49 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -43,6 +43,12 @@ struct Cli { #[arg(short, long, default_value = "localhost:3000")] base_url: String, + #[arg(short, long)] + username: String, + + #[arg(short, long)] + password: String, + #[command(subcommand)] command: Commands, } @@ -208,7 +214,7 @@ enum Commands { #[tokio::main] async fn main() { let cli = Cli::parse(); - let client = LdkServerClient::new(cli.base_url); + let client = LdkServerClient::new(cli.base_url, cli.username, cli.password); match cli.command { Commands::GetNodeInfo => { diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 9983151..fc05502 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -40,12 +40,14 @@ const APPLICATION_OCTET_STREAM: &str = "application/octet-stream"; pub struct LdkServerClient { base_url: String, client: Client, + auth_credentials: (String, String), } impl LdkServerClient { /// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint. - pub fn new(base_url: String) -> Self { - Self { base_url, client: Client::new() } + /// `username` and `password` are used for basic authentication. + pub fn new(base_url: String, username: String, password: String) -> Self { + Self { base_url, client: Client::new(), auth_credentials: (username, password) } } /// Retrieve the latest node info like `node_id`, `current_best_block` etc. @@ -196,10 +198,12 @@ impl LdkServerClient { &self, request: &Rq, url: &str, ) -> Result { let request_body = request.encode_to_vec(); + let (username, password) = &self.auth_credentials; let response_raw = self .client .post(url) .header(CONTENT_TYPE, APPLICATION_OCTET_STREAM) + .basic_auth(username, Some(password)) .body(request_body) .send() .await diff --git a/ldk-server/Cargo.toml b/ldk-server/Cargo.toml index e1053f7..62f82d3 100644 --- a/ldk-server/Cargo.toml +++ b/ldk-server/Cargo.toml @@ -20,6 +20,7 @@ async-trait = { version = "0.1.85", default-features = false } toml = { version = "0.8.9", default-features = false, features = ["parse"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } log = "0.4.28" +base64 = { version = "0.21", default-features = false, features = ["std"] } # Required for RabittMQ based EventPublisher. Only enabled for `events-rabbitmq` feature. lapin = { version = "2.4.0", features = ["rustls"], default-features = false, optional = true } diff --git a/ldk-server/ldk-server-config.toml b/ldk-server/ldk-server-config.toml index 45967f5..ee298c6 100644 --- a/ldk-server/ldk-server-config.toml +++ b/ldk-server/ldk-server-config.toml @@ -12,6 +12,11 @@ dir_path = "/tmp/ldk-server/" # Path for LDK and BDK data persis level = "Debug" # Log level (Error, Warn, Info, Debug, Trace) file_path = "/tmp/ldk-server/ldk-server.log" # Log file path +# HTTP Basic Authentication (REQUIRED) +[auth] +username = "your-username" +password = "your-password" + # Must set either bitcoind or esplora settings, but not both # Bitcoin Core settings diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index e16d5f0..ae375fa 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -351,7 +351,7 @@ fn main() { match res { Ok((stream, _)) => { let io_stream = TokioIo::new(stream); - let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store)); + let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), config_file.auth_config.clone()); runtime.spawn(async move { if let Err(err) = http1::Builder::new().serve_connection(io_stream, node_service).await { error!("Failed to serve connection: {}", err); diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 6048a10..b0c8903 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -7,10 +7,12 @@ // You may not use this file except in accordance with one or both of these // licenses. +use ldk_node::bitcoin::base64::{self, Engine}; use ldk_node::Node; use http_body_util::{BodyExt, Full, Limited}; use hyper::body::{Bytes, Incoming}; +use hyper::header::AUTHORIZATION; use hyper::service::Service; use hyper::{Request, Response, StatusCode}; @@ -30,7 +32,7 @@ use crate::api::bolt12_receive::handle_bolt12_receive_request; use crate::api::bolt12_send::handle_bolt12_send_request; use crate::api::close_channel::{handle_close_channel_request, handle_force_close_channel_request}; use crate::api::error::LdkServerError; -use crate::api::error::LdkServerErrorCode::InvalidRequestError; +use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError}; use crate::api::get_balances::handle_get_balances_request; use crate::api::get_node_info::handle_get_node_info_request; use crate::api::get_payment_details::handle_get_payment_details_request; @@ -43,6 +45,7 @@ use crate::api::open_channel::handle_open_channel; use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_request}; use crate::api::update_channel_config::handle_update_channel_config_request; use crate::io::persist::paginated_kv_store::PaginatedKVStore; +use crate::util::config::BasicAuthConfig; use crate::util::proto_adapter::to_error_response; use std::future::Future; use std::pin::Pin; @@ -56,11 +59,44 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; pub struct NodeService { node: Arc, paginated_kv_store: Arc, + auth_config: BasicAuthConfig, } impl NodeService { - pub(crate) fn new(node: Arc, paginated_kv_store: Arc) -> Self { - Self { node, paginated_kv_store } + pub(crate) fn new( + node: Arc, paginated_kv_store: Arc, + auth_config: BasicAuthConfig, + ) -> Self { + Self { node, paginated_kv_store, auth_config } + } +} + +fn validate_auth(req: &Request, auth_config: &BasicAuthConfig) -> Result<(), LdkServerError> { + let auth_header = req + .headers() + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| LdkServerError::new(AuthError, "Missing Authorization header"))?; + + let encoded = auth_header + .strip_prefix("Basic ") + .ok_or_else(|| LdkServerError::new(AuthError, "Invalid Authorization header format"))?; + + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .map_err(|_| LdkServerError::new(AuthError, "Invalid base64 encoding"))?; + + let credentials = std::str::from_utf8(&decoded) + .map_err(|_| LdkServerError::new(AuthError, "Invalid credentials format"))?; + + let (username, password) = credentials + .split_once(':') + .ok_or_else(|| LdkServerError::new(AuthError, "Invalid credentials format"))?; + + if username == auth_config.username && password == auth_config.password { + Ok(()) + } else { + Err(LdkServerError::new(AuthError, "Invalid credentials")) } } @@ -75,6 +111,18 @@ impl Service> for NodeService { type Future = Pin> + Send>>; fn call(&self, req: Request) -> Self::Future { + // Validate authentication + if let Err(e) = validate_auth(&req, &self.auth_config) { + let (error_response, status_code) = to_error_response(e); + return Box::pin(async move { + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + // unwrap safety: body only errors when previous chained calls failed. + .unwrap()) + }); + } + let context = Context { node: Arc::clone(&self.node), paginated_kv_store: Arc::clone(&self.paginated_kv_store), @@ -189,3 +237,107 @@ async fn handle_request< }, } } + +#[cfg(test)] +mod tests { + use super::*; + use hyper::header::AUTHORIZATION; + + fn create_test_request(auth_header: Option) -> Request<()> { + let mut builder = Request::builder(); + if let Some(header) = auth_header { + builder = builder.header(AUTHORIZATION, header); + } + builder.body(()).unwrap() + } + + #[test] + fn test_validate_auth_success() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + // Create a valid Basic Auth header + let credentials = format!("{}:{}", auth_config.username, auth_config.password); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + let auth_header = format!("Basic {encoded}"); + + let req = create_test_request(Some(auth_header)); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_auth_wrong_password() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + // Wrong password + let credentials = format!("{}:wrongpass", auth_config.username); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + let auth_header = format!("Basic {encoded}"); + + let req = create_test_request(Some(auth_header)); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_auth_wrong_username() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + // Wrong username + let credentials = format!("wronguser:{}", auth_config.password); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + let auth_header = format!("Basic {encoded}"); + + let req = create_test_request(Some(auth_header)); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_auth_missing_header() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + let req = create_test_request(None); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_auth_invalid_format() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + let credentials = format!("{}:{}", auth_config.username, auth_config.password); + let encoded = base64::engine::general_purpose::STANDARD.encode(credentials); + // Missing "Basic " prefix + let req = create_test_request(Some(encoded)); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_auth_invalid_base64() { + let auth_config = + BasicAuthConfig { username: "testuser".to_string(), password: "testpass".to_string() }; + + // Invalid base64 + let req = create_test_request(Some("Basic not-valid-base64!".to_string())); + + let result = validate_auth(&req, &auth_config); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } +} diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 79fda35..7fbabe8 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -32,6 +32,13 @@ pub struct Config { pub lsps2_service_config: Option, pub log_level: LevelFilter, pub log_file_path: Option, + pub auth_config: BasicAuthConfig, +} + +#[derive(Debug, Clone)] +pub struct BasicAuthConfig { + pub username: String, + pub password: String, } #[derive(Debug)] @@ -139,6 +146,16 @@ impl TryFrom for Config { ))? .into()); + let auth_config = toml_config + .auth + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "`auth` section with `username` and `password` is required in config file", + ) + }) + .map(|auth| BasicAuthConfig { username: auth.username, password: auth.password })?; + Ok(Config { listening_addr, network: toml_config.node.network, @@ -151,6 +168,7 @@ impl TryFrom for Config { lsps2_service_config, log_level, log_file_path: toml_config.log.and_then(|l| l.file), + auth_config, }) } } @@ -165,6 +183,7 @@ pub struct TomlConfig { rabbitmq: Option, liquidity: Option, log: Option, + auth: Option, } #[derive(Deserialize, Serialize)] @@ -209,6 +228,12 @@ struct RabbitmqConfig { exchange_name: String, } +#[derive(Deserialize, Serialize)] +struct AuthConfig { + username: String, + password: String, +} + #[derive(Deserialize, Serialize)] struct LiquidityConfig { lsps2_service: Option, @@ -293,17 +318,21 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - + [storage.disk] dir_path = "/tmp" [log] level = "Trace" file = "/var/log/ldk-server.log" - + + [auth] + username = "testuser" + password = "testpass" + [esplora] server_url = "https://mempool.space/api" - + [rabbitmq] connection_string = "rabbitmq_connection_string" exchange_name = "rabbitmq_exchange_name" @@ -352,6 +381,10 @@ mod tests { }), log_level: LevelFilter::Trace, log_file_path: Some("/var/log/ldk-server.log".to_string()), + auth_config: BasicAuthConfig { + username: "testuser".to_string(), + password: "testpass".to_string(), + }, }; assert_eq!(config.listening_addr, expected.listening_addr); @@ -378,19 +411,23 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - + [storage.disk] dir_path = "/tmp" [log] level = "Trace" file = "/var/log/ldk-server.log" - + + [auth] + username = "testuser" + password = "testpass" + [bitcoind] rpc_address = "127.0.0.1:8332" # RPC endpoint rpc_user = "bitcoind-testuser" rpc_password = "bitcoind-testpassword" - + [rabbitmq] connection_string = "rabbitmq_connection_string" exchange_name = "rabbitmq_exchange_name" @@ -426,22 +463,26 @@ mod tests { listening_address = "localhost:3001" rest_service_address = "127.0.0.1:3002" alias = "LDK Server" - + [storage.disk] dir_path = "/tmp" [log] level = "Trace" file = "/var/log/ldk-server.log" - + + [auth] + username = "testuser" + password = "testpass" + [bitcoind] rpc_address = "127.0.0.1:8332" # RPC endpoint rpc_user = "bitcoind-testuser" rpc_password = "bitcoind-testpassword" - + [esplora] server_url = "https://mempool.space/api" - + [rabbitmq] connection_string = "rabbitmq_connection_string" exchange_name = "rabbitmq_exchange_name"