From edf9a38455c5e0001e16a288b1bb352141bbfe34 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Sat, 7 Mar 2026 15:02:36 -0600 Subject: [PATCH 1/2] Add key ID to HMAC auth header for O(1) key lookup Change the auth header format from `HMAC :` to `HMAC ::` where key_id is the first 8 bytes of SHA256(api_key), hex-encoded (16 chars). The server validates the key_id before computing the HMAC, enabling O(1) key lookup when multiple API keys are supported. The client precomputes the key_id at construction time. Co-Authored-By: Claude Opus 4.6 (1M context) --- ldk-server-client/src/client.rs | 15 ++++-- ldk-server/src/service.rs | 83 ++++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 19 deletions(-) diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 04b1dc5..508910a 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -59,6 +59,7 @@ pub struct LdkServerClient { base_url: String, client: Client, api_key: String, + key_id: String, } impl LdkServerClient { @@ -77,11 +78,19 @@ impl LdkServerClient { .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; - Ok(Self { base_url, client, api_key }) + // Compute key_id as first 8 bytes of SHA256(api_key), hex-encoded (16 chars) + let hash = sha256::Hash::hash(api_key.as_bytes()); + let key_id = hash[..8].iter().fold(String::with_capacity(16), |mut acc, b| { + use std::fmt::Write; + let _ = write!(acc, "{:02x}", b); + acc + }); + + Ok(Self { base_url, client, api_key, key_id }) } /// Computes the HMAC-SHA256 authentication header value. - /// Format: "HMAC :" + /// Format: "HMAC ::" fn compute_auth_header(&self, body: &[u8]) -> String { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -94,7 +103,7 @@ impl LdkServerClient { hmac_engine.input(body); let hmac_result = Hmac::::from_engine(hmac_engine); - format!("HMAC {}:{}", timestamp, hmac_result) + format!("HMAC {}:{}:{}", self.key_id, timestamp, hmac_result) } /// Retrieve the latest node info like `node_id`, `current_best_block` etc. diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index d10d615..41950ac 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -84,12 +84,23 @@ const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; #[derive(Debug, Clone)] pub(crate) struct AuthParams { + key_id: String, timestamp: u64, hmac_hex: String, } +/// Computes the key_id for an API key: first 8 bytes of SHA256(api_key), hex-encoded (16 chars). +fn compute_key_id(api_key: &str) -> String { + let hash = sha256::Hash::hash(api_key.as_bytes()); + hash[..8].iter().fold(String::with_capacity(16), |mut acc, b| { + use std::fmt::Write; + let _ = write!(acc, "{:02x}", b); + acc + }) +} + /// Extracts authentication parameters from request headers. -/// Returns (timestamp, hmac_hex) if valid format, or error. +/// Returns (key_id, timestamp, hmac_hex) if valid format, or error. fn extract_auth_params(req: &Request) -> Result { let auth_header = req .headers() @@ -97,14 +108,24 @@ fn extract_auth_params(req: &Request) -> Result:" + // Format: "HMAC ::" let auth_data = auth_header .strip_prefix("HMAC ") .ok_or_else(|| LdkServerError::new(AuthError, "Invalid X-Auth header format"))?; - let (timestamp_str, hmac_hex) = auth_data - .split_once(':') - .ok_or_else(|| LdkServerError::new(AuthError, "Invalid X-Auth header format"))?; + let parts: Vec<&str> = auth_data.splitn(3, ':').collect(); + if parts.len() != 3 { + return Err(LdkServerError::new(AuthError, "Invalid X-Auth header format")); + } + + let key_id = parts[0]; + let timestamp_str = parts[1]; + let hmac_hex = parts[2]; + + // Validate key_id is 16 hex chars + if key_id.len() != 16 || !key_id.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(LdkServerError::new(AuthError, "Invalid key_id in X-Auth header")); + } let timestamp = timestamp_str .parse::() @@ -115,13 +136,19 @@ fn extract_auth_params(req: &Request) -> Result Result<(), LdkServerError> { + // Verify the key_id matches the api_key + let expected_key_id = compute_key_id(api_key); + if key_id != expected_key_id { + return Err(LdkServerError::new(AuthError, "Invalid credentials")); + } + // Validate timestamp is within acceptable window let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -406,9 +433,13 @@ async fn handle_request< }; // Validate HMAC authentication with the request body - if let Err(e) = - validate_hmac_auth(auth_params.timestamp, &auth_params.hmac_hex, &bytes, &api_key) - { + if let Err(e) = validate_hmac_auth( + &auth_params.key_id, + auth_params.timestamp, + &auth_params.hmac_hex, + &bytes, + &api_key, + ) { let (error_response, status_code) = to_error_response(e); return Ok(Response::builder() .status(status_code) @@ -468,13 +499,15 @@ mod tests { let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); let hmac = "8f5a33c2c68fb253899a588308fd13dcaf162d2788966a1fb6cc3aa2e0c51a93"; - let auth_header = format!("HMAC {timestamp}:{hmac}"); + let key_id = "abcdef0123456789"; + let auth_header = format!("HMAC {key_id}:{timestamp}:{hmac}"); let req = create_test_request(Some(auth_header)); let result = extract_auth_params(&req); assert!(result.is_ok()); - let AuthParams { timestamp: ts, hmac_hex } = result.unwrap(); + let AuthParams { key_id: kid, timestamp: ts, hmac_hex } = result.unwrap(); + assert_eq!(kid, key_id); assert_eq!(ts, timestamp); assert_eq!(hmac_hex, hmac); } @@ -501,25 +534,41 @@ mod tests { #[test] fn test_validate_hmac_auth_success() { let api_key = "test_api_key".to_string(); + let key_id = compute_key_id(&api_key); let body = b"test request body"; let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); let hmac = compute_hmac(&api_key, timestamp, body); - let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); assert!(result.is_ok()); } #[test] fn test_validate_hmac_auth_wrong_key() { let api_key = "test_api_key".to_string(); + let key_id = compute_key_id(&api_key); let body = b"test request body"; let timestamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); // Compute HMAC with wrong key let hmac = compute_hmac("wrong_key", timestamp, body); - let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().error_code, AuthError); + } + + #[test] + fn test_validate_hmac_auth_wrong_key_id() { + let api_key = "test_api_key".to_string(); + let wrong_key_id = "0000000000000000"; + let body = b"test request body"; + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let hmac = compute_hmac(&api_key, timestamp, body); + + let result = validate_hmac_auth(wrong_key_id, timestamp, &hmac, body, &api_key); assert!(result.is_err()); assert_eq!(result.unwrap_err().error_code, AuthError); } @@ -527,6 +576,7 @@ mod tests { #[test] fn test_validate_hmac_auth_expired_timestamp() { let api_key = "test_api_key".to_string(); + let key_id = compute_key_id(&api_key); let body = b"test request body"; // Use a timestamp from 10 minutes ago let timestamp = @@ -534,7 +584,7 @@ mod tests { - 600; let hmac = compute_hmac(&api_key, timestamp, body); - let result = validate_hmac_auth(timestamp, &hmac, body, &api_key); + let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); assert!(result.is_err()); assert_eq!(result.unwrap_err().error_code, AuthError); } @@ -542,6 +592,7 @@ mod tests { #[test] fn test_validate_hmac_auth_tampered_body() { let api_key = "test_api_key".to_string(); + let key_id = compute_key_id(&api_key); let original_body = b"test request body"; let tampered_body = b"tampered body"; let timestamp = @@ -550,7 +601,7 @@ mod tests { let hmac = compute_hmac(&api_key, timestamp, original_body); // Try to validate with tampered body - let result = validate_hmac_auth(timestamp, &hmac, tampered_body, &api_key); + let result = validate_hmac_auth(&key_id, timestamp, &hmac, tampered_body, &api_key); assert!(result.is_err()); assert_eq!(result.unwrap_err().error_code, AuthError); } From 44f4d06562d4cb8fb546680e153aeb326a8ba0cd Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 9 Mar 2026 11:14:06 -0400 Subject: [PATCH 2/2] Add granular per-endpoint API key permissions Replace the single all-or-nothing API key with a directory-based system that supports multiple API keys with per-endpoint access control. API keys are stored as TOML files in `/api_keys/`, each specifying a hex key and a list of allowed endpoints (or "*" for admin). The legacy `api_key` file is auto-migrated on first startup. New RPC endpoints: - `CreateApiKey`: generates a new key with specified permissions (admin only), writes it to disk, and updates the in-memory store - `GetPermissions`: returns the allowed endpoints for the calling key (always accessible by any valid key) The in-memory key store is shared via Arc across all connections so keys created at runtime are immediately usable. File writes use atomic rename for crash safety. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e-tests/src/lib.rs | 38 +- e2e-tests/tests/e2e.rs | 167 +++++- ldk-server-cli/src/config.rs | 9 +- ldk-server-cli/src/main.rs | 51 +- ldk-server-client/src/client.rs | 54 +- ldk-server-protos/src/api.rs | 46 ++ ldk-server-protos/src/endpoints.rs | 36 ++ ldk-server/src/api_keys.rs | 700 ++++++++++++++++++++++++ ldk-server/src/main.rs | 56 +- ldk-server/src/service.rs | 826 +++++++++++++++++------------ 10 files changed, 1574 insertions(+), 409 deletions(-) create mode 100644 ldk-server/src/api_keys.rs diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index 8b34fd2..ec22d64 100644 --- a/e2e-tests/src/lib.rs +++ b/e2e-tests/src/lib.rs @@ -14,7 +14,6 @@ use std::process::{Child, Command, Stdio}; use std::time::Duration; use corepc_node::Node; -use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; use ldk_server_client::ldk_server_protos::api::{GetNodeInfoRequest, GetNodeInfoResponse}; use ldk_server_protos::api::{ @@ -176,17 +175,21 @@ client_trusts_lsp = true } }); - // Wait for the api_key and tls.crt files to appear in the network subdir + // Wait for the api_keys/admin.toml and tls.crt files to appear let network_dir = storage_dir.join("regtest"); - let api_key_path = network_dir.join("api_key"); + let admin_toml_path = network_dir.join("api_keys").join("admin.toml"); let tls_cert_path = storage_dir.join("tls.crt"); - wait_for_file(&api_key_path, Duration::from_secs(30)).await; + wait_for_file(&admin_toml_path, Duration::from_secs(30)).await; wait_for_file(&tls_cert_path, Duration::from_secs(30)).await; - // Read the API key (raw bytes -> hex) - let api_key_bytes = std::fs::read(&api_key_path).unwrap(); - let api_key = api_key_bytes.to_lower_hex_string(); + // Read the API key from admin.toml + let admin_toml_contents = std::fs::read_to_string(&admin_toml_path).unwrap(); + let api_key = admin_toml_contents + .lines() + .find_map(|line| line.strip_prefix("key = \"")?.strip_suffix('"')) + .unwrap() + .to_string(); // Read TLS cert let tls_cert_pem = std::fs::read(&tls_cert_path).unwrap(); @@ -311,6 +314,12 @@ pub fn run_cli(handle: &LdkServerHandle, args: &[&str]) -> serde_json::Value { .unwrap_or_else(|e| panic!("Failed to parse CLI output as JSON: {e}\nOutput: {stdout}")) } +/// Create a client with a specific API key. +pub fn make_client(handle: &LdkServerHandle, api_key: &str) -> LdkServerClient { + let tls_cert_pem = std::fs::read(&handle.tls_cert_path).unwrap(); + LdkServerClient::new(handle.base_url(), api_key.to_string(), &tls_cert_pem).unwrap() +} + /// Mine blocks and wait for all servers to sync to the new chain tip. pub async fn mine_and_sync( bitcoind: &TestBitcoind, servers: &[&LdkServerHandle], block_count: u64, @@ -428,6 +437,21 @@ pub async fn setup_funded_channel( open_resp.user_channel_id } +/// Create a restricted API key by calling the CreateApiKey RPC on the server. +pub async fn create_restricted_client( + handle: &LdkServerHandle, name: &str, endpoints: Vec, +) -> LdkServerClient { + use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest; + + let resp = handle + .client() + .create_api_key(CreateApiKeyRequest { name: name.to_string(), endpoints }) + .await + .unwrap(); + + make_client(handle, &resp.api_key) +} + /// RabbitMQ event consumer for verifying events published by ldk-server. pub struct RabbitMqEventConsumer { _connection: lapin::Connection, diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 034926e..3adf159 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -11,12 +11,14 @@ use std::str::FromStr; use std::time::Duration; use e2e_tests::{ - find_available_port, mine_and_sync, run_cli, run_cli_raw, setup_funded_channel, - wait_for_onchain_balance, LdkServerHandle, RabbitMqEventConsumer, TestBitcoind, + create_restricted_client, find_available_port, make_client, mine_and_sync, run_cli, + run_cli_raw, setup_funded_channel, wait_for_onchain_balance, LdkServerHandle, + RabbitMqEventConsumer, TestBitcoind, }; use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_server_client::ldk_server_protos::api::{ - Bolt11ReceiveRequest, Bolt12ReceiveRequest, OnchainReceiveRequest, + Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetNodeInfoRequest, GetPermissionsRequest, + OnchainReceiveRequest, }; use ldk_server_client::ldk_server_protos::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, @@ -611,3 +613,162 @@ async fn test_forwarded_payment_event() { node_c.stop().unwrap(); } + +#[tokio::test] +async fn test_get_permissions_admin() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let resp = server.client().get_permissions(GetPermissionsRequest {}).await.unwrap(); + assert!( + resp.endpoints.contains(&"*".to_string()), + "Expected admin key to have wildcard permission" + ); +} + +#[tokio::test] +async fn test_create_api_key_and_get_permissions() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let restricted_client = create_restricted_client( + &server, + "read-only", + vec!["GetNodeInfo".to_string(), "GetBalances".to_string()], + ) + .await; + + let resp = restricted_client.get_permissions(GetPermissionsRequest {}).await.unwrap(); + assert_eq!(resp.endpoints, vec!["GetBalances", "GetNodeInfo"]); +} + +#[tokio::test] +async fn test_restricted_key_allowed_endpoint() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let restricted_client = + create_restricted_client(&server, "node-info-only", vec!["GetNodeInfo".to_string()]).await; + + let resp = restricted_client.get_node_info(GetNodeInfoRequest {}).await; + assert!(resp.is_ok(), "Restricted key should be able to call allowed endpoint"); + assert_eq!(resp.unwrap().node_id, server.node_id()); +} + +#[tokio::test] +async fn test_restricted_key_denied_endpoint() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let restricted_client = + create_restricted_client(&server, "info-only", vec!["GetNodeInfo".to_string()]).await; + + let resp = restricted_client.onchain_receive(OnchainReceiveRequest {}).await; + assert!(resp.is_err(), "Restricted key should be denied access to unauthorized endpoint"); +} + +#[tokio::test] +async fn test_restricted_key_get_permissions_always_allowed() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + // Create key with no endpoints at all (except GetPermissions which is always allowed) + let restricted_client = + create_restricted_client(&server, "perms-only", vec!["GetNodeInfo".to_string()]).await; + + let resp = restricted_client.get_permissions(GetPermissionsRequest {}).await; + assert!(resp.is_ok(), "GetPermissions should always be allowed"); +} + +#[tokio::test] +async fn test_create_api_key_via_cli() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let output = + run_cli(&server, &["create-api-key", "cli-test-key", "-e", "GetNodeInfo", "GetBalances"]); + let api_key = output["api_key"].as_str().unwrap(); + assert_eq!(api_key.len(), 64); + assert!(api_key.chars().all(|c| c.is_ascii_hexdigit())); +} + +#[tokio::test] +async fn test_invalid_api_key_rejected() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let bad_key = "ff".repeat(32); + let bad_client = make_client(&server, &bad_key); + + let resp = bad_client.get_node_info(GetNodeInfoRequest {}).await; + assert!(resp.is_err(), "Invalid API key should be rejected"); +} + +#[tokio::test] +async fn test_restricted_key_cannot_create_api_key() { + use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest; + + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let restricted = create_restricted_client(&server, "limited", vec!["GetNodeInfo".to_string()]) + .await; + + // Restricted key should not be able to create new keys + let result = restricted + .create_api_key(CreateApiKeyRequest { + name: "sneaky".to_string(), + endpoints: vec!["*".to_string()], + }) + .await; + assert!(result.is_err(), "Restricted key should not be able to create API keys"); + assert_eq!( + result.unwrap_err().error_code, + ldk_server_client::error::LdkServerErrorCode::AuthError + ); +} + +#[tokio::test] +async fn test_create_api_key_duplicate_name_rejected() { + use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest; + + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + // First creation should succeed + let result = server + .client() + .create_api_key(CreateApiKeyRequest { + name: "my-key".to_string(), + endpoints: vec!["GetNodeInfo".to_string()], + }) + .await; + assert!(result.is_ok()); + + // Duplicate name should fail + let result = server + .client() + .create_api_key(CreateApiKeyRequest { + name: "my-key".to_string(), + endpoints: vec!["GetNodeInfo".to_string()], + }) + .await; + assert!(result.is_err(), "Duplicate API key name should be rejected"); +} + +#[tokio::test] +async fn test_create_api_key_invalid_endpoint_rejected() { + use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest; + + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let result = server + .client() + .create_api_key(CreateApiKeyRequest { + name: "bad-key".to_string(), + endpoints: vec!["NonExistentEndpoint".to_string()], + }) + .await; + assert!(result.is_err(), "Unknown endpoint should be rejected"); +} diff --git a/ldk-server-cli/src/config.rs b/ldk-server-cli/src/config.rs index 49f84cf..0e2ab8e 100644 --- a/ldk-server-cli/src/config.rs +++ b/ldk-server-cli/src/config.rs @@ -13,7 +13,6 @@ use serde::{Deserialize, Serialize}; const DEFAULT_CONFIG_FILE: &str = "config.toml"; const DEFAULT_CERT_FILE: &str = "tls.crt"; -const API_KEY_FILE: &str = "api_key"; pub fn get_default_data_dir() -> Option { #[cfg(target_os = "macos")] @@ -40,8 +39,12 @@ pub fn get_default_cert_path() -> Option { get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) } -pub fn get_default_api_key_path(network: &str) -> Option { - get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) +/// Reads the admin API key from `api_keys/admin.toml` in the network directory. +pub fn get_default_admin_api_key(network: &str) -> Option { + let admin_toml_path = get_default_data_dir()?.join(network).join("api_keys").join("admin.toml"); + let contents = std::fs::read_to_string(&admin_toml_path).ok()?; + let parsed: toml::Value = toml::from_str(&contents).ok()?; + parsed.get("key").and_then(|v| v.as_str()).map(String::from) } #[derive(Debug, Deserialize)] diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 139380a..72fb615 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -12,7 +12,7 @@ use std::path::PathBuf; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; use config::{ - get_default_api_key_path, get_default_cert_path, get_default_config_path, load_config, + get_default_admin_api_key, get_default_cert_path, get_default_config_path, load_config, }; use hex_conservative::DisplayHex; use ldk_server_client::client::LdkServerClient; @@ -24,12 +24,13 @@ use ldk_server_client::ldk_server_protos::api::{ Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, - GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, - GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, - GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest, - GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, + CreateApiKeyRequest, CreateApiKeyResponse, DisconnectPeerRequest, DisconnectPeerResponse, + ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse, + GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, + GetPaymentDetailsRequest, GetPaymentDetailsResponse, GetPermissionsRequest, + GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, + GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, + GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, @@ -411,6 +412,20 @@ enum Commands { #[arg(help = "The hex-encoded node ID to look up")] node_id: String, }, + #[command(about = "Create a new API key with specific endpoint permissions (admin-only)")] + CreateApiKey { + #[arg(help = "A human-readable name for the API key")] + name: String, + #[arg( + short, + long, + num_args = 1.., + help = "List of endpoint names this key is permitted to access" + )] + endpoints: Vec, + }, + #[command(about = "Retrieve the permissions of the current API key")] + GetPermissions, #[command(about = "Generate shell completions for the CLI")] Completions { #[arg( @@ -434,18 +449,16 @@ async fn main() { let config_path = cli.config.map(PathBuf::from).or_else(get_default_config_path); let config = config_path.as_ref().and_then(|p| load_config(p).ok()); - // Get API key from argument, then from api_key file + // Get API key from argument, then from admin.toml let api_key = cli .api_key .or_else(|| { - // Try to read from api_key file based on network (file contains raw bytes) - let network = config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); - get_default_api_key_path(&network) - .and_then(|path| std::fs::read(&path).ok()) - .map(|bytes| bytes.to_lower_hex_string()) + let network = + config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string()); + get_default_admin_api_key(&network) }) .unwrap_or_else(|| { - eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at ~/.ldk-server/[network]/api_key"); + eprintln!("API key not provided. Use --api-key or ensure api_keys/admin.toml exists at ~/.ldk-server/[network]/api_keys/admin.toml"); std::process::exit(1); }); @@ -844,6 +857,16 @@ async fn main() { client.graph_get_node(GraphGetNodeRequest { node_id }).await, ); }, + Commands::CreateApiKey { name, endpoints } => { + handle_response_result::<_, CreateApiKeyResponse>( + client.create_api_key(CreateApiKeyRequest { name, endpoints }).await, + ); + }, + Commands::GetPermissions => { + handle_response_result::<_, GetPermissionsResponse>( + client.get_permissions(GetPermissionsRequest {}).await, + ); + }, Commands::Completions { .. } => unreachable!("Handled above"), } } diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index 508910a..b551b89 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -15,27 +15,29 @@ use ldk_server_protos::api::{ Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse, - DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, - ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, - GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, - GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, - GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, - ListChannelsResponse, ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, - ListPaymentsRequest, ListPaymentsResponse, OnchainReceiveRequest, OnchainReceiveResponse, - OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, - SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, - SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, UpdateChannelConfigRequest, + CreateApiKeyRequest, CreateApiKeyResponse, DisconnectPeerRequest, DisconnectPeerResponse, + ExportPathfindingScoresRequest, ExportPathfindingScoresResponse, ForceCloseChannelRequest, + ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, + GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GetPermissionsRequest, GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, + GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, + GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse, + ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListPaymentsRequest, + ListPaymentsResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, + OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, + SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, + SpontaneousSendRequest, SpontaneousSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, }; use ldk_server_protos::endpoints::{ BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, - GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, - LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, - ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, - SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, CREATE_API_KEY_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, + GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH, + GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, + LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, + OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, + UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use ldk_server_protos::error::{ErrorCode, ErrorResponse}; use prost::Message; @@ -358,6 +360,24 @@ impl LdkServerClient { self.post_request(&request, &url).await } + /// Create a new API key with specific endpoint permissions (admin-only). + /// For API contract/usage, refer to docs for [`CreateApiKeyRequest`] and [`CreateApiKeyResponse`]. + pub async fn create_api_key( + &self, request: CreateApiKeyRequest, + ) -> Result { + let url = format!("https://{}/{CREATE_API_KEY_PATH}", self.base_url); + self.post_request(&request, &url).await + } + + /// Retrieve the permissions of the current API key. + /// For API contract/usage, refer to docs for [`GetPermissionsRequest`] and [`GetPermissionsResponse`]. + pub async fn get_permissions( + &self, request: GetPermissionsRequest, + ) -> Result { + let url = format!("https://{}/{GET_PERMISSIONS_PATH}", self.base_url); + self.post_request(&request, &url).await + } + async fn post_request( &self, request: &Rq, url: &str, ) -> Result { diff --git a/ldk-server-protos/src/api.rs b/ldk-server-protos/src/api.rs index cfe4550..8a5c571 100644 --- a/ldk-server-protos/src/api.rs +++ b/ldk-server-protos/src/api.rs @@ -841,3 +841,49 @@ pub struct GraphGetNodeResponse { #[prost(message, optional, tag = "1")] pub node: ::core::option::Option, } +/// Create a new API key with specific endpoint permissions. +/// Admin-only: requires an admin API key. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateApiKeyRequest { + /// A human-readable name for the API key. Must be unique and consist of + /// alphanumeric characters, hyphens, or underscores. + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + /// The list of endpoint names this key is permitted to access. + /// An empty list means the key can only access GetPermissions. + #[prost(string, repeated, tag = "2")] + pub endpoints: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// The response `content` for the `CreateApiKey` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateApiKeyResponse { + /// The hex-encoded API key. This is only returned once at creation time. + #[prost(string, tag = "1")] + pub api_key: ::prost::alloc::string::String, +} +/// Retrieve the permissions of the current API key. +/// This endpoint is always accessible regardless of key permissions. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPermissionsRequest {} +/// The response `content` for the `GetPermissions` API, when HttpStatusCode is OK (200). +/// When HttpStatusCode is not OK (non-200), the response `content` contains a serialized `ErrorResponse`. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPermissionsResponse { + /// The list of endpoint names this key is permitted to access. + /// An empty list means the key is an admin key with access to all endpoints. + #[prost(string, repeated, tag = "1")] + pub endpoints: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} diff --git a/ldk-server-protos/src/endpoints.rs b/ldk-server-protos/src/endpoints.rs index 5766d52..c1c5715 100644 --- a/ldk-server-protos/src/endpoints.rs +++ b/ldk-server-protos/src/endpoints.rs @@ -35,3 +35,39 @@ pub const GRAPH_LIST_CHANNELS_PATH: &str = "GraphListChannels"; pub const GRAPH_GET_CHANNEL_PATH: &str = "GraphGetChannel"; pub const GRAPH_LIST_NODES_PATH: &str = "GraphListNodes"; pub const GRAPH_GET_NODE_PATH: &str = "GraphGetNode"; +pub const CREATE_API_KEY_PATH: &str = "CreateApiKey"; +pub const GET_PERMISSIONS_PATH: &str = "GetPermissions"; + +/// All valid endpoint names. Used to validate API key permissions. +pub const ALL_ENDPOINTS: [&str; 30] = [ + GET_NODE_INFO_PATH, + GET_BALANCES_PATH, + ONCHAIN_RECEIVE_PATH, + ONCHAIN_SEND_PATH, + BOLT11_RECEIVE_PATH, + BOLT11_SEND_PATH, + BOLT12_RECEIVE_PATH, + BOLT12_SEND_PATH, + OPEN_CHANNEL_PATH, + SPLICE_IN_PATH, + SPLICE_OUT_PATH, + CLOSE_CHANNEL_PATH, + FORCE_CLOSE_CHANNEL_PATH, + LIST_CHANNELS_PATH, + LIST_PAYMENTS_PATH, + LIST_FORWARDED_PAYMENTS_PATH, + UPDATE_CHANNEL_CONFIG_PATH, + GET_PAYMENT_DETAILS_PATH, + CONNECT_PEER_PATH, + DISCONNECT_PEER_PATH, + SPONTANEOUS_SEND_PATH, + SIGN_MESSAGE_PATH, + VERIFY_SIGNATURE_PATH, + EXPORT_PATHFINDING_SCORES_PATH, + GRAPH_LIST_CHANNELS_PATH, + GRAPH_GET_CHANNEL_PATH, + GRAPH_LIST_NODES_PATH, + GRAPH_GET_NODE_PATH, + CREATE_API_KEY_PATH, + GET_PERMISSIONS_PATH, +]; diff --git a/ldk-server/src/api_keys.rs b/ldk-server/src/api_keys.rs new file mode 100644 index 0000000..14018c4 --- /dev/null +++ b/ldk-server/src/api_keys.rs @@ -0,0 +1,700 @@ +// This file is Copyright its original authors, visible in version control +// history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license +// , at your option. +// You may not use this file except in accordance with one or both of these +// licenses. + +use std::collections::{HashMap, HashSet}; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; + +use hex::DisplayHex; +use ldk_node::bitcoin::hashes::{sha256, Hash}; + +use crate::api::error::LdkServerError; +use crate::api::error::LdkServerErrorCode::{AuthError, InvalidRequestError}; + +/// Computes the key_id for an API key: first 8 bytes of SHA256(key_hex), hex-encoded (16 chars). +pub fn compute_key_id(key_hex: &str) -> String { + let hash = sha256::Hash::hash(key_hex.as_bytes()); + hash[..8].to_lower_hex_string() +} + +/// Atomically writes contents to path by writing to a temp file then renaming. +/// Sets permissions to 0o400 (read-only for owner). +fn atomic_write(path: &Path, contents: &[u8]) -> io::Result<()> { + let tmp_path = path.with_file_name(format!(".tmp_{}", std::process::id(),)); + std::fs::write(&tmp_path, contents)?; + std::fs::set_permissions(&tmp_path, std::fs::Permissions::from_mode(0o400))?; + std::fs::rename(&tmp_path, path)?; + Ok(()) +} + +/// Stores and manages API keys with per-endpoint permissions. +/// Keys are stored as TOML files in the `api_keys/` directory. +/// The HashMap maps key_id -> (key_hex, endpoints). +/// An empty endpoints set means the key is an admin key with access to all endpoints. +pub struct ApiKeyStore { + keys: HashMap)>, + api_keys_dir: PathBuf, +} + +impl ApiKeyStore { + /// Loads all API key files from the given directory. + /// Each file should be a `.toml` file containing `key` and optionally `endpoints` fields. + pub fn load_from_dir(api_keys_dir: &Path) -> io::Result { + let mut keys = HashMap::new(); + + if api_keys_dir.exists() { + for entry in std::fs::read_dir(api_keys_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().is_none_or(|ext| ext != "toml") { + continue; + } + + let contents = std::fs::read_to_string(&path)?; + let parsed: toml::Value = toml::from_str(&contents).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to parse {}: {}", path.display(), e), + ) + })?; + + let key_hex = parsed + .get("key") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Missing 'key' field in {}", path.display()), + ) + })? + .to_string(); + + // Validate 64-char hex key + if key_hex.len() != 64 || !key_hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("Invalid key format in {}: must be 64 hex chars", path.display()), + )); + } + + let endpoints: HashSet = parsed + .get("endpoints") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let key_id = compute_key_id(&key_hex); + keys.insert(key_id, (key_hex, endpoints)); + } + } + + Ok(Self { keys, api_keys_dir: api_keys_dir.to_path_buf() }) + } + + /// Initializes the api_keys directory, migrating any legacy api_key file. + /// Returns the path to the api_keys directory. + pub fn init(storage_dir: &Path) -> io::Result { + let api_keys_dir = storage_dir.join("api_keys"); + std::fs::create_dir_all(&api_keys_dir)?; + + let admin_toml = api_keys_dir.join("admin.toml"); + + // TODO: Remove legacy migration once all deployments have been upgraded. + let legacy_path = storage_dir.join("api_key"); + if legacy_path.exists() && !admin_toml.exists() { + let key_bytes = std::fs::read(&legacy_path)?; + let key_hex = key_bytes.to_lower_hex_string(); + let toml_contents = format!("key = \"{key_hex}\"\nendpoints = [\"*\"]\n"); + atomic_write(&admin_toml, toml_contents.as_bytes())?; + return Ok(api_keys_dir); + } + + if !admin_toml.exists() { + let mut key_bytes = [0u8; 32]; + getrandom::getrandom(&mut key_bytes).map_err(io::Error::other)?; + let key_hex = key_bytes.to_lower_hex_string(); + let toml_contents = format!("key = \"{key_hex}\"\nendpoints = [\"*\"]\n"); + atomic_write(&admin_toml, toml_contents.as_bytes())?; + } + + Ok(api_keys_dir) + } + + /// Validates authentication and checks endpoint authorization. + /// Returns the set of permitted endpoints for this key. + pub fn validate_and_authorize( + &self, endpoint: &str, key_id: &str, timestamp: u64, hmac_hex: &str, body: &[u8], + ) -> Result, LdkServerError> { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + // O(1) lookup by key_id + let (key_hex, endpoints) = self + .keys + .get(key_id) + .ok_or_else(|| LdkServerError::new(AuthError, "Invalid credentials"))?; + + // Validate timestamp is within acceptable window + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| LdkServerError::new(AuthError, "System time error"))? + .as_secs(); + + let time_diff = now.abs_diff(timestamp); + if time_diff > super::service::AUTH_TIMESTAMP_TOLERANCE_SECS { + return Err(LdkServerError::new(AuthError, "Request timestamp expired")); + } + + // Compute expected HMAC: HMAC-SHA256(api_key, timestamp_bytes || body) + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let expected_hmac = Hmac::::from_engine(hmac_engine); + + // Compare HMACs (constant-time comparison via Hash equality) + let expected_hex = expected_hmac.to_string(); + if expected_hex != hmac_hex { + return Err(LdkServerError::new(AuthError, "Invalid credentials")); + } + + // GetPermissions is always allowed + if endpoint == ldk_server_protos::endpoints::GET_PERMISSIONS_PATH { + return Ok(endpoints.clone()); + } + + // Check endpoint permission — "*" means admin (all endpoints allowed) + if !endpoints.contains("*") && !endpoints.contains(endpoint) { + return Err(LdkServerError::new( + AuthError, + format!("Key not authorized for endpoint: {}", endpoint), + )); + } + + Ok(endpoints.clone()) + } + + /// Creates a new API key with the given name and endpoint permissions. + /// Returns the hex-encoded key. + pub fn create_key( + &mut self, name: &str, endpoints: Vec, + ) -> Result { + // Validate name: alphanumeric, hyphens, underscores only + if name.is_empty() || !name.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return Err(LdkServerError::new( + InvalidRequestError, + "Name must be non-empty and contain only alphanumeric characters, hyphens, or underscores", + )); + } + + if endpoints.is_empty() { + return Err(LdkServerError::new( + InvalidRequestError, + "Endpoints list must not be empty", + )); + } + + // Validate endpoint names + for ep in &endpoints { + if ep != "*" && !ldk_server_protos::endpoints::ALL_ENDPOINTS.contains(&ep.as_str()) { + return Err(LdkServerError::new( + InvalidRequestError, + format!("Unknown endpoint: '{}'", ep), + )); + } + } + + // Check for duplicate file name + let toml_path = self.api_keys_dir.join(format!("{}.toml", name)); + if toml_path.exists() { + return Err(LdkServerError::new( + InvalidRequestError, + format!("API key with name '{}' already exists", name), + )); + } + + // Generate 32-byte random key + let mut key_bytes = [0u8; 32]; + getrandom::getrandom(&mut key_bytes).map_err(|e| { + LdkServerError::new(InvalidRequestError, format!("Failed to generate key: {}", e)) + })?; + let key_hex = key_bytes.to_lower_hex_string(); + + // Build TOML content + let endpoints_toml: Vec = endpoints.iter().map(|e| format!("\"{}\"", e)).collect(); + let toml_contents = + format!("key = \"{}\"\nendpoints = [{}]\n", key_hex, endpoints_toml.join(", ")); + + // Write to disk + atomic_write(&toml_path, toml_contents.as_bytes()).map_err(|e| { + LdkServerError::new(InvalidRequestError, format!("Failed to write API key file: {}", e)) + })?; + + // Update in-memory store + let key_id = compute_key_id(&key_hex); + let endpoint_set: HashSet = endpoints.into_iter().collect(); + self.keys.insert(key_id, (key_hex.clone(), endpoint_set)); + + Ok(key_hex) + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::sync::atomic::{AtomicU32, Ordering}; + + use super::*; + + static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + + fn test_dir(name: &str) -> std::path::PathBuf { + let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("ldk_server_test_{}_{}", name, id)); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn test_compute_key_id() { + let key_hex = "a".repeat(64); + let key_id = compute_key_id(&key_hex); + // Should be 16 hex chars + assert_eq!(key_id.len(), 16); + assert!(key_id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_compute_key_id_deterministic() { + let key_hex = "b".repeat(64); + assert_eq!(compute_key_id(&key_hex), compute_key_id(&key_hex)); + } + + #[test] + fn test_compute_key_id_different_keys() { + let key_a = "a".repeat(64); + let key_b = "b".repeat(64); + assert_ne!(compute_key_id(&key_a), compute_key_id(&key_b)); + } + + #[test] + fn test_atomic_write() { + let dir = test_dir("atomic_write"); + let path = dir.join("test_file"); + atomic_write(&path, b"hello").unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "hello"); + let perms = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(perms & 0o777, 0o400); + } + + #[test] + fn test_load_from_dir_empty() { + let dir = test_dir("load_empty"); + let store = ApiKeyStore::load_from_dir(&dir).unwrap(); + assert!(store.keys.is_empty()); + } + + #[test] + fn test_load_from_dir_with_key() { + let dir = test_dir("load_with_key"); + let key_hex = "ab".repeat(32); + let toml_contents = format!("key = \"{key_hex}\"\nendpoints = [\"GetNodeInfo\"]\n"); + std::fs::write(dir.join("test.toml"), &toml_contents).unwrap(); + + let store = ApiKeyStore::load_from_dir(&dir).unwrap(); + assert_eq!(store.keys.len(), 1); + let key_id = compute_key_id(&key_hex); + let (stored_key, endpoints) = store.keys.get(&key_id).unwrap(); + assert_eq!(stored_key, &key_hex); + assert!(endpoints.contains("GetNodeInfo")); + } + + #[test] + fn test_load_from_dir_admin_key() { + let dir = test_dir("load_admin"); + let key_hex = "cd".repeat(32); + let toml_contents = format!("key = \"{key_hex}\"\nendpoints = [\"*\"]\n"); + std::fs::write(dir.join("admin.toml"), &toml_contents).unwrap(); + + let store = ApiKeyStore::load_from_dir(&dir).unwrap(); + assert_eq!(store.keys.len(), 1); + let key_id = compute_key_id(&key_hex); + let (_, endpoints) = store.keys.get(&key_id).unwrap(); + assert!(endpoints.contains("*")); + } + + #[test] + fn test_load_from_dir_invalid_key_length() { + let dir = test_dir("invalid_len"); + let toml_contents = "key = \"tooshort\"\nendpoints = []\n"; + std::fs::write(dir.join("bad.toml"), toml_contents).unwrap(); + + let result = ApiKeyStore::load_from_dir(&dir); + assert!(result.is_err()); + } + + #[test] + fn test_init_creates_admin_key() { + let dir = test_dir("init_creates"); + let api_keys_dir = ApiKeyStore::init(&dir).unwrap(); + assert!(api_keys_dir.join("admin.toml").exists()); + } + + #[test] + fn test_init_migrates_legacy_key() { + let dir = test_dir("init_migrates"); + let legacy_bytes: [u8; 32] = [0xab; 32]; + std::fs::write(dir.join("api_key"), legacy_bytes).unwrap(); + + let api_keys_dir = ApiKeyStore::init(&dir).unwrap(); + let admin_toml = std::fs::read_to_string(api_keys_dir.join("admin.toml")).unwrap(); + assert!(admin_toml.contains("key = \"")); + assert!(admin_toml.contains("abababab")); // first few bytes hex + } + + #[test] + fn test_init_does_not_overwrite_existing_admin() { + let dir = test_dir("init_no_overwrite"); + let api_keys_dir_path = dir.join("api_keys"); + std::fs::create_dir_all(&api_keys_dir_path).unwrap(); + let key_hex = "ff".repeat(32); + let toml_contents = format!("key = \"{key_hex}\"\nendpoints = [\"*\"]\n"); + std::fs::write(api_keys_dir_path.join("admin.toml"), &toml_contents).unwrap(); + + let api_keys_dir = ApiKeyStore::init(&dir).unwrap(); + let admin_toml = std::fs::read_to_string(api_keys_dir.join("admin.toml")).unwrap(); + assert!(admin_toml.contains(&key_hex)); + } + + #[test] + fn test_validate_and_authorize_admin() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "ab".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), HashSet::from(["*".to_string()]))); + + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let body = b"test body"; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac = Hmac::::from_engine(hmac_engine); + + let result = store.validate_and_authorize( + "GetNodeInfo", + &key_id, + timestamp, + &hmac.to_string(), + body, + ); + assert!(result.is_ok()); + assert!(result.unwrap().contains("*")); + } + + #[test] + fn test_validate_and_authorize_restricted_allowed() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "cd".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut endpoints = HashSet::new(); + endpoints.insert("GetNodeInfo".to_string()); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), endpoints)); + + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let body = b""; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac = Hmac::::from_engine(hmac_engine); + + let result = store.validate_and_authorize( + "GetNodeInfo", + &key_id, + timestamp, + &hmac.to_string(), + body, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_and_authorize_restricted_denied() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "cd".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut endpoints = HashSet::new(); + endpoints.insert("GetNodeInfo".to_string()); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), endpoints)); + + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let body = b""; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac = Hmac::::from_engine(hmac_engine); + + let result = store.validate_and_authorize( + "OnchainSend", + &key_id, + timestamp, + &hmac.to_string(), + body, + ); + assert!(result.is_err()); + } + + #[test] + fn test_validate_and_authorize_get_permissions_always_allowed() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "cd".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut endpoints = HashSet::new(); + endpoints.insert("GetNodeInfo".to_string()); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), endpoints)); + + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let body = b""; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac = Hmac::::from_engine(hmac_engine); + + let result = store.validate_and_authorize( + "GetPermissions", + &key_id, + timestamp, + &hmac.to_string(), + body, + ); + assert!(result.is_ok()); + } + + #[test] + fn test_validate_and_authorize_invalid_key_id() { + let store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: PathBuf::new() }; + + let result = store.validate_and_authorize( + "GetNodeInfo", + "0000000000000000", + 0, + &"00".repeat(32), + b"", + ); + assert!(result.is_err()); + } + + #[test] + fn test_validate_and_authorize_expired_timestamp() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "ab".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), HashSet::new())); + + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + - 600; // 10 minutes ago + let body = b""; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(body); + let hmac = Hmac::::from_engine(hmac_engine); + + let result = store.validate_and_authorize( + "GetNodeInfo", + &key_id, + timestamp, + &hmac.to_string(), + body, + ); + assert!(result.is_err()); + } + + #[test] + fn test_create_key() { + let dir = test_dir("create_key"); + let mut store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: dir.to_path_buf() }; + + let key_hex = store.create_key("test-key", vec!["GetNodeInfo".to_string()]).unwrap(); + assert_eq!(key_hex.len(), 64); + assert!(key_hex.chars().all(|c| c.is_ascii_hexdigit())); + + // Check file was written + assert!(dir.join("test-key.toml").exists()); + + // Check in-memory store updated + let key_id = compute_key_id(&key_hex); + assert!(store.keys.contains_key(&key_id)); + } + + #[test] + fn test_create_key_invalid_name() { + let dir = test_dir("invalid_name"); + let mut store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: dir.to_path_buf() }; + + let ep = vec!["GetNodeInfo".to_string()]; + + // Empty name + assert!(store.create_key("", ep.clone()).is_err()); + // Spaces + assert!(store.create_key("bad name", ep.clone()).is_err()); + // Path traversal + assert!(store.create_key("../etc/passwd", ep.clone()).is_err()); + assert!(store.create_key("bad/name", ep.clone()).is_err()); + // Leading dot (hidden files / temp file collision) + assert!(store.create_key(".hidden", ep.clone()).is_err()); + assert!(store.create_key(".tmp_123", ep.clone()).is_err()); + // Dots in general (could fake file extensions) + assert!(store.create_key("foo.toml", ep.clone()).is_err()); + assert!(store.create_key("key.bak", ep.clone()).is_err()); + // Wildcard / glob characters + assert!(store.create_key("*", ep.clone()).is_err()); + assert!(store.create_key("key*", ep.clone()).is_err()); + // Null byte + assert!(store.create_key("key\0name", ep.clone()).is_err()); + // Valid names should work + assert!(store.create_key("good-name", ep.clone()).is_ok()); + assert!(store.create_key("good_name_2", ep).is_ok()); + } + + #[test] + fn test_create_key_duplicate() { + let dir = test_dir("duplicate"); + let mut store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: dir.to_path_buf() }; + + store.create_key("my-key", vec!["GetNodeInfo".to_string()]).unwrap(); + // Duplicate should fail + assert!(store.create_key("my-key", vec!["GetNodeInfo".to_string()]).is_err()); + } + + #[test] + fn test_validate_and_authorize_tampered_body() { + use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; + use ldk_node::bitcoin::hashes::HashEngine; + + let key_hex = "ab".repeat(32); + let key_id = compute_key_id(&key_hex); + let mut keys = HashMap::new(); + keys.insert(key_id.clone(), (key_hex.clone(), HashSet::from(["*".to_string()]))); + let store = ApiKeyStore { keys, api_keys_dir: PathBuf::new() }; + + let timestamp = + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let original_body = b"original body"; + let tampered_body = b"tampered body"; + + let mut hmac_engine: HmacEngine = HmacEngine::new(key_hex.as_bytes()); + hmac_engine.input(×tamp.to_be_bytes()); + hmac_engine.input(original_body); + let hmac = Hmac::::from_engine(hmac_engine); + + // Valid key_id but body was tampered — should fail + let result = store.validate_and_authorize( + "GetNodeInfo", + &key_id, + timestamp, + &hmac.to_string(), + tampered_body, + ); + assert!(result.is_err()); + } + + #[test] + fn test_load_from_dir_multiple_keys() { + let dir = test_dir("load_multiple"); + let admin_key = "aa".repeat(32); + std::fs::write( + dir.join("admin.toml"), + format!("key = \"{admin_key}\"\nendpoints = [\"*\"]\n"), + ) + .unwrap(); + let readonly_key = "bb".repeat(32); + std::fs::write( + dir.join("readonly.toml"), + format!("key = \"{readonly_key}\"\nendpoints = [\"GetNodeInfo\"]\n"), + ) + .unwrap(); + // Non-toml file should be ignored + std::fs::write(dir.join("README.txt"), "not a key").unwrap(); + + let store = ApiKeyStore::load_from_dir(&dir).unwrap(); + assert_eq!(store.keys.len(), 2); + + let admin_id = compute_key_id(&admin_key); + let readonly_id = compute_key_id(&readonly_key); + assert!(store.keys.get(&admin_id).unwrap().1.contains("*")); + assert!(store.keys.get(&readonly_id).unwrap().1.contains("GetNodeInfo")); + } + + #[test] + fn test_create_key_empty_endpoints_rejected() { + let dir = test_dir("empty_endpoints"); + let mut store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: dir.to_path_buf() }; + + let result = store.create_key("test-key", vec![]); + assert!(result.is_err()); + } + + #[test] + fn test_create_key_invalid_endpoint_rejected() { + let dir = test_dir("invalid_endpoint"); + let mut store = ApiKeyStore { keys: HashMap::new(), api_keys_dir: dir.to_path_buf() }; + + // Unknown endpoint should fail + let result = store.create_key("test-key", vec!["FakeEndpoint".to_string()]); + assert!(result.is_err()); + assert!(result.unwrap_err().message.contains("Unknown endpoint")); + + // Typo in endpoint name should fail + let result = store.create_key("test-key", vec!["GetNodeinfo".to_string()]); + assert!(result.is_err()); + + // Valid endpoint should work + let result = store.create_key("test-key", vec!["GetNodeInfo".to_string()]); + assert!(result.is_ok()); + + // Wildcard should work + let result = store.create_key("admin-key", vec!["*".to_string()]); + assert!(result.is_ok()); + } +} diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 0b4460c..f7427a4 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -8,14 +8,13 @@ // licenses. mod api; +mod api_keys; mod io; mod service; mod util; -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; @@ -30,12 +29,13 @@ use ldk_node::{Builder, Event, Node}; use ldk_server_protos::events; use ldk_server_protos::events::{event_envelope, EventEnvelope}; use ldk_server_protos::types::Payment; -use log::{debug, error, info}; +use log::{error, info}; use prost::Message; use tokio::net::TcpListener; use tokio::select; use tokio::signal::unix::SignalKind; +use crate::api_keys::ApiKeyStore; use crate::io::events::event_publisher::EventPublisher; use crate::io::events::get_event_name; #[cfg(feature = "events-rabbitmq")] @@ -53,8 +53,6 @@ use crate::util::logger::ServerLogger; use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto}; use crate::util::tls::get_or_generate_tls_config; -const API_KEY_FILE: &str = "api_key"; - pub fn get_default_data_dir() -> Option { #[cfg(target_os = "macos")] { @@ -128,10 +126,18 @@ fn main() { }, }; - let api_key = match load_or_generate_api_key(&network_dir) { - Ok(key) => key, + let api_keys_dir = match ApiKeyStore::init(&network_dir) { + Ok(dir) => dir, + Err(e) => { + eprintln!("Failed to initialize API keys: {e}"); + std::process::exit(-1); + }, + }; + + let api_key_store = match ApiKeyStore::load_from_dir(&api_keys_dir) { + Ok(store) => Arc::new(RwLock::new(store)), Err(e) => { - eprintln!("Failed to load or generate API key: {e}"); + eprintln!("Failed to load API keys: {e}"); std::process::exit(-1); }, }; @@ -415,7 +421,7 @@ fn main() { res = rest_svc_listener.accept() => { match res { Ok((stream, _)) => { - let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), api_key.clone()); + let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), Arc::clone(&api_key_store)); let acceptor = tls_acceptor.clone(); runtime.spawn(async move { match acceptor.accept(stream).await { @@ -500,31 +506,3 @@ fn upsert_payment_details( }, } } - -/// Loads the API key from a file, or generates a new one if it doesn't exist. -/// The API key file is stored with 0400 permissions (read-only for owner). -fn load_or_generate_api_key(storage_dir: &Path) -> std::io::Result { - let api_key_path = storage_dir.join(API_KEY_FILE); - - if api_key_path.exists() { - let key_bytes = fs::read(&api_key_path)?; - Ok(key_bytes.to_lower_hex_string()) - } else { - // Ensure the storage directory exists - fs::create_dir_all(storage_dir)?; - - // Generate a 32-byte random API key - let mut key_bytes = [0u8; 32]; - getrandom::getrandom(&mut key_bytes).map_err(std::io::Error::other)?; - - // Write the raw bytes to the file - fs::write(&api_key_path, key_bytes)?; - - // Set permissions to 0400 (read-only for owner) - let permissions = fs::Permissions::from_mode(0o400); - fs::set_permissions(&api_key_path, permissions)?; - - debug!("Generated new API key at {}", api_key_path.display()); - Ok(key_bytes.to_lower_hex_string()) - } -} diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 41950ac..9098f6e 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -7,25 +7,28 @@ // You may not use this file except in accordance with one or both of these // licenses. +use std::collections::HashSet; use std::future::Future; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use http_body_util::{BodyExt, Full, Limited}; use hyper::body::{Bytes, Incoming}; use hyper::service::Service; use hyper::{Request, Response, StatusCode}; -use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; -use ldk_node::bitcoin::hashes::{sha256, Hash, HashEngine}; use ldk_node::Node; +use ldk_server_protos::api::{ + CreateApiKeyRequest, CreateApiKeyResponse, GetPermissionsRequest, GetPermissionsResponse, +}; use ldk_server_protos::endpoints::{ BOLT11_RECEIVE_PATH, BOLT11_SEND_PATH, BOLT12_RECEIVE_PATH, BOLT12_SEND_PATH, - CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, - GRAPH_GET_CHANNEL_PATH, GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, - LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, - ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, - SPONTANEOUS_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, + CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, CREATE_API_KEY_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, + GET_NODE_INFO_PATH, GET_PAYMENT_DETAILS_PATH, GET_PERMISSIONS_PATH, GRAPH_GET_CHANNEL_PATH, + GRAPH_GET_NODE_PATH, GRAPH_LIST_CHANNELS_PATH, GRAPH_LIST_NODES_PATH, LIST_CHANNELS_PATH, + LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, + OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, + UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use prost::Message; @@ -57,6 +60,7 @@ use crate::api::splice_channel::{handle_splice_in_request, handle_splice_out_req use crate::api::spontaneous_send::handle_spontaneous_send_request; use crate::api::update_channel_config::handle_update_channel_config_request; use crate::api::verify_signature::handle_verify_signature_request; +use crate::api_keys::ApiKeyStore; use crate::io::persist::paginated_kv_store::PaginatedKVStore; use crate::util::proto_adapter::to_error_response; @@ -68,19 +72,20 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; pub struct NodeService { node: Arc, paginated_kv_store: Arc, - api_key: String, + api_key_store: Arc>, } impl NodeService { pub(crate) fn new( - node: Arc, paginated_kv_store: Arc, api_key: String, + node: Arc, paginated_kv_store: Arc, + api_key_store: Arc>, ) -> Self { - Self { node, paginated_kv_store, api_key } + Self { node, paginated_kv_store, api_key_store } } } // Maximum allowed time difference between client timestamp and server time (1 minute) -const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; +pub(crate) const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; #[derive(Debug, Clone)] pub(crate) struct AuthParams { @@ -89,16 +94,6 @@ pub(crate) struct AuthParams { hmac_hex: String, } -/// Computes the key_id for an API key: first 8 bytes of SHA256(api_key), hex-encoded (16 chars). -fn compute_key_id(api_key: &str) -> String { - let hash = sha256::Hash::hash(api_key.as_bytes()); - hash[..8].iter().fold(String::with_capacity(16), |mut acc, b| { - use std::fmt::Write; - let _ = write!(acc, "{:02x}", b); - acc - }) -} - /// Extracts authentication parameters from request headers. /// Returns (key_id, timestamp, hmac_hex) if valid format, or error. fn extract_auth_params(req: &Request) -> Result { @@ -139,40 +134,30 @@ fn extract_auth_params(req: &Request) -> Result Result<(), LdkServerError> { - // Verify the key_id matches the api_key - let expected_key_id = compute_key_id(api_key); - if key_id != expected_key_id { - return Err(LdkServerError::new(AuthError, "Invalid credentials")); - } - - // Validate timestamp is within acceptable window - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| LdkServerError::new(AuthError, "System time error"))? - .as_secs(); +fn handle_get_permissions_request( + _context: Context, _request: GetPermissionsRequest, endpoints: HashSet, +) -> Result { + // Sort for deterministic response ordering since endpoints are stored in a HashSet. + let mut endpoints: Vec = endpoints.into_iter().collect(); + endpoints.sort(); + Ok(GetPermissionsResponse { endpoints }) +} - let time_diff = now.abs_diff(timestamp); - if time_diff > AUTH_TIMESTAMP_TOLERANCE_SECS { - return Err(LdkServerError::new(AuthError, "Request timestamp expired")); +fn handle_create_api_key_request( + _context: Context, request: CreateApiKeyRequest, endpoints: HashSet, + api_key_store: Arc>, +) -> Result { + // Only admin keys (with "*" wildcard) can create new keys + if !endpoints.contains("*") { + return Err(LdkServerError::new(AuthError, "Only admin keys can create new API keys")); } - // Compute expected HMAC: HMAC-SHA256(api_key, timestamp_bytes || body) - let mut hmac_engine: HmacEngine = HmacEngine::new(api_key.as_bytes()); - hmac_engine.input(×tamp.to_be_bytes()); - hmac_engine.input(body); - let expected_hmac = Hmac::::from_engine(hmac_engine); + let mut store = api_key_store.write().map_err(|_| { + LdkServerError::new(InvalidRequestError, "Failed to acquire API key store lock") + })?; - // Compare HMACs (constant-time comparison via Hash equality) - let expected_hex = expected_hmac.to_string(); - if expected_hex != provided_hmac_hex { - return Err(LdkServerError::new(AuthError, "Invalid credentials")); - } - - Ok(()) + let api_key = store.create_key(&request.name, request.endpoints)?; + Ok(CreateApiKeyResponse { api_key }) } pub(crate) struct Context { @@ -180,6 +165,12 @@ pub(crate) struct Context { pub(crate) paginated_kv_store: Arc, } +macro_rules! route { + ($context:expr, $req:expr, $auth_params:expr, $api_key_store:expr, $endpoint:expr, $handler:expr) => { + Box::pin(handle_request($context, $req, $auth_params, $api_key_store, $endpoint, $handler)) + }; +} + impl Service> for NodeService { type Response = Response>; type Error = hyper::Error; @@ -205,194 +196,296 @@ impl Service> for NodeService { node: Arc::clone(&self.node), paginated_kv_store: Arc::clone(&self.paginated_kv_store), }; - let api_key = self.api_key.clone(); + let api_key_store = Arc::clone(&self.api_key_store); // Exclude '/' from path pattern matching. match &req.uri().path()[1..] { - GET_NODE_INFO_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_get_node_info_request, - )), - GET_BALANCES_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_get_balances_request, - )), - ONCHAIN_RECEIVE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_onchain_receive_request, - )), - ONCHAIN_SEND_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_onchain_send_request, - )), - BOLT11_RECEIVE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_bolt11_receive_request, - )), - BOLT11_SEND_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_bolt11_send_request, - )), - BOLT12_RECEIVE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_bolt12_receive_request, - )), - BOLT12_SEND_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_bolt12_send_request, - )), + GET_NODE_INFO_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GET_NODE_INFO_PATH, + handle_get_node_info_request + ) + }, + GET_BALANCES_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GET_BALANCES_PATH, + handle_get_balances_request + ) + }, + ONCHAIN_RECEIVE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + ONCHAIN_RECEIVE_PATH, + handle_onchain_receive_request + ) + }, + ONCHAIN_SEND_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + ONCHAIN_SEND_PATH, + handle_onchain_send_request + ) + }, + BOLT11_RECEIVE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + BOLT11_RECEIVE_PATH, + handle_bolt11_receive_request + ) + }, + BOLT11_SEND_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + BOLT11_SEND_PATH, + handle_bolt11_send_request + ) + }, + BOLT12_RECEIVE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + BOLT12_RECEIVE_PATH, + handle_bolt12_receive_request + ) + }, + BOLT12_SEND_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + BOLT12_SEND_PATH, + handle_bolt12_send_request + ) + }, OPEN_CHANNEL_PATH => { - Box::pin(handle_request(context, req, auth_params, api_key, handle_open_channel)) - }, - SPLICE_IN_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_splice_in_request, - )), - SPLICE_OUT_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_splice_out_request, - )), - CLOSE_CHANNEL_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_close_channel_request, - )), - FORCE_CLOSE_CHANNEL_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_force_close_channel_request, - )), - LIST_CHANNELS_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_list_channels_request, - )), - UPDATE_CHANNEL_CONFIG_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_update_channel_config_request, - )), - GET_PAYMENT_DETAILS_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_get_payment_details_request, - )), - LIST_PAYMENTS_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_list_payments_request, - )), - LIST_FORWARDED_PAYMENTS_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_list_forwarded_payments_request, - )), + route!( + context, + req, + auth_params, + api_key_store, + OPEN_CHANNEL_PATH, + handle_open_channel + ) + }, + SPLICE_IN_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + SPLICE_IN_PATH, + handle_splice_in_request + ) + }, + SPLICE_OUT_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + SPLICE_OUT_PATH, + handle_splice_out_request + ) + }, + CLOSE_CHANNEL_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + CLOSE_CHANNEL_PATH, + handle_close_channel_request + ) + }, + FORCE_CLOSE_CHANNEL_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + FORCE_CLOSE_CHANNEL_PATH, + handle_force_close_channel_request + ) + }, + LIST_CHANNELS_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + LIST_CHANNELS_PATH, + handle_list_channels_request + ) + }, + UPDATE_CHANNEL_CONFIG_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + UPDATE_CHANNEL_CONFIG_PATH, + handle_update_channel_config_request + ) + }, + GET_PAYMENT_DETAILS_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GET_PAYMENT_DETAILS_PATH, + handle_get_payment_details_request + ) + }, + LIST_PAYMENTS_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + LIST_PAYMENTS_PATH, + handle_list_payments_request + ) + }, + LIST_FORWARDED_PAYMENTS_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + LIST_FORWARDED_PAYMENTS_PATH, + handle_list_forwarded_payments_request + ) + }, CONNECT_PEER_PATH => { - Box::pin(handle_request(context, req, auth_params, api_key, handle_connect_peer)) + route!( + context, + req, + auth_params, + api_key_store, + CONNECT_PEER_PATH, + handle_connect_peer + ) }, DISCONNECT_PEER_PATH => { - Box::pin(handle_request(context, req, auth_params, api_key, handle_disconnect_peer)) - }, - SPONTANEOUS_SEND_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_spontaneous_send_request, - )), - SIGN_MESSAGE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_sign_message_request, - )), - VERIFY_SIGNATURE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_verify_signature_request, - )), - EXPORT_PATHFINDING_SCORES_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_export_pathfinding_scores_request, - )), - GRAPH_LIST_CHANNELS_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_graph_list_channels_request, - )), - GRAPH_GET_CHANNEL_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_graph_get_channel_request, - )), - GRAPH_LIST_NODES_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_graph_list_nodes_request, - )), - GRAPH_GET_NODE_PATH => Box::pin(handle_request( - context, - req, - auth_params, - api_key, - handle_graph_get_node_request, - )), + route!( + context, + req, + auth_params, + api_key_store, + DISCONNECT_PEER_PATH, + handle_disconnect_peer + ) + }, + SPONTANEOUS_SEND_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + SPONTANEOUS_SEND_PATH, + handle_spontaneous_send_request + ) + }, + SIGN_MESSAGE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + SIGN_MESSAGE_PATH, + handle_sign_message_request + ) + }, + VERIFY_SIGNATURE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + VERIFY_SIGNATURE_PATH, + handle_verify_signature_request + ) + }, + EXPORT_PATHFINDING_SCORES_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + EXPORT_PATHFINDING_SCORES_PATH, + handle_export_pathfinding_scores_request + ) + }, + GET_PERMISSIONS_PATH => { + Box::pin(handle_permissions_request(context, req, auth_params, api_key_store)) + }, + CREATE_API_KEY_PATH => { + Box::pin(handle_create_key_request(context, req, auth_params, api_key_store)) + }, + GRAPH_LIST_CHANNELS_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GRAPH_LIST_CHANNELS_PATH, + handle_graph_list_channels_request + ) + }, + GRAPH_GET_CHANNEL_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GRAPH_GET_CHANNEL_PATH, + handle_graph_get_channel_request + ) + }, + GRAPH_LIST_NODES_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GRAPH_LIST_NODES_PATH, + handle_graph_list_nodes_request + ) + }, + GRAPH_GET_NODE_PATH => { + route!( + context, + req, + auth_params, + api_key_store, + GRAPH_GET_NODE_PATH, + handle_graph_get_node_request + ) + }, path => { let error = format!("Unknown request: {}", path).into_bytes(); Box::pin(async { @@ -412,8 +505,8 @@ async fn handle_request< R: Message, F: Fn(Context, T) -> Result, >( - context: Context, request: Request, auth_params: AuthParams, api_key: String, - handler: F, + context: Context, request: Request, auth_params: AuthParams, + api_key_store: Arc>, endpoint: &str, handler: F, ) -> Result<>>::Response, hyper::Error> { // Limit the size of the request body to prevent abuse let limited_body = Limited::new(request.into_body(), MAX_BODY_SIZE); @@ -432,14 +525,24 @@ async fn handle_request< }, }; - // Validate HMAC authentication with the request body - if let Err(e) = validate_hmac_auth( - &auth_params.key_id, - auth_params.timestamp, - &auth_params.hmac_hex, - &bytes, - &api_key, - ) { + // Validate HMAC authentication and endpoint authorization + let auth_result = { + let store = api_key_store.read().map_err(|_| { + // hyper::Error can't be constructed directly; return an auth error response instead + }); + match store { + Ok(store) => store.validate_and_authorize( + endpoint, + &auth_params.key_id, + auth_params.timestamp, + &auth_params.hmac_hex, + &bytes, + ), + Err(_) => Err(LdkServerError::new(AuthError, "Failed to acquire API key store lock")), + } + }; + + if let Err(e) = auth_result { let (error_response, status_code) = to_error_response(e); return Ok(Response::builder() .status(status_code) @@ -475,17 +578,163 @@ async fn handle_request< } } +async fn handle_permissions_request( + context: Context, request: Request, auth_params: AuthParams, + api_key_store: Arc>, +) -> Result<>>::Response, hyper::Error> { + let limited_body = Limited::new(request.into_body(), MAX_BODY_SIZE); + let bytes = match limited_body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + let (error_response, status_code) = to_error_response(LdkServerError::new( + InvalidRequestError, + "Request body too large or failed to read.", + )); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + }; + + let endpoints = { + let store = match api_key_store.read() { + Ok(s) => s, + Err(_) => { + let (error_response, status_code) = to_error_response(LdkServerError::new( + AuthError, + "Failed to acquire API key store lock", + )); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + }; + match store.validate_and_authorize( + GET_PERMISSIONS_PATH, + &auth_params.key_id, + auth_params.timestamp, + &auth_params.hmac_hex, + &bytes, + ) { + Ok(endpoints) => endpoints, + Err(e) => { + let (error_response, status_code) = to_error_response(e); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + } + }; + + match GetPermissionsRequest::decode(bytes) { + Ok(req) => match handle_get_permissions_request(context, req, endpoints) { + Ok(response) => Ok(Response::builder() + .body(Full::new(Bytes::from(response.encode_to_vec()))) + .unwrap()), + Err(e) => { + let (error_response, status_code) = to_error_response(e); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()) + }, + }, + Err(_) => { + let (error_response, status_code) = + to_error_response(LdkServerError::new(InvalidRequestError, "Malformed request.")); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()) + }, + } +} + +async fn handle_create_key_request( + context: Context, request: Request, auth_params: AuthParams, + api_key_store: Arc>, +) -> Result<>>::Response, hyper::Error> { + let limited_body = Limited::new(request.into_body(), MAX_BODY_SIZE); + let bytes = match limited_body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + let (error_response, status_code) = to_error_response(LdkServerError::new( + InvalidRequestError, + "Request body too large or failed to read.", + )); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + }; + + let endpoints = { + let store = match api_key_store.read() { + Ok(s) => s, + Err(_) => { + let (error_response, status_code) = to_error_response(LdkServerError::new( + AuthError, + "Failed to acquire API key store lock", + )); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + }; + match store.validate_and_authorize( + CREATE_API_KEY_PATH, + &auth_params.key_id, + auth_params.timestamp, + &auth_params.hmac_hex, + &bytes, + ) { + Ok(endpoints) => endpoints, + Err(e) => { + let (error_response, status_code) = to_error_response(e); + return Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()); + }, + } + }; + + match CreateApiKeyRequest::decode(bytes) { + Ok(req) => { + match handle_create_api_key_request(context, req, endpoints, Arc::clone(&api_key_store)) + { + Ok(response) => Ok(Response::builder() + .body(Full::new(Bytes::from(response.encode_to_vec()))) + .unwrap()), + Err(e) => { + let (error_response, status_code) = to_error_response(e); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()) + }, + } + }, + Err(_) => { + let (error_response, status_code) = + to_error_response(LdkServerError::new(InvalidRequestError, "Malformed request.")); + Ok(Response::builder() + .status(status_code) + .body(Full::new(Bytes::from(error_response.encode_to_vec()))) + .unwrap()) + }, + } +} + #[cfg(test)] mod tests { use super::*; - fn compute_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> String { - let mut hmac_engine: HmacEngine = HmacEngine::new(api_key.as_bytes()); - hmac_engine.input(×tamp.to_be_bytes()); - hmac_engine.input(body); - Hmac::::from_engine(hmac_engine).to_string() - } - fn create_test_request(auth_header: Option) -> Request<()> { let mut builder = Request::builder(); if let Some(header) = auth_header { @@ -530,79 +779,4 @@ mod tests { assert!(result.is_err()); assert_eq!(result.unwrap_err().error_code, AuthError); } - - #[test] - fn test_validate_hmac_auth_success() { - let api_key = "test_api_key".to_string(); - let key_id = compute_key_id(&api_key); - let body = b"test request body"; - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let hmac = compute_hmac(&api_key, timestamp, body); - - let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); - assert!(result.is_ok()); - } - - #[test] - fn test_validate_hmac_auth_wrong_key() { - let api_key = "test_api_key".to_string(); - let key_id = compute_key_id(&api_key); - let body = b"test request body"; - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - // Compute HMAC with wrong key - let hmac = compute_hmac("wrong_key", timestamp, body); - - let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, AuthError); - } - - #[test] - fn test_validate_hmac_auth_wrong_key_id() { - let api_key = "test_api_key".to_string(); - let wrong_key_id = "0000000000000000"; - let body = b"test request body"; - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let hmac = compute_hmac(&api_key, timestamp, body); - - let result = validate_hmac_auth(wrong_key_id, timestamp, &hmac, body, &api_key); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, AuthError); - } - - #[test] - fn test_validate_hmac_auth_expired_timestamp() { - let api_key = "test_api_key".to_string(); - let key_id = compute_key_id(&api_key); - let body = b"test request body"; - // Use a timestamp from 10 minutes ago - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() - - 600; - let hmac = compute_hmac(&api_key, timestamp, body); - - let result = validate_hmac_auth(&key_id, timestamp, &hmac, body, &api_key); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, AuthError); - } - - #[test] - fn test_validate_hmac_auth_tampered_body() { - let api_key = "test_api_key".to_string(); - let key_id = compute_key_id(&api_key); - let original_body = b"test request body"; - let tampered_body = b"tampered body"; - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - // Compute HMAC with original body - let hmac = compute_hmac(&api_key, timestamp, original_body); - - // Try to validate with tampered body - let result = validate_hmac_auth(&key_id, timestamp, &hmac, tampered_body, &api_key); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, AuthError); - } }