Skip to content

Commit 583437a

Browse files
benthecarmanclaude
andcommitted
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 `<network_dir>/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<RwLock> 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) <noreply@anthropic.com>
1 parent 451bf86 commit 583437a

File tree

10 files changed

+1574
-405
lines changed

10 files changed

+1574
-405
lines changed

e2e-tests/src/lib.rs

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ use std::process::{Child, Command, Stdio};
1414
use std::time::Duration;
1515

1616
use corepc_node::Node;
17-
use hex_conservative::DisplayHex;
1817
use ldk_server_client::client::LdkServerClient;
1918
use ldk_server_client::ldk_server_protos::api::{GetNodeInfoRequest, GetNodeInfoResponse};
2019
use ldk_server_protos::api::{
@@ -176,17 +175,21 @@ client_trusts_lsp = true
176175
}
177176
});
178177

179-
// Wait for the api_key and tls.crt files to appear in the network subdir
178+
// Wait for the api_keys/admin.toml and tls.crt files to appear
180179
let network_dir = storage_dir.join("regtest");
181-
let api_key_path = network_dir.join("api_key");
180+
let admin_toml_path = network_dir.join("api_keys").join("admin.toml");
182181
let tls_cert_path = storage_dir.join("tls.crt");
183182

184-
wait_for_file(&api_key_path, Duration::from_secs(30)).await;
183+
wait_for_file(&admin_toml_path, Duration::from_secs(30)).await;
185184
wait_for_file(&tls_cert_path, Duration::from_secs(30)).await;
186185

187-
// Read the API key (raw bytes -> hex)
188-
let api_key_bytes = std::fs::read(&api_key_path).unwrap();
189-
let api_key = api_key_bytes.to_lower_hex_string();
186+
// Read the API key from admin.toml
187+
let admin_toml_contents = std::fs::read_to_string(&admin_toml_path).unwrap();
188+
let api_key = admin_toml_contents
189+
.lines()
190+
.find_map(|line| line.strip_prefix("key = \"")?.strip_suffix('"'))
191+
.unwrap()
192+
.to_string();
190193

191194
// Read TLS cert
192195
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 {
311314
.unwrap_or_else(|e| panic!("Failed to parse CLI output as JSON: {e}\nOutput: {stdout}"))
312315
}
313316

317+
/// Create a client with a specific API key.
318+
pub fn make_client(handle: &LdkServerHandle, api_key: &str) -> LdkServerClient {
319+
let tls_cert_pem = std::fs::read(&handle.tls_cert_path).unwrap();
320+
LdkServerClient::new(handle.base_url(), api_key.to_string(), &tls_cert_pem).unwrap()
321+
}
322+
314323
/// Mine blocks and wait for all servers to sync to the new chain tip.
315324
pub async fn mine_and_sync(
316325
bitcoind: &TestBitcoind, servers: &[&LdkServerHandle], block_count: u64,
@@ -428,6 +437,21 @@ pub async fn setup_funded_channel(
428437
open_resp.user_channel_id
429438
}
430439

440+
/// Create a restricted API key by calling the CreateApiKey RPC on the server.
441+
pub async fn create_restricted_client(
442+
handle: &LdkServerHandle, name: &str, endpoints: Vec<String>,
443+
) -> LdkServerClient {
444+
use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest;
445+
446+
let resp = handle
447+
.client()
448+
.create_api_key(CreateApiKeyRequest { name: name.to_string(), endpoints })
449+
.await
450+
.unwrap();
451+
452+
make_client(handle, &resp.api_key)
453+
}
454+
431455
/// RabbitMQ event consumer for verifying events published by ldk-server.
432456
pub struct RabbitMqEventConsumer {
433457
_connection: lapin::Connection,

e2e-tests/tests/e2e.rs

Lines changed: 164 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ use std::str::FromStr;
1111
use std::time::Duration;
1212

1313
use e2e_tests::{
14-
find_available_port, mine_and_sync, run_cli, run_cli_raw, setup_funded_channel,
15-
wait_for_onchain_balance, LdkServerHandle, RabbitMqEventConsumer, TestBitcoind,
14+
create_restricted_client, find_available_port, make_client, mine_and_sync, run_cli,
15+
run_cli_raw, setup_funded_channel, wait_for_onchain_balance, LdkServerHandle,
16+
RabbitMqEventConsumer, TestBitcoind,
1617
};
1718
use ldk_node::lightning::ln::msgs::SocketAddress;
1819
use ldk_server_client::ldk_server_protos::api::{
19-
Bolt11ReceiveRequest, Bolt12ReceiveRequest, OnchainReceiveRequest,
20+
Bolt11ReceiveRequest, Bolt12ReceiveRequest, GetNodeInfoRequest, GetPermissionsRequest,
21+
OnchainReceiveRequest,
2022
};
2123
use ldk_server_client::ldk_server_protos::types::{
2224
bolt11_invoice_description, Bolt11InvoiceDescription,
@@ -611,3 +613,162 @@ async fn test_forwarded_payment_event() {
611613

612614
node_c.stop().unwrap();
613615
}
616+
617+
#[tokio::test]
618+
async fn test_get_permissions_admin() {
619+
let bitcoind = TestBitcoind::new();
620+
let server = LdkServerHandle::start(&bitcoind).await;
621+
622+
let resp = server.client().get_permissions(GetPermissionsRequest {}).await.unwrap();
623+
assert!(
624+
resp.endpoints.contains(&"*".to_string()),
625+
"Expected admin key to have wildcard permission"
626+
);
627+
}
628+
629+
#[tokio::test]
630+
async fn test_create_api_key_and_get_permissions() {
631+
let bitcoind = TestBitcoind::new();
632+
let server = LdkServerHandle::start(&bitcoind).await;
633+
634+
let restricted_client = create_restricted_client(
635+
&server,
636+
"read-only",
637+
vec!["GetNodeInfo".to_string(), "GetBalances".to_string()],
638+
)
639+
.await;
640+
641+
let resp = restricted_client.get_permissions(GetPermissionsRequest {}).await.unwrap();
642+
assert_eq!(resp.endpoints, vec!["GetBalances", "GetNodeInfo"]);
643+
}
644+
645+
#[tokio::test]
646+
async fn test_restricted_key_allowed_endpoint() {
647+
let bitcoind = TestBitcoind::new();
648+
let server = LdkServerHandle::start(&bitcoind).await;
649+
650+
let restricted_client =
651+
create_restricted_client(&server, "node-info-only", vec!["GetNodeInfo".to_string()]).await;
652+
653+
let resp = restricted_client.get_node_info(GetNodeInfoRequest {}).await;
654+
assert!(resp.is_ok(), "Restricted key should be able to call allowed endpoint");
655+
assert_eq!(resp.unwrap().node_id, server.node_id());
656+
}
657+
658+
#[tokio::test]
659+
async fn test_restricted_key_denied_endpoint() {
660+
let bitcoind = TestBitcoind::new();
661+
let server = LdkServerHandle::start(&bitcoind).await;
662+
663+
let restricted_client =
664+
create_restricted_client(&server, "info-only", vec!["GetNodeInfo".to_string()]).await;
665+
666+
let resp = restricted_client.onchain_receive(OnchainReceiveRequest {}).await;
667+
assert!(resp.is_err(), "Restricted key should be denied access to unauthorized endpoint");
668+
}
669+
670+
#[tokio::test]
671+
async fn test_restricted_key_get_permissions_always_allowed() {
672+
let bitcoind = TestBitcoind::new();
673+
let server = LdkServerHandle::start(&bitcoind).await;
674+
675+
// Create key with no endpoints at all (except GetPermissions which is always allowed)
676+
let restricted_client =
677+
create_restricted_client(&server, "perms-only", vec!["GetNodeInfo".to_string()]).await;
678+
679+
let resp = restricted_client.get_permissions(GetPermissionsRequest {}).await;
680+
assert!(resp.is_ok(), "GetPermissions should always be allowed");
681+
}
682+
683+
#[tokio::test]
684+
async fn test_create_api_key_via_cli() {
685+
let bitcoind = TestBitcoind::new();
686+
let server = LdkServerHandle::start(&bitcoind).await;
687+
688+
let output =
689+
run_cli(&server, &["create-api-key", "cli-test-key", "-e", "GetNodeInfo", "GetBalances"]);
690+
let api_key = output["api_key"].as_str().unwrap();
691+
assert_eq!(api_key.len(), 64);
692+
assert!(api_key.chars().all(|c| c.is_ascii_hexdigit()));
693+
}
694+
695+
#[tokio::test]
696+
async fn test_invalid_api_key_rejected() {
697+
let bitcoind = TestBitcoind::new();
698+
let server = LdkServerHandle::start(&bitcoind).await;
699+
700+
let bad_key = "ff".repeat(32);
701+
let bad_client = make_client(&server, &bad_key);
702+
703+
let resp = bad_client.get_node_info(GetNodeInfoRequest {}).await;
704+
assert!(resp.is_err(), "Invalid API key should be rejected");
705+
}
706+
707+
#[tokio::test]
708+
async fn test_restricted_key_cannot_create_api_key() {
709+
use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest;
710+
711+
let bitcoind = TestBitcoind::new();
712+
let server = LdkServerHandle::start(&bitcoind).await;
713+
714+
let restricted = create_restricted_client(&server, "limited", vec!["GetNodeInfo".to_string()])
715+
.await;
716+
717+
// Restricted key should not be able to create new keys
718+
let result = restricted
719+
.create_api_key(CreateApiKeyRequest {
720+
name: "sneaky".to_string(),
721+
endpoints: vec!["*".to_string()],
722+
})
723+
.await;
724+
assert!(result.is_err(), "Restricted key should not be able to create API keys");
725+
assert_eq!(
726+
result.unwrap_err().error_code,
727+
ldk_server_client::error::LdkServerErrorCode::AuthError
728+
);
729+
}
730+
731+
#[tokio::test]
732+
async fn test_create_api_key_duplicate_name_rejected() {
733+
use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest;
734+
735+
let bitcoind = TestBitcoind::new();
736+
let server = LdkServerHandle::start(&bitcoind).await;
737+
738+
// First creation should succeed
739+
let result = server
740+
.client()
741+
.create_api_key(CreateApiKeyRequest {
742+
name: "my-key".to_string(),
743+
endpoints: vec!["GetNodeInfo".to_string()],
744+
})
745+
.await;
746+
assert!(result.is_ok());
747+
748+
// Duplicate name should fail
749+
let result = server
750+
.client()
751+
.create_api_key(CreateApiKeyRequest {
752+
name: "my-key".to_string(),
753+
endpoints: vec!["GetNodeInfo".to_string()],
754+
})
755+
.await;
756+
assert!(result.is_err(), "Duplicate API key name should be rejected");
757+
}
758+
759+
#[tokio::test]
760+
async fn test_create_api_key_invalid_endpoint_rejected() {
761+
use ldk_server_client::ldk_server_protos::api::CreateApiKeyRequest;
762+
763+
let bitcoind = TestBitcoind::new();
764+
let server = LdkServerHandle::start(&bitcoind).await;
765+
766+
let result = server
767+
.client()
768+
.create_api_key(CreateApiKeyRequest {
769+
name: "bad-key".to_string(),
770+
endpoints: vec!["NonExistentEndpoint".to_string()],
771+
})
772+
.await;
773+
assert!(result.is_err(), "Unknown endpoint should be rejected");
774+
}

ldk-server-cli/src/config.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ use serde::{Deserialize, Serialize};
1313

1414
const DEFAULT_CONFIG_FILE: &str = "config.toml";
1515
const DEFAULT_CERT_FILE: &str = "tls.crt";
16-
const API_KEY_FILE: &str = "api_key";
1716

1817
pub fn get_default_data_dir() -> Option<PathBuf> {
1918
#[cfg(target_os = "macos")]
@@ -40,8 +39,12 @@ pub fn get_default_cert_path() -> Option<PathBuf> {
4039
get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE))
4140
}
4241

43-
pub fn get_default_api_key_path(network: &str) -> Option<PathBuf> {
44-
get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE))
42+
/// Reads the admin API key from `api_keys/admin.toml` in the network directory.
43+
pub fn get_default_admin_api_key(network: &str) -> Option<String> {
44+
let admin_toml_path = get_default_data_dir()?.join(network).join("api_keys").join("admin.toml");
45+
let contents = std::fs::read_to_string(&admin_toml_path).ok()?;
46+
let parsed: toml::Value = toml::from_str(&contents).ok()?;
47+
parsed.get("key").and_then(|v| v.as_str()).map(String::from)
4548
}
4649

4750
#[derive(Debug, Deserialize)]

ldk-server-cli/src/main.rs

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use std::path::PathBuf;
1212
use clap::{CommandFactory, Parser, Subcommand};
1313
use clap_complete::{generate, Shell};
1414
use config::{
15-
get_default_api_key_path, get_default_cert_path, get_default_config_path, load_config,
15+
get_default_admin_api_key, get_default_cert_path, get_default_config_path, load_config,
1616
};
1717
use hex_conservative::DisplayHex;
1818
use ldk_server_client::client::LdkServerClient;
@@ -24,12 +24,13 @@ use ldk_server_client::ldk_server_protos::api::{
2424
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse,
2525
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
2626
CloseChannelRequest, CloseChannelResponse, ConnectPeerRequest, ConnectPeerResponse,
27-
DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest,
28-
ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse,
29-
GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse,
30-
GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse,
31-
GraphListChannelsRequest, GraphListChannelsResponse, GraphListNodesRequest,
32-
GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
27+
CreateApiKeyRequest, CreateApiKeyResponse, DisconnectPeerRequest, DisconnectPeerResponse,
28+
ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse,
29+
GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse,
30+
GetPaymentDetailsRequest, GetPaymentDetailsResponse, GetPermissionsRequest,
31+
GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest,
32+
GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse,
33+
GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, ListChannelsResponse,
3334
ListForwardedPaymentsRequest, ListPaymentsRequest, OnchainReceiveRequest,
3435
OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest,
3536
OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest,
@@ -411,6 +412,20 @@ enum Commands {
411412
#[arg(help = "The hex-encoded node ID to look up")]
412413
node_id: String,
413414
},
415+
#[command(about = "Create a new API key with specific endpoint permissions (admin-only)")]
416+
CreateApiKey {
417+
#[arg(help = "A human-readable name for the API key")]
418+
name: String,
419+
#[arg(
420+
short,
421+
long,
422+
num_args = 1..,
423+
help = "List of endpoint names this key is permitted to access"
424+
)]
425+
endpoints: Vec<String>,
426+
},
427+
#[command(about = "Retrieve the permissions of the current API key")]
428+
GetPermissions,
414429
#[command(about = "Generate shell completions for the CLI")]
415430
Completions {
416431
#[arg(
@@ -434,18 +449,16 @@ async fn main() {
434449
let config_path = cli.config.map(PathBuf::from).or_else(get_default_config_path);
435450
let config = config_path.as_ref().and_then(|p| load_config(p).ok());
436451

437-
// Get API key from argument, then from api_key file
452+
// Get API key from argument, then from admin.toml
438453
let api_key = cli
439454
.api_key
440455
.or_else(|| {
441-
// Try to read from api_key file based on network (file contains raw bytes)
442-
let network = config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string());
443-
get_default_api_key_path(&network)
444-
.and_then(|path| std::fs::read(&path).ok())
445-
.map(|bytes| bytes.to_lower_hex_string())
456+
let network =
457+
config.as_ref().and_then(|c| c.network().ok()).unwrap_or("bitcoin".to_string());
458+
get_default_admin_api_key(&network)
446459
})
447460
.unwrap_or_else(|| {
448-
eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at ~/.ldk-server/[network]/api_key");
461+
eprintln!("API key not provided. Use --api-key or ensure api_keys/admin.toml exists at ~/.ldk-server/[network]/api_keys/admin.toml");
449462
std::process::exit(1);
450463
});
451464

@@ -844,6 +857,16 @@ async fn main() {
844857
client.graph_get_node(GraphGetNodeRequest { node_id }).await,
845858
);
846859
},
860+
Commands::CreateApiKey { name, endpoints } => {
861+
handle_response_result::<_, CreateApiKeyResponse>(
862+
client.create_api_key(CreateApiKeyRequest { name, endpoints }).await,
863+
);
864+
},
865+
Commands::GetPermissions => {
866+
handle_response_result::<_, GetPermissionsResponse>(
867+
client.get_permissions(GetPermissionsRequest {}).await,
868+
);
869+
},
847870
Commands::Completions { .. } => unreachable!("Handled above"),
848871
}
849872
}

0 commit comments

Comments
 (0)