From 3c2d4416b11b919f0cf274f0e767f813c096eeb4 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 4 Sep 2026 17:41:18 -0500 Subject: [PATCH] Add scoped API key permissions The shared API key gave every client full access and required all clients to rotate credentials when one client's access was revoked. Add separate keys with scoped permissions so each client gets only its required access and can be revoked independently. Manage keys through gRPC and the CLI, and bind signatures to key IDs and RPC methods. AI assistance: OpenAI Codex and Claude Fable 5.1 review. --- CONTRIBUTING.md | 21 +- docs/api-guide.md | 61 +- docs/configuration.md | 3 +- docs/getting-started.md | 19 +- docs/operations.md | 15 +- e2e-tests/src/lib.rs | 20 +- e2e-tests/tests/e2e.rs | 109 ++- e2e-tests/tests/mcp.rs | 38 + ldk-server-cli/src/main.rs | 107 ++- ldk-server-client/README.md | 13 +- ldk-server-client/src/client.rs | 100 ++- ldk-server-client/src/config.rs | 138 ++- ldk-server-client/src/error.rs | 4 + ldk-server-grpc/src/api.rs | 91 ++ ldk-server-grpc/src/auth.rs | 39 + ldk-server-grpc/src/endpoints.rs | 4 + ldk-server-grpc/src/grpc.rs | 1 + ldk-server-grpc/src/lib.rs | 2 + ldk-server-grpc/src/permissions.rs | 80 ++ ldk-server-grpc/src/proto/api.proto | 56 ++ ldk-server-mcp/CLAUDE.md | 2 +- ldk-server-mcp/README.md | 2 +- ldk-server-mcp/src/config.rs | 8 +- ldk-server-mcp/src/protocol.rs | 28 +- ldk-server-mcp/src/tools/handlers.rs | 61 +- ldk-server-mcp/src/tools/mod.rs | 24 + ldk-server-mcp/src/tools/schema.rs | 29 + ldk-server-mcp/tests/integration.rs | 26 +- ldk-server/src/api/error.rs | 4 + ldk-server/src/api_keys.rs | 1158 ++++++++++++++++++++++++++ ldk-server/src/main.rs | 74 +- ldk-server/src/service.rs | 269 +++--- 32 files changed, 2249 insertions(+), 357 deletions(-) create mode 100644 ldk-server-grpc/src/auth.rs create mode 100644 ldk-server-grpc/src/permissions.rs create mode 100644 ldk-server/src/api_keys.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9fa15f3..89e5ed87 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,14 @@ cargo run --bin ldk-server ./contrib/ldk-server-config.toml ## Testing ```bash -cargo test # Run all tests -cargo test --all-features # Run tests with all features +cargo test # Run workspace tests +cargo test --all-features # Run workspace tests with all features +``` + +The end-to-end tests use a separate workspace. Run them with: + +```bash +cargo test --manifest-path e2e-tests/Cargo.toml -- --test-threads=4 ``` ## Code Quality @@ -50,7 +56,16 @@ cargo fmt --all 2. Regenerate protos (see above) 3. Create handler in `ldk-server/src/api/` (follow existing patterns) 4. Add route in `ldk-server/src/service.rs` -5. Add CLI command in `ldk-server-cli/src/main.rs` +5. Map the RPC to its required permission in `method_authorization` in `ldk-server/src/api_keys.rs`. + Unmapped methods return `UNIMPLEMENTED`, including requests made with an admin key. +6. Add CLI command in `ldk-server-cli/src/main.rs` +7. For a unary RPC, add its MCP schema, handler, and registry entry in `ldk-server-mcp/src/tools/`. + Update the expected tools in `ldk-server-mcp/tests/integration.rs` and add live coverage in + `e2e-tests/tests/mcp.rs` when applicable. +8. Test requests with and without the required permission, including access with an admin key. + +If the RPC needs a new permission, add it to `ldk-server-grpc/src/permissions.rs` and +`ALL_PERMISSIONS`. Update any relevant presets and document the permission in `docs/api-guide.md`. ## Configuration diff --git a/docs/api-guide.md b/docs/api-guide.md index 71feb52e..59fe3e96 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -18,22 +18,59 @@ underlying LDK Node documentation. Every gRPC request must include an `x-auth` metadata header with an HMAC-SHA256 signature: ``` -x-auth: HMAC : +x-auth: HMAC :: ``` Where: +- `key_id` is the first 16 bytes of `SHA256(api_key_bytes)`, encoded as 32 lowercase hex characters - `unix_timestamp` is the current time in seconds since the Unix epoch - `hmac_hex` is the hex-encoded result of - `HMAC-SHA256(api_key_bytes, timestamp_be_bytes || grpc_request_body_bytes)` + `HMAC-SHA256(api_key_bytes, auth_domain || key_id || method_length || method || timestamp || body)` - `api_key_bytes` is the API key string encoded as UTF-8 bytes - - `timestamp_be_bytes` is the timestamp as a big-endian 8-byte unsigned integer - - `grpc_request_body_bytes` is the raw gRPC request body sent over HTTP/2, including + - `auth_domain` is the UTF-8 string `ldk-server-auth-v1` + - `method_length` is the byte length of the RPC method as a big-endian 8-byte unsigned integer + - `method` is the RPC method name encoded as UTF-8 bytes, such as `GetNodeInfo` + - `timestamp` is the timestamp as a big-endian 8-byte unsigned integer + - `body` is the raw gRPC request body sent over HTTP/2, including the 5-byte gRPC message frame The server rejects requests where the timestamp differs from the server's clock by more than **60 seconds**. +### API Key Permissions + +Each API key has one or more capabilities. New RPCs are denied to scoped keys until they have an +explicit capability mapping. The `admin` capability grants unrestricted access and must be used by +itself. + +| Capability | Access | +|------------------------|---------------------------------------------------------------| +| `node:read` | Node information, balances, and pathfinding scores | +| `onchain:receive` | Create on-chain receive addresses | +| `onchain:send` | Send on-chain funds | +| `invoices:create` | Create BOLT11/BOLT12 invoices and incoming refund requests | +| `payments:read` | Read payments and forwarded payments | +| `payments:claim` | Claim or fail held BOLT11 payments | +| `payments:send` | Send BOLT11, BOLT12, spontaneous, unified, and refund payments | +| `channels:read` | List channels | +| `channels:manage` | Open, configure, or cooperatively close channels | +| `channels:splice` | Splice funds in or out, including to an external address | +| `channels:force_close` | Force-close channels | +| `peers:read` | List peers | +| `peers:manage` | Connect or disconnect peers | +| `messages:sign` | Sign messages and create BOLT12 payer proofs | +| `messages:verify` | Verify message signatures | +| `graph:read` | Read network graph data | +| `utilities:read` | Decode invoices and offers | +| `events:read` | Subscribe to the event stream | +| `api_keys:manage` | Create, list, and revoke keys without privilege escalation | + +Use `CreateApiKey`, `ListApiKeys`, `RevokeApiKey`, and `GetPermissions` to manage keys. Key +secrets are returned only by `CreateApiKey`. The CLI also provides `readonly`, `invoice`, and +`admin` presets. MCP exposes the same operations as `create_api_key`, `list_api_keys`, +`revoke_api_key`, and `get_permissions` tools. + ## TLS The server auto-generates a self-signed ECDSA P-256 certificate on first startup, stored at @@ -67,6 +104,7 @@ Errors are returned as standard gRPC status codes: | gRPC Code | Meaning | |---------------------------|------------------------------------------------------------------| | `INVALID_ARGUMENT` (3) | Malformed request or invalid parameters | +| `PERMISSION_DENIED` (7) | Valid API key without the required capability | | `FAILED_PRECONDITION` (9) | Lightning operation error (e.g., insufficient balance, no route) | | `INTERNAL` (13) | Server-side bug | | `UNAUTHENTICATED` (16) | Missing or invalid `x-auth` header | @@ -232,6 +270,21 @@ Use events as notifications. After reconnecting, reconcile recoverable state wit `GetPaymentDetails`, `ListPayments`, `ListForwardedPayments`, and `ListChannels`. Some event fields cannot be recovered through these APIs. +### API Key Management + +| RPC | Description | +|------------------|---------------------------------------------------------------| +| `CreateApiKey` | Create a scoped key and return its secret once | +| `ListApiKeys` | List key IDs, names, and permissions without secrets | +| `RevokeApiKey` | Revoke a key for new requests | +| `GetPermissions` | Return the calling key's ID, name, and permissions | + +The first three RPCs require `api_keys:manage` or `admin`. A scoped key manager cannot create or +revoke a key with permissions that it does not have. The final admin key cannot be revoked. +Revoking a key blocks new requests, including new event subscriptions. Existing event streams +remain open and continue to receive events until the client disconnects or the server stops. +Authorization is checked only when a subscription starts. + ### Metrics Metrics are served as a plain HTTP GET endpoint (not gRPC): diff --git a/docs/configuration.md b/docs/configuration.md index e045cf17..6f02dc2f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -207,7 +207,8 @@ Two resolution methods are supported via the `mode` field: tls.crt # TLS certificate (PEM) tls.key # TLS private key (PEM) / # e.g., bitcoin/, regtest/, signet/ - api_key # API key + api_keys/ # Scoped API key TOML files + admin.toml # Initial unrestricted API key ldk-server.log # Log file ldk_node_data.sqlite # LDK Node state (channels, wallet, payments) ldk_server_data.sqlite # Forwarded-payment history diff --git a/docs/getting-started.md b/docs/getting-started.md index 78e39e37..3d169ee1 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,22 +73,27 @@ gRPC service listening on 127.0.0.1:3536 NODE_URI: @
``` -Two files are auto-generated on first run: +The admin API key and TLS certificate are auto-generated on first run: -| File | Location | Purpose | -|-----------------|-----------------------------------|------------------------------------------| -| API key | `//api_key` | 32-byte random key (stored as raw bytes) | -| TLS certificate | `/tls.crt` | Self-signed ECDSA P-256 certificate | +| File | Location | Purpose | +|-----------------|---------------------------------------------------|-------------------------------------| +| Admin API key | `//api_keys/admin.toml` | Unrestricted API credential | +| TLS certificate | `/tls.crt` | Self-signed ECDSA P-256 certificate | The default storage directory is `~/.ldk-server/` on Linux and `~/Library/Application Support/ldk-server/` on macOS. ### Reading the API Key -The API key file contains raw bytes. To get the hex string the CLI and client library expect: +The CLI reads the admin API key automatically from the configured storage directory. No +manual extraction is needed. To use the admin key with another client, open +`//api_keys/admin.toml` and copy the value of the `key` field. + +Create a restricted key for an application instead of copying the admin key: ```bash -xxd -p -c 64 ~/.ldk-server/bitcoin/api_key +ldk-server-cli create-api-key my-app --preset readonly +ldk-server-cli create-api-key invoice-app --preset invoice ``` ## First Commands diff --git a/docs/operations.md b/docs/operations.md index 178a7226..ea923a7b 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -60,7 +60,7 @@ the following config to `/etc/logrotate.d/ldk-server` (adjust the log path to ma - Network graph data (re-synced from gossip or RGS) - Fee rate cache (re-fetched from the chain backend) -- The API key (can be regenerated, but clients will need the new one) +- API keys (can be regenerated, but clients will need new keys) - The TLS certificate (can be regenerated, but clients will need the new one) > **Warning:** Do not restore a backup onto two running nodes simultaneously. Running the @@ -69,13 +69,14 @@ the following config to `/etc/logrotate.d/ldk-server` (adjust the log path to ma ## Security -### API Key +### API Keys -- Auto-generated as 32 random bytes on first startup -- Stored at `/api_key` with `0400` permissions (read-only for owner) -- The hex-encoded form of this key is used for HMAC authentication -- Treat it as a secret: anyone with the API key and network access to the gRPC port can - control the node +- An unrestricted admin key is generated on first startup +- Keys are stored as TOML files in `/api_keys/` +- Revoking a key blocks new requests; existing event streams continue until the client disconnects + or the server stops +- Keys can have scoped capabilities; use the minimum permissions required by each client +- Treat each key as a secret: anyone with a key and network access can use its capabilities ### TLS diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index c63df6f0..944acbe1 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::{EventStream, LdkServerClient}; use ldk_server_client::ldk_server_grpc::api::{GetNodeInfoRequest, GetNodeInfoResponse}; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; @@ -343,17 +342,20 @@ impl LdkServerHandle { } }); - // Wait for the api_key and tls.crt files to appear in the network subdir + // Wait for the admin API key and TLS certificate files to appear. let network_dir = storage_dir.join("regtest"); - let api_key_path = network_dir.join("api_key"); + let api_key_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(&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(); + let api_key_file = std::fs::read_to_string(&api_key_path).unwrap(); + let api_key = api_key_file + .lines() + .find_map(|line| line.strip_prefix("key = \"").and_then(|key| key.strip_suffix('"'))) + .expect("admin API key file must contain a key") + .to_string(); // Read TLS cert let tls_cert_pem = std::fs::read(&tls_cert_path).unwrap(); @@ -547,10 +549,14 @@ pub struct McpHandle { impl McpHandle { pub fn start(server: &LdkServerHandle) -> Self { + Self::start_with_api_key(server, &server.api_key) + } + + pub fn start_with_api_key(server: &LdkServerHandle, api_key: &str) -> Self { let mcp_path = mcp_binary_path(); let mut child = Command::new(&mcp_path) .env("LDK_BASE_URL", server.base_url()) - .env("LDK_API_KEY", &server.api_key) + .env("LDK_API_KEY", api_key) .env("LDK_TLS_CERT_PATH", server.tls_cert_path.to_str().unwrap()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 05dea885..5c1f6d3c 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -22,10 +22,12 @@ use ldk_node::lightning::ln::msgs::SocketAddress; use ldk_node::lightning::offers::offer::Offer; use ldk_node::lightning::offers::refund::Refund; use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_server_client::client::LdkServerClient; use ldk_server_client::error::LdkServerErrorCode::InvalidRequestError; use ldk_server_client::ldk_server_grpc::api::{ open_channel_request, Bolt11ClaimForIdRequest, Bolt11FailForIdRequest, Bolt11ReceiveRequest, - Bolt12ReceiveRequest, GetBalancesRequest, OnchainReceiveRequest, OpenChannelRequest, + Bolt12ReceiveRequest, GetBalancesRequest, GetNodeInfoRequest, GetPermissionsRequest, + OnchainReceiveRequest, OpenChannelRequest, }; use ldk_server_client::ldk_server_grpc::events::event_envelope::Event; use ldk_server_client::ldk_server_grpc::events::{ @@ -81,6 +83,111 @@ async fn test_cli_get_balances() { assert_eq!(output["total_lightning_balance_sats"], 0); } +#[tokio::test] +async fn test_scoped_api_key_lifecycle() { + use ldk_server_client::error::LdkServerErrorCode::{ + AuthError, AuthorizationError, InvalidRequestError, + }; + + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + + let created = run_cli(&server, &["create-api-key", "readonly-client", "--preset", "readonly"]); + let key_id = created["api_key"]["id"].as_str().unwrap(); + let secret = created["secret"].as_str().unwrap(); + let certificate = std::fs::read(&server.tls_cert_path).unwrap(); + let client = LdkServerClient::new( + format!("127.0.0.1:{}", server.grpc_port), + secret.to_string(), + &certificate, + ) + .unwrap(); + + client.get_node_info(GetNodeInfoRequest {}).await.unwrap(); + let permissions = client.get_permissions(GetPermissionsRequest {}).await.unwrap(); + assert_eq!(permissions.api_key.unwrap().name, "readonly-client"); + assert_eq!( + client.onchain_receive(OnchainReceiveRequest {}).await.unwrap_err().error_code, + AuthorizationError + ); + assert_eq!( + client.list_api_keys(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + + let keys = run_cli(&server, &["list-api-keys"]); + assert!(keys["api_keys"].as_array().unwrap().iter().any(|key| key["id"] == key_id)); + // Invalid request fields distinguish reaching the splice handler from an auth rejection. + for (name, permission, expected) in [ + ("manager", "channels:manage", AuthorizationError), + ("splicer", "channels:splice", InvalidRequestError), + ] { + let created = run_cli(&server, &["create-api-key", name, "--permissions", permission]); + let scoped_client = LdkServerClient::new( + format!("127.0.0.1:{}", server.grpc_port), + created["secret"].as_str().unwrap().to_string(), + &certificate, + ) + .unwrap(); + assert_eq!( + scoped_client.splice_in(Default::default()).await.unwrap_err().error_code, + expected + ); + assert_eq!( + scoped_client.splice_out(Default::default()).await.unwrap_err().error_code, + expected + ); + } + + run_cli(&server, &["revoke-api-key", key_id]); + assert_eq!( + client.subscribe_events().await.err().expect("Revoked key must not subscribe").error_code, + AuthError + ); + assert_eq!( + client.get_node_info(GetNodeInfoRequest {}).await.unwrap_err().error_code, + AuthError + ); +} + +#[tokio::test] +async fn test_revoking_a_key_keeps_existing_event_streams_open() { + use ldk_server_client::error::LdkServerErrorCode::AuthError; + + let bitcoind = TestBitcoind::new(); + let server_a = LdkServerHandle::start(&bitcoind).await; + let server_b = LdkServerHandle::start(&bitcoind).await; + let channel_id = setup_funded_channel(&bitcoind, &server_a, &server_b, 100_000).await; + let created = run_cli(&server_a, &["create-api-key", "reader", "--permissions", "events:read"]); + let certificate = std::fs::read(&server_a.tls_cert_path).unwrap(); + let client = LdkServerClient::new( + format!("127.0.0.1:{}", server_a.grpc_port), + created["secret"].as_str().unwrap().to_string(), + &certificate, + ) + .unwrap(); + let mut events = client.subscribe_events().await.unwrap(); + + run_cli(&server_a, &["revoke-api-key", created["api_key"]["id"].as_str().unwrap()]); + assert_eq!( + client.subscribe_events().await.err().expect("Revoked key must not subscribe").error_code, + AuthError + ); + + // An event created after revocation must still reach the existing subscription. + run_cli(&server_a, &["close-channel", &channel_id, server_b.node_id()]); + mine_and_sync(&bitcoind, &[&server_a, &server_b], 6).await; + wait_for_event(&mut events, |event| { + matches!( + event, + Event::ChannelStateChanged(channel_event) + if channel_event.user_channel_id == channel_id + && channel_event.state == ChannelState::Closed as i32 + ) + }) + .await; +} + #[tokio::test] async fn test_cli_list_channels_empty() { let bitcoind = TestBitcoind::new(); diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 3ae00766..f56ff112 100644 --- a/e2e-tests/tests/mcp.rs +++ b/e2e-tests/tests/mcp.rs @@ -23,6 +23,44 @@ fn tool_result_json(response: &Value) -> Value { serde_json::from_str(text).unwrap() } +#[tokio::test] +async fn test_mcp_api_key_lifecycle_and_error_categories() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let mut admin = McpHandle::start(&server); + let created = admin.call(1, "tools/call", json!({"name": "create_api_key", "arguments": {"name": "mcp-reader", "permissions": ["node:read"]}})); + let created = tool_result_json(&created); + let id = created["api_key"]["id"].as_str().unwrap(); + let secret = created["secret"].as_str().unwrap(); + assert_eq!(secret.len(), 64); + let listed = admin.call(2, "tools/call", json!({"name": "list_api_keys", "arguments": {}})); + let listed = tool_result_json(&listed); + assert!(listed["api_keys"].as_array().unwrap().iter().any(|key| key["id"] == id)); + assert!(!listed.to_string().contains(secret)); + let mut reader = McpHandle::start_with_api_key(&server, secret); + let permissions = + reader.call(1, "tools/call", json!({"name": "get_permissions", "arguments": {}})); + let permissions = tool_result_json(&permissions); + assert_eq!(permissions["api_key"]["id"], id); + assert_eq!(permissions["api_key"]["permissions"], json!(["node:read"])); + let denied = reader.call(2, "tools/call", json!({"name": "list_api_keys", "arguments": {}})); + assert_eq!(denied["result"]["isError"], true); + assert!(denied["result"]["content"][0]["text"] + .as_str() + .unwrap() + .starts_with("Permission denied:")); + let revoked = + admin.call(3, "tools/call", json!({"name": "revoke_api_key", "arguments": {"id": id}})); + assert_eq!(tool_result_json(&revoked), json!({})); + let rejected = + reader.call(3, "tools/call", json!({"name": "get_permissions", "arguments": {}})); + assert_eq!(rejected["result"]["isError"], true); + assert!(rejected["result"]["content"][0]["text"] + .as_str() + .unwrap() + .starts_with("Authentication error:")); +} + #[tokio::test] async fn test_mcp_initialize_and_list_tools() { let bitcoind = TestBitcoind::new(); diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 1b951197..5b73beb2 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -10,7 +10,7 @@ use std::fmt::Write; use std::path::PathBuf; -use clap::{CommandFactory, Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand, ValueEnum}; use clap_complete::{generate, Shell}; use hex_conservative::{DisplayHex, FromHex}; use ldk_server_client::client::LdkServerClient; @@ -20,7 +20,8 @@ use ldk_server_client::config::{ }; use ldk_server_client::error::LdkServerError; use ldk_server_client::error::LdkServerErrorCode::{ - AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, + AuthError, AuthorizationError, InternalError, InternalServerError, InvalidRequestError, + LightningError, }; use ldk_server_client::ldk_server_grpc::api::{ onchain_send_request, open_channel_request, splice_in_request, AllFunds, @@ -33,20 +34,24 @@ use ldk_server_client::ldk_server_grpc::api::{ Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, - ConnectPeerRequest, ConnectPeerResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, - DecodeOfferRequest, DecodeOfferResponse, DisconnectPeerRequest, DisconnectPeerResponse, - ExportPathfindingScoresRequest, ForceCloseChannelRequest, ForceCloseChannelResponse, - GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, - GetPaymentDetailsRequest, GetPaymentDetailsResponse, GraphGetChannelRequest, - GraphGetChannelResponse, GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, - GraphListChannelsResponse, GraphListNodesRequest, GraphListNodesResponse, ListChannelsRequest, - ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, - ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, - OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, SignMessageRequest, - SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, - SpontaneousSendRequest, SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, - UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, - VerifySignatureResponse, + ConnectPeerRequest, ConnectPeerResponse, CreateApiKeyRequest, CreateApiKeyResponse, + DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, + DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, + ForceCloseChannelRequest, ForceCloseChannelResponse, GetBalancesRequest, GetBalancesResponse, + GetNodeInfoRequest, GetNodeInfoResponse, GetPaymentDetailsRequest, GetPaymentDetailsResponse, + GetPermissionsRequest, GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, + GraphGetNodeRequest, GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, + GraphListNodesRequest, GraphListNodesResponse, ListApiKeysRequest, ListApiKeysResponse, + ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, ListPaymentsRequest, + ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, + OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, + RevokeApiKeyRequest, RevokeApiKeyResponse, SignMessageRequest, SignMessageResponse, + SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, + SpontaneousSendResponse, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, + UpdateChannelConfigResponse, VerifySignatureRequest, VerifySignatureResponse, +}; +use ldk_server_client::ldk_server_grpc::permissions::{ + ADMIN_PERMISSION, INVOICE_PERMISSIONS, READONLY_PERMISSIONS, }; use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, CustomTlvRecord, @@ -92,7 +97,7 @@ struct Cli { )] base_url: Option, - #[arg(short, long, help = format!("API key for authentication. Defaults by reading {DEFAULT_DIR}/[network]/api_key"))] + #[arg(short, long, help = format!("API key for authentication. Defaults by reading {DEFAULT_DIR}/[network]/api_keys/admin.toml"))] api_key: Option, #[arg(short, long, help = format!("Path to the server's TLS certificate file (PEM format). Defaults to {DEFAULT_DIR}/tls.crt"))] @@ -637,6 +642,31 @@ enum Commands { #[arg(help = "The hex-encoded node ID to look up")] node_id: String, }, + #[command(about = "Create an API key with scoped permissions")] + CreateApiKey { + #[arg(help = "A unique human-readable name for the API key")] + name: String, + #[arg( + short, + long, + num_args = 1.., + conflicts_with = "preset", + required_unless_present = "preset", + help = "Capabilities to grant, such as node:read or invoices:create" + )] + permissions: Vec, + #[arg(long, value_enum, conflicts_with = "permissions", help = "Use a permission preset")] + preset: Option, + }, + #[command(about = "List API keys without their secrets")] + ListApiKeys, + #[command(about = "Revoke an API key")] + RevokeApiKey { + #[arg(help = "The hex-encoded API key ID")] + id: String, + }, + #[command(about = "Show permissions for the current API key")] + GetPermissions, #[command(about = "Generate shell completions for the CLI")] Completions { #[arg( @@ -647,6 +677,25 @@ enum Commands { }, } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum ApiKeyPreset { + Readonly, + Invoice, + Admin, +} + +impl ApiKeyPreset { + fn permissions(self) -> Vec { + match self { + Self::Readonly => { + READONLY_PERMISSIONS.iter().map(|value| (*value).to_string()).collect() + }, + Self::Invoice => INVOICE_PERMISSIONS.iter().map(|value| (*value).to_string()).collect(), + Self::Admin => vec![ADMIN_PERMISSION.to_string()], + } + } +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -679,7 +728,7 @@ async fn main() { std::process::exit(1); }) .unwrap_or_else(|| { - eprintln!("API key not provided. Use --api-key or ensure the api_key file exists at {DEFAULT_DIR}/[network]/api_key"); + eprintln!("API key not provided. Use --api-key or ensure the admin key exists at {DEFAULT_DIR}/[network]/api_keys/admin.toml"); std::process::exit(1); }); @@ -1301,6 +1350,27 @@ async fn main() { client.graph_get_node(GraphGetNodeRequest { node_id }).await, ); }, + Commands::CreateApiKey { name, permissions, preset } => { + let permissions = preset.map(ApiKeyPreset::permissions).unwrap_or(permissions); + handle_response_result::<_, CreateApiKeyResponse>( + client.create_api_key(CreateApiKeyRequest { name, permissions }).await, + ); + }, + Commands::ListApiKeys => { + handle_response_result::<_, ListApiKeysResponse>( + client.list_api_keys(ListApiKeysRequest {}).await, + ); + }, + Commands::RevokeApiKey { id } => { + handle_response_result::<_, RevokeApiKeyResponse>( + client.revoke_api_key(RevokeApiKeyRequest { id }).await, + ); + }, + Commands::GetPermissions => { + handle_response_result::<_, GetPermissionsResponse>( + client.get_permissions(GetPermissionsRequest {}).await, + ); + }, Commands::Completions { .. } => unreachable!("Handled above"), } } @@ -1461,6 +1531,7 @@ fn handle_error(e: LdkServerError) -> ! { let error_type = match e.error_code { InvalidRequestError => "Invalid Request", AuthError => "Authentication Error", + AuthorizationError => "Permission Denied", LightningError => "Lightning Error", InternalServerError => "Internal Server Error", InternalError => "Internal Error", diff --git a/ldk-server-client/README.md b/ldk-server-client/README.md index e6e50adb..3fa68733 100644 --- a/ldk-server-client/README.md +++ b/ldk-server-client/README.md @@ -10,7 +10,7 @@ subscriptions). use ldk_server_client::client::LdkServerClient; use ldk_server_client::ldk_server_grpc::api::GetNodeInfoRequest; -# #[tokio::main] +# #[tokio::main(flavor = "current_thread")] # async fn main() { let cert_pem = std::fs::read("/path/to/tls.crt").unwrap(); let api_key = "your_hex_api_key".to_string(); @@ -29,9 +29,9 @@ println!("Node ID: {}", info.node_id); ## Authentication The client handles HMAC-SHA256 authentication automatically. Pass the hex-encoded API key -(found at `//api_key`) and the server's TLS certificate (found at -`/tls.crt`). Each request signature covers both the timestamp and the raw gRPC -request body bytes. +(found in `//api_keys/admin.toml` for the initial admin key) and the +server's TLS certificate (found at `/tls.crt`). Each request signature covers the +key ID, RPC method, timestamp, and raw gRPC request body bytes. ## Event Streaming @@ -39,7 +39,7 @@ Subscribe to real-time payment and channel events: ```rust,no_run # use ldk_server_client::client::LdkServerClient; -# #[tokio::main] +# #[tokio::main(flavor = "current_thread")] # async fn main() { # let cert_pem = std::fs::read("/path/to/tls.crt").unwrap(); # let client = LdkServerClient::new("localhost:3536".to_string(), "key".to_string(), &cert_pem).unwrap(); @@ -58,7 +58,7 @@ Pattern-match channel state changes: ```rust,no_run # use ldk_server_client::client::LdkServerClient; # use ldk_server_client::ldk_server_grpc::events::{event_envelope, ChannelState}; -# #[tokio::main] +# #[tokio::main(flavor = "current_thread")] # async fn main() { # let cert_pem = std::fs::read("/path/to/tls.crt").unwrap(); # let client = LdkServerClient::new("localhost:3536".to_string(), "key".to_string(), &cert_pem).unwrap(); @@ -101,6 +101,7 @@ All methods return `Result`. Error codes map to gRPC status c | `LightningError` | FAILED_PRECONDITION (9) | Lightning operation error | | `InternalServerError` | INTERNAL (13) | Server bug | | `AuthError` | UNAUTHENTICATED (16) | Invalid credentials | +| `AuthorizationError` | PERMISSION_DENIED (7) | Missing permission | ## Documentation diff --git a/ldk-server-client/src/client.rs b/ldk-server-client/src/client.rs index b8cf8b3f..88d7e10d 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -12,6 +12,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use bitcoin_hashes::hmac::{Hmac, HmacEngine}; use bitcoin_hashes::{sha256, Hash, HashEngine}; +use hex_conservative::DisplayHex; use hyper::body::HttpBody as _; use hyper::{Body as HyperBody, Client as HyperClient, Request as HyperRequest, Version}; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; @@ -25,18 +26,20 @@ use ldk_server_grpc::api::{ Bolt12CreatePayerProofResponse, Bolt12ReceiveRefundRequest, Bolt12ReceiveRefundResponse, Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRefundRequest, Bolt12SendRefundResponse, Bolt12SendRequest, Bolt12SendResponse, CloseChannelRequest, CloseChannelResponse, - ConnectPeerRequest, ConnectPeerResponse, DecodeInvoiceRequest, DecodeInvoiceResponse, - DecodeOfferRequest, DecodeOfferResponse, 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, ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, - OnchainReceiveResponse, OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, - OpenChannelResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, + ConnectPeerRequest, ConnectPeerResponse, CreateApiKeyRequest, CreateApiKeyResponse, + DecodeInvoiceRequest, DecodeInvoiceResponse, DecodeOfferRequest, DecodeOfferResponse, + DisconnectPeerRequest, DisconnectPeerResponse, ExportPathfindingScoresRequest, + ExportPathfindingScoresResponse, ForceCloseChannelRequest, ForceCloseChannelResponse, + GetBalancesRequest, GetBalancesResponse, GetNodeInfoRequest, GetNodeInfoResponse, + GetPaymentDetailsRequest, GetPaymentDetailsResponse, GetPermissionsRequest, + GetPermissionsResponse, GraphGetChannelRequest, GraphGetChannelResponse, GraphGetNodeRequest, + GraphGetNodeResponse, GraphListChannelsRequest, GraphListChannelsResponse, + GraphListNodesRequest, GraphListNodesResponse, ListApiKeysRequest, ListApiKeysResponse, + ListChannelsRequest, ListChannelsResponse, ListForwardedPaymentsRequest, + ListForwardedPaymentsResponse, ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, + ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, + OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, RevokeApiKeyRequest, + RevokeApiKeyResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, SubscribeEventsRequest, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, @@ -48,20 +51,20 @@ use ldk_server_grpc::endpoints::{ BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, - DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_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, GRPC_SERVICE_PREFIX, LIST_CHANNELS_PATH, - LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, - ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, - SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, - VERIFY_SIGNATURE_PATH, + CREATE_API_KEY_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_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, GRPC_SERVICE_PREFIX, + LIST_API_KEYS_PATH, LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, + LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, + REVOKE_API_KEY_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, + SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ decode_grpc_body, encode_grpc_frame, percent_decode, GRPC_STATUS_FAILED_PRECONDITION, GRPC_STATUS_INTERNAL, GRPC_STATUS_INVALID_ARGUMENT, GRPC_STATUS_OK, - GRPC_STATUS_UNAUTHENTICATED, GRPC_STATUS_UNAVAILABLE, + GRPC_STATUS_PERMISSION_DENIED, GRPC_STATUS_UNAUTHENTICATED, GRPC_STATUS_UNAVAILABLE, }; use prost::Message; use reqwest::header::HeaderMap; @@ -71,7 +74,8 @@ use rustls_pemfile::certs; use crate::error::LdkServerError; use crate::error::LdkServerErrorCode::{ - AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError, + AuthError, AuthorizationError, InternalError, InternalServerError, InvalidRequestError, + LightningError, }; type StreamingClient = HyperClient, HyperBody>; @@ -97,6 +101,7 @@ pub struct LdkServerClient { client: Client, streaming_client: StreamingClient, api_key: String, + key_id: String, } impl LdkServerClient { @@ -107,6 +112,8 @@ impl LdkServerClient { /// `server_cert_pem` is the server's TLS certificate in PEM format. This can be /// found at `/tls.crt` after the server starts. pub fn new(base_url: String, api_key: String, server_cert_pem: &[u8]) -> Result { + let key_hash = sha256::Hash::hash(api_key.as_bytes()); + let key_id = key_hash[..16].to_lower_hex_string(); let cert = Certificate::from_pem(server_cert_pem) .map_err(|e| format!("Failed to parse server certificate: {e}"))?; let streaming_client = build_streaming_client(server_cert_pem)?; @@ -116,24 +123,25 @@ impl LdkServerClient { .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; - Ok(Self { base_url, client, streaming_client, api_key }) + Ok(Self { base_url, client, streaming_client, api_key, key_id }) } /// Computes the HMAC-SHA256 authentication header value. - /// Format: "HMAC :" - /// The signature covers the timestamp and raw gRPC request body bytes. - fn compute_auth_header(&self, body: &[u8]) -> String { + /// Format: "HMAC ::" + /// The signature covers the key ID, RPC method, timestamp, and raw gRPC request body bytes. + fn compute_auth_header(&self, method: &str, body: &[u8]) -> String { let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("System time should be after Unix epoch") .as_secs(); let mut hmac_engine: HmacEngine = HmacEngine::new(self.api_key.as_bytes()); - hmac_engine.input(×tamp.to_be_bytes()); - hmac_engine.input(body); + ldk_server_grpc::auth::write_auth_preimage(&self.key_id, method, timestamp, body, |part| { + hmac_engine.input(part) + }); 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. @@ -461,6 +469,34 @@ impl LdkServerClient { self.grpc_unary(&request, GRAPH_GET_NODE_PATH).await } + /// Create an API key with the specified permissions. + pub async fn create_api_key( + &self, request: CreateApiKeyRequest, + ) -> Result { + self.grpc_unary(&request, CREATE_API_KEY_PATH).await + } + + /// List API keys without returning their secrets. + pub async fn list_api_keys( + &self, request: ListApiKeysRequest, + ) -> Result { + self.grpc_unary(&request, LIST_API_KEYS_PATH).await + } + + /// Revoke an API key by ID. + pub async fn revoke_api_key( + &self, request: RevokeApiKeyRequest, + ) -> Result { + self.grpc_unary(&request, REVOKE_API_KEY_PATH).await + } + + /// Return metadata and permissions for the calling API key. + pub async fn get_permissions( + &self, request: GetPermissionsRequest, + ) -> Result { + self.grpc_unary(&request, GET_PERMISSIONS_PATH).await + } + /// Subscribe to a stream of server events via server-streaming gRPC. /// /// Returns an [`EventStream`] that yields [`EventEnvelope`] messages as they arrive. @@ -476,7 +512,7 @@ impl LdkServerClient { let content_length = grpc_body.len().to_string(); let url = format!("https://{}{}{}", self.base_url, GRPC_SERVICE_PREFIX, method); - let auth_header = self.compute_auth_header(&grpc_body); + let auth_header = self.compute_auth_header(method, &grpc_body); let response = self .client @@ -518,7 +554,7 @@ impl LdkServerClient { let content_length = grpc_body.len().to_string(); let url = format!("https://{}{}{}", self.base_url, GRPC_SERVICE_PREFIX, method); - let auth_header = self.compute_auth_header(&grpc_body); + let auth_header = self.compute_auth_header(method, &grpc_body); let response = self .streaming_client @@ -606,6 +642,7 @@ fn grpc_code_to_error(code: u32, message: String) -> LdkServerError { format!("gRPC stream became unavailable: {message}") }, ), + GRPC_STATUS_PERMISSION_DENIED => LdkServerError::new(AuthorizationError, message), GRPC_STATUS_UNAUTHENTICATED => LdkServerError::new(AuthError, message), _ => LdkServerError::new( InternalError, @@ -887,6 +924,7 @@ mod tests { let cases = [ (GRPC_STATUS_INVALID_ARGUMENT, InvalidRequestError, "msg"), (GRPC_STATUS_UNAUTHENTICATED, AuthError, "msg"), + (GRPC_STATUS_PERMISSION_DENIED, AuthorizationError, "msg"), (GRPC_STATUS_FAILED_PRECONDITION, LightningError, "msg"), (GRPC_STATUS_INTERNAL, InternalServerError, "msg"), ]; diff --git a/ldk-server-client/src/config.rs b/ldk-server-client/src/config.rs index 243ab18a..fbd528e5 100644 --- a/ldk-server-client/src/config.rs +++ b/ldk-server-client/src/config.rs @@ -16,15 +16,16 @@ use std::io::{self, ErrorKind, Read}; use std::path::{Path, PathBuf}; -use hex_conservative::DisplayHex; 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"; const API_KEY_LEN: usize = 32; const CONFIG_FILE_SIZE_LIMIT: usize = 1024 * 1024; const TLS_CERT_FILE_SIZE_LIMIT: usize = 1024 * 1024; +const API_KEYS_DIR: &str = "api_keys"; +const ADMIN_API_KEY_FILE: &str = "admin.toml"; +const API_KEY_FILE_SIZE_LIMIT: usize = 4096; /// Default address of the `ldk-server` gRPC endpoint when no explicit value is configured. pub const DEFAULT_GRPC_SERVICE_ADDRESS: &str = "127.0.0.1:3536"; @@ -57,14 +58,15 @@ pub fn get_default_cert_path() -> Option { get_default_data_dir().map(|path| path.join(DEFAULT_CERT_FILE)) } -/// Default path of the network-scoped API key file inside the default data directory. -pub fn get_default_api_key_path(network: &str) -> Option { - get_default_data_dir().map(|path| path.join(network).join(API_KEY_FILE)) +/// Default path of the network-scoped admin API key file. +pub fn get_default_admin_api_key_path(network: &str) -> Option { + get_default_data_dir() + .map(|path| path.join(network).join(API_KEYS_DIR).join(ADMIN_API_KEY_FILE)) } -/// Path of the network-scoped API key file inside the given storage directory. -pub fn api_key_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { - PathBuf::from(storage_dir).join(network).join(API_KEY_FILE) +/// Path of the network-scoped admin API key file inside the given storage directory. +pub fn admin_api_key_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { + PathBuf::from(storage_dir).join(network).join(API_KEYS_DIR).join(ADMIN_API_KEY_FILE) } /// Path of the server's TLS certificate inside the given storage directory. @@ -155,12 +157,11 @@ pub fn resolve_base_url(override_url: Option, config: Option<&Config>) - /// Resolves the API key used to authenticate against the `ldk-server` gRPC endpoint. /// -/// Prefers `override_key`, falls back to reading the API key file from the configured storage -/// directory, and finally from the OS-specific default data directory. The raw bytes read from -/// disk are lower-hex encoded before being returned. +/// Prefers `override_key`, falls back to reading the admin TOML file from the configured storage +/// directory, and finally from the OS-specific default data directory. /// -/// Returns an error if a candidate API key file exists but cannot be read or does not contain -/// exactly 32 bytes. +/// Returns an error if a candidate key file exists but cannot be read, exceeds its size limit, +/// or does not contain a valid key. pub fn resolve_api_key( override_key: Option, config: Option<&Config>, ) -> Result, String> { @@ -170,37 +171,17 @@ pub fn resolve_api_key( let network = config.and_then(|c| c.network().ok()).unwrap_or_else(|| "bitcoin".to_string()); if let Some(dir) = storage_dir(config) { - let path = api_key_path_for_storage_dir(dir, &network); - if let Some(api_key) = read_api_key(&path)? { - return Ok(Some(api_key)); + if let Some(key) = read_admin_api_key(&admin_api_key_path_for_storage_dir(dir, &network))? { + return Ok(Some(key)); } } - match get_default_api_key_path(&network) { - Some(path) => read_api_key(&path), + match get_default_admin_api_key_path(&network) { + Some(path) => read_admin_api_key(&path), None => Ok(None), } } -fn read_api_key(path: &Path) -> Result, String> { - let file = match std::fs::File::open(path) { - Ok(file) => file, - Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), - Err(e) => return Err(format!("Failed to read API key file '{}': {e}", path.display())), - }; - let mut bytes = Vec::with_capacity(API_KEY_LEN + 1); - file.take((API_KEY_LEN + 1) as u64) - .read_to_end(&mut bytes) - .map_err(|e| format!("Failed to read API key file '{}': {e}", path.display()))?; - if bytes.len() != API_KEY_LEN { - return Err(format!( - "API key file '{}' must contain exactly {API_KEY_LEN} bytes", - path.display() - )); - } - Ok(Some(bytes.to_lower_hex_string())) -} - fn read_with_limit(path: &Path, limit: usize) -> io::Result> { let file = std::fs::File::open(path)?; let mut contents = Vec::new(); @@ -219,6 +200,29 @@ fn read_to_string_with_limit(path: &Path, limit: usize) -> io::Result { .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } +#[derive(Deserialize)] +struct StoredAdminApiKey { + key: String, +} + +fn read_admin_api_key(path: &Path) -> Result, String> { + let contents = match read_to_string_with_limit(path, API_KEY_FILE_SIZE_LIMIT) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("Failed to read API key file '{}': {error}", path.display())) + }, + }; + let stored: StoredAdminApiKey = toml::from_str(&contents) + .map_err(|_| format!("Invalid API key file '{}'", path.display()))?; + if stored.key.len() != API_KEY_LEN * 2 + || !stored.key.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(format!("Invalid API key in '{}'", path.display())); + } + Ok(Some(stored.key)) +} + /// Resolves the path to the server's TLS certificate (PEM). /// /// Prefers `override_path`, falls back to `tls.cert_path` in the configuration file, then to the @@ -247,9 +251,12 @@ fn default_grpc_service_address() -> String { #[cfg(test)] mod tests { use super::{ - load_config, read_tls_certificate, resolve_base_url, Config, CONFIG_FILE_SIZE_LIMIT, - DEFAULT_GRPC_SERVICE_ADDRESS, TLS_CERT_FILE_SIZE_LIMIT, + load_config, read_tls_certificate, resolve_api_key, resolve_base_url, Config, + CONFIG_FILE_SIZE_LIMIT, DEFAULT_GRPC_SERVICE_ADDRESS, TLS_CERT_FILE_SIZE_LIMIT, }; + use std::fs; + use std::sync::atomic::{AtomicU32, Ordering}; + static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); #[test] fn config_defaults_grpc_service_address() { @@ -368,4 +375,57 @@ mod tests { std::fs::remove_file(path).unwrap(); } + + #[test] + fn resolve_api_key_reads_scoped_admin_file() { + let count = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("ldk-server-client-config-test-{}-{count}", std::process::id())); + let admin_directory = directory.join("regtest").join("api_keys"); + fs::create_dir_all(&admin_directory).unwrap(); + let secret = "42".repeat(32); + fs::write( + admin_directory.join("admin.toml"), + format!( + "id = \"{}\"\nname = \"admin\"\nkey = \"{secret}\"\npermissions = [\"admin\"]\n", + "24".repeat(16) + ), + ) + .unwrap(); + let config: Config = toml::from_str(&format!( + r#" + [node] + network = "regtest" + + [storage.disk] + dir_path = "{}" + "#, + directory.display() + )) + .unwrap(); + + assert_eq!(resolve_api_key(None, Some(&config)).unwrap(), Some(secret)); + let admin_path = admin_directory.join("admin.toml"); + for contents in [ + // Not valid TOML syntax. + "not valid toml".to_string(), + // The key must contain exactly 64 hexadecimal characters. + "key = \"short\"".to_string(), + // The length is correct, but z is not a hexadecimal character. + format!("key = \"{}\"", "z".repeat(64)), + // Valid TOML and key, but the file exceeds the size limit. + format!( + "key = \"{}\"\n#{}", + "42".repeat(32), + "x".repeat(super::API_KEY_FILE_SIZE_LIMIT) + ), + ] { + fs::write(&admin_path, contents).unwrap(); + assert!(resolve_api_key(None, Some(&config)).is_err()); + } + fs::remove_file(&admin_path).unwrap(); + assert_eq!(super::read_admin_api_key(&admin_path).unwrap(), None); + + fs::remove_dir_all(directory).unwrap(); + } } diff --git a/ldk-server-client/src/error.rs b/ldk-server-client/src/error.rs index bbccd40b..e934a522 100644 --- a/ldk-server-client/src/error.rs +++ b/ldk-server-client/src/error.rs @@ -47,6 +47,9 @@ pub enum LdkServerErrorCode { /// Please refer to [`ldk_server_grpc::error::ErrorCode::AuthError`]. AuthError, + /// The credentials are valid, but lack the permission required by this RPC. + AuthorizationError, + /// Please refer to [`ldk_server_grpc::error::ErrorCode::LightningError`]. LightningError, @@ -63,6 +66,7 @@ impl fmt::Display for LdkServerErrorCode { match self { LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"), LdkServerErrorCode::AuthError => write!(f, "AuthError"), + LdkServerErrorCode::AuthorizationError => write!(f, "AuthorizationError"), LdkServerErrorCode::LightningError => write!(f, "LightningError"), LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"), LdkServerErrorCode::InternalError => write!(f, "InternalError"), diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index bbb7c756..86d462ee 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -1437,3 +1437,94 @@ pub struct DecodeOfferResponse { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SubscribeEventsRequest {} +/// Public metadata for an API key. The secret is never included. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ApiKey { + /// The stable, hex-encoded identifier used to look up the key. + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// The human-readable name assigned when the key was created. + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, + /// The capabilities granted to the key. + #[prost(string, repeated, tag = "3")] + pub permissions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// Create an API key with the specified capabilities. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateApiKeyRequest { + /// A unique human-readable name. + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + /// The capabilities to grant. Use "admin" by itself for unrestricted access. + #[prost(string, repeated, tag = "2")] + pub permissions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// The created key and its secret. The secret is returned only once. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct CreateApiKeyResponse { + #[prost(message, optional, tag = "1")] + pub api_key: ::core::option::Option, + #[prost(string, tag = "2")] + pub secret: ::prost::alloc::string::String, +} +/// List API keys without returning their secrets. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListApiKeysRequest {} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListApiKeysResponse { + #[prost(message, repeated, tag = "1")] + pub api_keys: ::prost::alloc::vec::Vec, +} +/// Revoke an API key by ID. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RevokeApiKeyRequest { + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, +} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RevokeApiKeyResponse {} +/// Return metadata and permissions for the calling API key. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPermissionsRequest {} +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetPermissionsResponse { + #[prost(message, optional, tag = "1")] + pub api_key: ::core::option::Option, +} diff --git a/ldk-server-grpc/src/auth.rs b/ldk-server-grpc/src/auth.rs new file mode 100644 index 00000000..e6146153 --- /dev/null +++ b/ldk-server-grpc/src/auth.rs @@ -0,0 +1,39 @@ +// 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. + +/// Write the authentication preimage to a hash engine or byte sink without allocating. +/// The key ID is fixed-width; the method is length-prefixed and the timestamp is big-endian. +pub fn write_auth_preimage( + key_id: &str, method: &str, timestamp: u64, body: &[u8], mut write: impl FnMut(&[u8]), +) { + write(b"ldk-server-auth-v1"); + write(key_id.as_bytes()); + write(&(method.len() as u64).to_be_bytes()); + write(method.as_bytes()); + write(×tamp.to_be_bytes()); + write(body); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preimage_matches_wire_format() { + let mut bytes = Vec::new(); + write_auth_preimage( + "0123456789abcdef0123456789abcdef", + "GetNodeInfo", + 7, + b"body", + |part| bytes.extend_from_slice(part), + ); + assert_eq!(bytes, b"ldk-server-auth-v10123456789abcdef0123456789abcdef\x00\x00\x00\x00\x00\x00\x00\x0bGetNodeInfo\x00\x00\x00\x00\x00\x00\x00\x07body"); + } +} diff --git a/ldk-server-grpc/src/endpoints.rs b/ldk-server-grpc/src/endpoints.rs index 2314dd17..56766342 100644 --- a/ldk-server-grpc/src/endpoints.rs +++ b/ldk-server-grpc/src/endpoints.rs @@ -54,3 +54,7 @@ pub const DECODE_INVOICE_PATH: &str = "DecodeInvoice"; pub const DECODE_OFFER_PATH: &str = "DecodeOffer"; pub const GET_METRICS_PATH: &str = "metrics"; pub const SUBSCRIBE_EVENTS_PATH: &str = "SubscribeEvents"; +pub const CREATE_API_KEY_PATH: &str = "CreateApiKey"; +pub const LIST_API_KEYS_PATH: &str = "ListApiKeys"; +pub const REVOKE_API_KEY_PATH: &str = "RevokeApiKey"; +pub const GET_PERMISSIONS_PATH: &str = "GetPermissions"; diff --git a/ldk-server-grpc/src/grpc.rs b/ldk-server-grpc/src/grpc.rs index 59d15764..06deb959 100644 --- a/ldk-server-grpc/src/grpc.rs +++ b/ldk-server-grpc/src/grpc.rs @@ -18,6 +18,7 @@ use bytes::{BufMut, Bytes, BytesMut}; pub const GRPC_STATUS_OK: u32 = 0; pub const GRPC_STATUS_INVALID_ARGUMENT: u32 = 3; pub const GRPC_STATUS_DEADLINE_EXCEEDED: u32 = 4; +pub const GRPC_STATUS_PERMISSION_DENIED: u32 = 7; pub const GRPC_STATUS_FAILED_PRECONDITION: u32 = 9; pub const GRPC_STATUS_UNIMPLEMENTED: u32 = 12; pub const GRPC_STATUS_INTERNAL: u32 = 13; diff --git a/ldk-server-grpc/src/lib.rs b/ldk-server-grpc/src/lib.rs index 69ef1f8a..996f8602 100644 --- a/ldk-server-grpc/src/lib.rs +++ b/ldk-server-grpc/src/lib.rs @@ -10,10 +10,12 @@ #![doc = include_str!("../README.md")] pub mod api; +pub mod auth; pub mod endpoints; pub mod error; pub mod events; pub mod grpc; +pub mod permissions; #[cfg(feature = "serde")] pub mod serde_utils; pub mod types; diff --git a/ldk-server-grpc/src/permissions.rs b/ldk-server-grpc/src/permissions.rs new file mode 100644 index 00000000..c8fc8c3e --- /dev/null +++ b/ldk-server-grpc/src/permissions.rs @@ -0,0 +1,80 @@ +// 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. + +pub const ADMIN_PERMISSION: &str = "admin"; +pub const NODE_READ_PERMISSION: &str = "node:read"; +pub const ONCHAIN_RECEIVE_PERMISSION: &str = "onchain:receive"; +pub const ONCHAIN_SEND_PERMISSION: &str = "onchain:send"; +pub const INVOICES_CREATE_PERMISSION: &str = "invoices:create"; +pub const PAYMENTS_READ_PERMISSION: &str = "payments:read"; +pub const PAYMENTS_CLAIM_PERMISSION: &str = "payments:claim"; +pub const PAYMENTS_SEND_PERMISSION: &str = "payments:send"; +pub const CHANNELS_READ_PERMISSION: &str = "channels:read"; +pub const CHANNELS_SPLICE_PERMISSION: &str = "channels:splice"; +pub const CHANNELS_MANAGE_PERMISSION: &str = "channels:manage"; +pub const CHANNELS_FORCE_CLOSE_PERMISSION: &str = "channels:force_close"; +pub const PEERS_READ_PERMISSION: &str = "peers:read"; +pub const PEERS_MANAGE_PERMISSION: &str = "peers:manage"; +pub const MESSAGES_SIGN_PERMISSION: &str = "messages:sign"; +pub const MESSAGES_VERIFY_PERMISSION: &str = "messages:verify"; +pub const GRAPH_READ_PERMISSION: &str = "graph:read"; +pub const UTILITIES_READ_PERMISSION: &str = "utilities:read"; +pub const EVENTS_READ_PERMISSION: &str = "events:read"; +pub const API_KEYS_MANAGE_PERMISSION: &str = "api_keys:manage"; + +/// All permissions accepted when an API key is created. +pub const ALL_PERMISSIONS: [&str; 20] = [ + ADMIN_PERMISSION, + NODE_READ_PERMISSION, + ONCHAIN_RECEIVE_PERMISSION, + ONCHAIN_SEND_PERMISSION, + INVOICES_CREATE_PERMISSION, + PAYMENTS_READ_PERMISSION, + PAYMENTS_CLAIM_PERMISSION, + PAYMENTS_SEND_PERMISSION, + CHANNELS_READ_PERMISSION, + CHANNELS_MANAGE_PERMISSION, + CHANNELS_SPLICE_PERMISSION, + CHANNELS_FORCE_CLOSE_PERMISSION, + PEERS_READ_PERMISSION, + PEERS_MANAGE_PERMISSION, + MESSAGES_SIGN_PERMISSION, + MESSAGES_VERIFY_PERMISSION, + GRAPH_READ_PERMISSION, + UTILITIES_READ_PERMISSION, + EVENTS_READ_PERMISSION, + API_KEYS_MANAGE_PERMISSION, +]; + +/// Permissions included in the CLI `readonly` preset. +pub const READONLY_PERMISSIONS: [&str; 8] = [ + NODE_READ_PERMISSION, + PAYMENTS_READ_PERMISSION, + CHANNELS_READ_PERMISSION, + PEERS_READ_PERMISSION, + MESSAGES_VERIFY_PERMISSION, + GRAPH_READ_PERMISSION, + UTILITIES_READ_PERMISSION, + EVENTS_READ_PERMISSION, +]; + +/// Permissions included in the CLI `invoice` preset. +pub const INVOICE_PERMISSIONS: [&str; 11] = [ + NODE_READ_PERMISSION, + ONCHAIN_RECEIVE_PERMISSION, + INVOICES_CREATE_PERMISSION, + PAYMENTS_READ_PERMISSION, + PAYMENTS_CLAIM_PERMISSION, + CHANNELS_READ_PERMISSION, + PEERS_READ_PERMISSION, + MESSAGES_VERIFY_PERMISSION, + GRAPH_READ_PERMISSION, + UTILITIES_READ_PERMISSION, + EVENTS_READ_PERMISSION, +]; diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index 2f3bbab8..a3251687 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -1032,6 +1032,54 @@ message DecodeOfferResponse { // Node automatically fails the HTLC backward at its claim_deadline. message SubscribeEventsRequest {} +// Public metadata for an API key. The secret is never included. +message ApiKey { + // The stable, hex-encoded identifier used to look up the key. + string id = 1; + + // The human-readable name assigned when the key was created. + string name = 2; + + // The capabilities granted to the key. + repeated string permissions = 3; +} + +// Create an API key with the specified capabilities. +message CreateApiKeyRequest { + // A unique human-readable name. + string name = 1; + + // The capabilities to grant. Use "admin" by itself for unrestricted access. + repeated string permissions = 2; +} + +// The created key and its secret. The secret is returned only once. +message CreateApiKeyResponse { + ApiKey api_key = 1; + string secret = 2; +} + +// List API keys without returning their secrets. +message ListApiKeysRequest {} + +message ListApiKeysResponse { + repeated ApiKey api_keys = 1; +} + +// Revoke an API key by ID. +message RevokeApiKeyRequest { + string id = 1; +} + +message RevokeApiKeyResponse {} + +// Return metadata and permissions for the calling API key. +message GetPermissionsRequest {} + +message GetPermissionsResponse { + ApiKey api_key = 1; +} + service LightningNode { // Retrieve the latest node info. rpc GetNodeInfo(GetNodeInfoRequest) returns (GetNodeInfoResponse); @@ -1118,4 +1166,12 @@ service LightningNode { rpc GraphGetNode(GraphGetNodeRequest) returns (GraphGetNodeResponse); // Subscribe to a stream of server events. rpc SubscribeEvents(SubscribeEventsRequest) returns (stream events.EventEnvelope); + // Create an API key. Requires api_keys:manage or admin permission. + rpc CreateApiKey(CreateApiKeyRequest) returns (CreateApiKeyResponse); + // List API keys. Requires api_keys:manage or admin permission. + rpc ListApiKeys(ListApiKeysRequest) returns (ListApiKeysResponse); + // Revoke an API key. Requires api_keys:manage or admin permission. + rpc RevokeApiKey(RevokeApiKeyRequest) returns (RevokeApiKeyResponse); + // Return permissions for the calling key. + rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse); } diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 0a17e8b8..5aac13ed 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -44,7 +44,7 @@ The server reads configuration in this precedence order (highest first): 1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` 2. **CLI argument**: `--config ` pointing to a TOML file -3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_key` +3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_keys/admin.toml` If no config path is provided explicitly, the crate uses the default `ldk-server` config location at `~/.ldk-server/config.toml`. diff --git a/ldk-server-mcp/README.md b/ldk-server-mcp/README.md index 3d958a3d..879d35c0 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -19,7 +19,7 @@ The server reads configuration in this precedence order (highest wins): 1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` 2. **CLI argument**: `--config ` pointing to a TOML config file -3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_key` +3. **Default paths**: `~/.ldk-server/config.toml`, `~/.ldk-server/tls.crt`, `~/.ldk-server/{network}/api_keys/admin.toml` The TOML config format is the same as used by [ `ldk-server-cli`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-cli): diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs index f8c066d9..3381705d 100644 --- a/ldk-server-mcp/src/config.rs +++ b/ldk-server-mcp/src/config.rs @@ -41,7 +41,7 @@ pub fn resolve_config(config_path: Option) -> Result "Invalid params", INTERNAL_ERROR => "Internal error", + AUTHENTICATION_ERROR => "Authentication error", + PERMISSION_DENIED => "Permission denied", _ => "Error", } } @@ -47,8 +51,9 @@ impl From for McpError { fn from(e: LdkServerError) -> Self { let code = match e.error_code { LdkServerErrorCode::InvalidRequestError => INVALID_PARAMS, - LdkServerErrorCode::AuthError - | LdkServerErrorCode::LightningError + LdkServerErrorCode::AuthError => AUTHENTICATION_ERROR, + LdkServerErrorCode::AuthorizationError => PERMISSION_DENIED, + LdkServerErrorCode::LightningError | LdkServerErrorCode::InternalServerError | LdkServerErrorCode::InternalError => INTERNAL_ERROR, }; @@ -98,3 +103,22 @@ impl JsonRpcErrorResponse { Self { jsonrpc: "2.0".to_string(), id, error: JsonRpcError { code, message, data: None } } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preserves_authentication_and_permission_errors() { + let auth = McpError::from(LdkServerError::new(LdkServerErrorCode::AuthError, "bad key")); + let permission = McpError::from(LdkServerError::new( + LdkServerErrorCode::AuthorizationError, + "missing scope", + )); + assert_eq!(auth.code, AUTHENTICATION_ERROR); + assert_eq!(auth.category(), "Authentication error"); + assert_eq!(permission.code, PERMISSION_DENIED); + assert_eq!(permission.category(), "Permission denied"); + assert_eq!(permission.message, "missing scope"); + } +} diff --git a/ldk-server-mcp/src/tools/handlers.rs b/ldk-server-mcp/src/tools/handlers.rs index 7c81168b..53ae60fe 100644 --- a/ldk-server-mcp/src/tools/handlers.rs +++ b/ldk-server-mcp/src/tools/handlers.rs @@ -15,13 +15,14 @@ use ldk_server_client::ldk_server_grpc::api::{ Bolt11ReceiveViaJitChannelRequest, Bolt11SendRequest, Bolt11SendUnderpayingRequest, Bolt12CreatePayerProofRequest, Bolt12ReceiveRefundRequest, Bolt12ReceiveRequest, Bolt12SendRefundRequest, Bolt12SendRequest, CloseChannelRequest, ConnectPeerRequest, - DecodeInvoiceRequest, DecodeOfferRequest, DisconnectPeerRequest, + CreateApiKeyRequest, DecodeInvoiceRequest, DecodeOfferRequest, DisconnectPeerRequest, ExportPathfindingScoresRequest, ForceCloseChannelRequest, GetBalancesRequest, - GetNodeInfoRequest, GetPaymentDetailsRequest, GraphGetChannelRequest, GraphGetNodeRequest, - GraphListChannelsRequest, GraphListNodesRequest, ListChannelsRequest, - ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, OnchainReceiveRequest, - OnchainSendRequest, OpenChannelRequest, SignMessageRequest, SpliceInRequest, SpliceOutRequest, - SpontaneousSendRequest, UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, + GetNodeInfoRequest, GetPaymentDetailsRequest, GetPermissionsRequest, GraphGetChannelRequest, + GraphGetNodeRequest, GraphListChannelsRequest, GraphListNodesRequest, ListApiKeysRequest, + ListChannelsRequest, ListForwardedPaymentsRequest, ListPaymentsRequest, ListPeersRequest, + OnchainReceiveRequest, OnchainSendRequest, OpenChannelRequest, RevokeApiKeyRequest, + SignMessageRequest, SpliceInRequest, SpliceOutRequest, SpontaneousSendRequest, + UnifiedSendRequest, UpdateChannelConfigRequest, VerifySignatureRequest, }; use ldk_server_client::ldk_server_grpc::types::RouteParametersConfig; use ldk_server_client::{ @@ -120,6 +121,38 @@ where Ok(request) } +pub async fn handle_create_api_key( + client: &LdkServerClient, args: Value, +) -> Result { + let request: CreateApiKeyRequest = parse_request(args)?; + let response = client.create_api_key(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_api_keys( + client: &LdkServerClient, args: Value, +) -> Result { + let request: ListApiKeysRequest = parse_request(args)?; + let response = client.list_api_keys(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_revoke_api_key( + client: &LdkServerClient, args: Value, +) -> Result { + let request: RevokeApiKeyRequest = parse_request(args)?; + let response = client.revoke_api_key(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_get_permissions( + client: &LdkServerClient, args: Value, +) -> Result { + let request: GetPermissionsRequest = parse_request(args)?; + let response = client.get_permissions(request).await.map_err(McpError::from)?; + serialize_response(response) +} + pub async fn handle_get_node_info( client: &LdkServerClient, _args: Value, ) -> Result { @@ -481,13 +514,27 @@ pub async fn handle_graph_get_node( #[cfg(test)] mod tests { use ldk_server_client::ldk_server_grpc::api::{ - onchain_send_request, open_channel_request, splice_in_request, + onchain_send_request, open_channel_request, splice_in_request, CreateApiKeyRequest, + RevokeApiKeyRequest, }; use super::*; const NODE_PUBKEY: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + #[test] + fn parses_api_key_management_arguments() { + let request: CreateApiKeyRequest = + parse_request(json!({"name": "reader", "permissions": ["node:read"]})).unwrap(); + assert_eq!(request.name, "reader"); + assert_eq!(request.permissions, vec!["node:read"]); + assert!(parse_request::( + json!({"name": "reader", "permissions": "node:read"}) + ) + .is_err()); + assert!(parse_request::(json!({"id": 123})).is_err()); + } + #[test] fn parse_request_with_amount_accepts_all() { let request: OpenChannelRequest = parse_request_with_amount( diff --git a/ldk-server-mcp/src/tools/mod.rs b/ldk-server-mcp/src/tools/mod.rs index 33d31e7e..6c30c416 100644 --- a/ldk-server-mcp/src/tools/mod.rs +++ b/ldk-server-mcp/src/tools/mod.rs @@ -69,6 +69,30 @@ impl ToolRegistry { pub fn build_tool_registry() -> ToolRegistry { let tools = vec![ + tool_spec( + "get_permissions", + "Get the current API key metadata and permissions", + schema::get_permissions_schema, + |client, args| Box::pin(handlers::handle_get_permissions(client, args)), + ), + tool_spec( + "revoke_api_key", + "Revoke an API key for new requests", + schema::revoke_api_key_schema, + |client, args| Box::pin(handlers::handle_revoke_api_key(client, args)), + ), + tool_spec( + "list_api_keys", + "List API key metadata without secrets", + schema::list_api_keys_schema, + |client, args| Box::pin(handlers::handle_list_api_keys(client, args)), + ), + tool_spec( + "create_api_key", + "Create an API key with scoped permissions and return its secret once", + schema::create_api_key_schema, + |client, args| Box::pin(handlers::handle_create_api_key(client, args)), + ), tool_spec( "get_node_info", "Retrieve node info including node_id, sync status, and best block", diff --git a/ldk-server-mcp/src/tools/schema.rs b/ldk-server-mcp/src/tools/schema.rs index 9cf732d1..41807010 100644 --- a/ldk-server-mcp/src/tools/schema.rs +++ b/ldk-server-mcp/src/tools/schema.rs @@ -137,6 +137,35 @@ fn page_token_schema() -> Value { }) } +pub fn create_api_key_schema() -> Value { + json!({ + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]+$"}, + "permissions": {"type": "array", "minItems": 1, "items": { + "type": "string", "enum": ldk_server_client::ldk_server_grpc::permissions::ALL_PERMISSIONS + }, "description": "Capabilities to grant. Use admin by itself for unrestricted access."} + }, + "required": ["name", "permissions"] + }) +} + +pub fn list_api_keys_schema() -> Value { + json!({"type": "object", "properties": {}, "required": []}) +} + +pub fn revoke_api_key_schema() -> Value { + json!({ + "type": "object", + "properties": {"id": {"type": "string", "pattern": "^[0-9a-fA-F]{32}$"}}, + "required": ["id"] + }) +} + +pub fn get_permissions_schema() -> Value { + json!({"type": "object", "properties": {}, "required": []}) +} + pub fn get_node_info_schema() -> Value { json!({ "type": "object", "properties": {}, "required": [] }) } diff --git a/ldk-server-mcp/tests/integration.rs b/ldk-server-mcp/tests/integration.rs index e05ae499..6ca20183 100644 --- a/ldk-server-mcp/tests/integration.rs +++ b/ldk-server-mcp/tests/integration.rs @@ -11,7 +11,7 @@ use std::io::{BufRead, BufReader, Write}; use serde_json::{json, Value}; -const NUM_TOOLS: usize = 41; +const NUM_TOOLS: usize = 45; const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "bolt11_claim_for_id", "bolt11_fail_for_id", @@ -28,6 +28,7 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "bolt12_send_refund", "close_channel", "connect_peer", + "create_api_key", "decode_invoice", "decode_offer", "disconnect_peer", @@ -36,10 +37,12 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "get_balances", "get_node_info", "get_payment_details", + "get_permissions", "graph_get_channel", "graph_get_node", "graph_list_channels", "graph_list_nodes", + "list_api_keys", "list_channels", "list_forwarded_payments", "list_payments", @@ -47,6 +50,7 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "onchain_receive", "onchain_send", "open_channel", + "revoke_api_key", "sign_message", "splice_in", "splice_out", @@ -181,6 +185,26 @@ fn test_tools_list() { EXPECTED_TOOLS.iter().map(|name| name.to_string()).collect::>(); expected_tool_names.sort(); assert_eq!(tool_names, expected_tool_names, "Tool names drifted from the expected API surface"); + let mut unary_rpc_tools: Vec<_> = include_str!("../../ldk-server-grpc/src/proto/api.proto") + .lines() + .filter_map(|line| { + let mut words = line.split_whitespace(); + if words.next() != Some("rpc") || line.contains("returns (stream ") { + return None; + } + let method = words.next().unwrap().split('(').next().unwrap(); + let mut name = String::new(); + for (index, ch) in method.chars().enumerate() { + if index > 0 && ch.is_ascii_uppercase() { + name.push('_'); + } + name.push(ch.to_ascii_lowercase()); + } + Some(name) + }) + .collect(); + unary_rpc_tools.sort(); + assert_eq!(tool_names, unary_rpc_tools, "Every unary RPC must have an MCP tool"); for tool in tools { assert!(tool["name"].is_string(), "Tool missing name"); diff --git a/ldk-server/src/api/error.rs b/ldk-server/src/api/error.rs index b28c22a8..a1216fd8 100644 --- a/ldk-server/src/api/error.rs +++ b/ldk-server/src/api/error.rs @@ -47,6 +47,9 @@ pub(crate) enum LdkServerErrorCode { /// Please refer to [`protos::error::ErrorCode::AuthError`]. AuthError, + /// The request was authenticated, but the key does not have the required permission. + AuthorizationError, + /// Please refer to [`protos::error::ErrorCode::LightningError`]. LightningError, @@ -59,6 +62,7 @@ impl fmt::Display for LdkServerErrorCode { match self { LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"), LdkServerErrorCode::AuthError => write!(f, "AuthError"), + LdkServerErrorCode::AuthorizationError => write!(f, "AuthorizationError"), LdkServerErrorCode::LightningError => write!(f, "LightningError"), LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"), } diff --git a/ldk-server/src/api_keys.rs b/ldk-server/src/api_keys.rs new file mode 100644 index 00000000..c51cb997 --- /dev/null +++ b/ldk-server/src/api_keys.rs @@ -0,0 +1,1158 @@ +// 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::{BTreeSet, HashMap}; +use std::fs::{self, File}; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, RwLock}; + +use hex::DisplayHex; +use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; +use ldk_node::bitcoin::hashes::{sha256, Hash, HashEngine}; +use ldk_server_grpc::endpoints::{ + BOLT11_CLAIM_FOR_ID_PATH, BOLT11_FAIL_FOR_ID_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, + BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, + BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, + BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, + BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, + CREATE_API_KEY_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_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_API_KEYS_PATH, + LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, + ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_API_KEY_PATH, + SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, + SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, +}; +use ldk_server_grpc::permissions::{ + ADMIN_PERMISSION, ALL_PERMISSIONS, API_KEYS_MANAGE_PERMISSION, CHANNELS_FORCE_CLOSE_PERMISSION, + CHANNELS_MANAGE_PERMISSION, CHANNELS_READ_PERMISSION, CHANNELS_SPLICE_PERMISSION, + EVENTS_READ_PERMISSION, GRAPH_READ_PERMISSION, INVOICES_CREATE_PERMISSION, + MESSAGES_SIGN_PERMISSION, MESSAGES_VERIFY_PERMISSION, NODE_READ_PERMISSION, + ONCHAIN_RECEIVE_PERMISSION, ONCHAIN_SEND_PERMISSION, PAYMENTS_CLAIM_PERMISSION, + PAYMENTS_READ_PERMISSION, PAYMENTS_SEND_PERMISSION, PEERS_MANAGE_PERMISSION, + PEERS_READ_PERMISSION, UTILITIES_READ_PERMISSION, +}; +use serde::Deserialize; + +use crate::api::error::{LdkServerError, LdkServerErrorCode}; +use crate::util::{create_dir_all_private, read_to_string_with_limit, write_new}; + +const API_KEY_FILE_SIZE_LIMIT: usize = 4096; +const API_KEYS_DIR: &str = "api_keys"; +const ADMIN_KEY_FILE: &str = "admin.toml"; +const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ApiKeyInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) permissions: BTreeSet, +} + +impl ApiKeyInfo { + pub(crate) fn is_admin(&self) -> bool { + self.permissions.contains(ADMIN_PERMISSION) + } + + pub(crate) fn allows(&self, permission: &str) -> bool { + self.is_admin() || self.permissions.contains(permission) + } +} + +#[derive(Debug)] +pub(crate) struct CreatedApiKey { + pub(crate) info: ApiKeyInfo, + pub(crate) secret: String, +} + +#[derive(Debug)] +struct ApiKeyRecord { + info: Arc, + secret: String, + path: PathBuf, +} + +#[derive(Deserialize)] +struct StoredApiKey { + id: String, + name: String, + key: String, + permissions: Vec, +} + +pub(crate) struct ApiKeyStore { + keys: RwLock>>, + management: Mutex<()>, + directory: PathBuf, +} + +impl ApiKeyStore { + pub(crate) fn load_or_create(storage_dir: &Path) -> io::Result { + let directory = storage_dir.join(API_KEYS_DIR); + create_dir_all_private(&directory)?; + fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?; + + let mut store = + Self { keys: RwLock::new(HashMap::new()), management: Mutex::new(()), directory }; + store.load_key_files()?; + if store + .keys + .get_mut() + .map_err(|_| io::Error::other("API key store lock is poisoned"))? + .is_empty() + { + store.create_initial_admin()?; + } + Ok(store) + } + + fn load_key_files(&mut self) -> io::Result<()> { + let keys = + self.keys.get_mut().map_err(|_| io::Error::other("API key store lock is poisoned"))?; + for entry in fs::read_dir(&self.directory)? { + let path = entry?.path(); + if path.extension().is_none_or(|extension| extension != "toml") { + continue; + } + + let contents = read_to_string_with_limit(&path, API_KEY_FILE_SIZE_LIMIT)?; + let stored: StoredApiKey = toml::from_str(&contents).map_err(|error| { + invalid_data(format!("Failed to parse API key file {}: {error}", path.display())) + })?; + let record = record_from_stored(stored, path)?; + if keys.values().any(|existing| existing.info.name == record.info.name) { + return Err(invalid_data(format!("Duplicate API key name: {}", record.info.name))); + } + if keys.insert(record.info.id.clone(), Arc::new(record)).is_some() { + return Err(invalid_data("Duplicate API key ID")); + } + } + Ok(()) + } + + fn create_initial_admin(&mut self) -> io::Result<()> { + let secret = generate_secret()?; + let info = ApiKeyInfo { + id: compute_key_id(&secret), + name: "admin".to_string(), + permissions: BTreeSet::from([ADMIN_PERMISSION.to_string()]), + }; + let path = self.directory.join(ADMIN_KEY_FILE); + write_key_file(&path, &info, &secret)?; + self.keys + .get_mut() + .map_err(|_| io::Error::other("API key store lock is poisoned"))? + .insert(info.id.clone(), Arc::new(ApiKeyRecord { info: Arc::new(info), secret, path })); + + Ok(()) + } + + pub(crate) fn authenticate( + &self, method: &str, auth_header: Option<&str>, body: &[u8], + ) -> Result, LdkServerError> { + let auth_error = |message| LdkServerError::new(LdkServerErrorCode::AuthError, message); + let auth_header = auth_header.ok_or_else(|| auth_error("Missing x-auth metadata"))?; + let auth_data = + auth_header.strip_prefix("HMAC ").ok_or_else(|| auth_error("Invalid x-auth format"))?; + let mut parts = auth_data.split(':'); + let key_id = parts.next().ok_or_else(|| auth_error("Invalid x-auth format"))?; + let timestamp = parts + .next() + .ok_or_else(|| auth_error("Invalid x-auth format"))? + .parse::() + .map_err(|_| auth_error("Invalid timestamp"))?; + let provided_hmac = parts.next().ok_or_else(|| auth_error("Invalid x-auth format"))?; + if parts.next().is_some() || !is_hex(key_id, 32) { + return Err(auth_error("Invalid x-auth format")); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| auth_error("System time error"))? + .as_secs(); + if now.abs_diff(timestamp) > AUTH_TIMESTAMP_TOLERANCE_SECS { + return Err(auth_error("Request timestamp expired")); + } + + let record = self + .keys + .read() + .map_err(|_| key_store_lock_error())? + .get(key_id) + .cloned() + .ok_or_else(|| auth_error("Invalid credentials"))?; + let expected_hmac = compute_auth_hmac(&record.secret, key_id, method, timestamp, body); + let provided_hmac = provided_hmac + .parse::>() + .map_err(|_| auth_error("Invalid HMAC in x-auth"))?; + if expected_hmac != provided_hmac { + return Err(auth_error("Invalid credentials")); + } + + Ok(Arc::clone(&record.info)) + } + + // Call management operations from a blocking thread. Authentication never takes this mutex. + pub(crate) fn create_key( + &self, name: &str, permissions: Vec, issuer: &ApiKeyInfo, + ) -> Result { + self.create_key_with_writer(name, permissions, issuer, write_key_file) + } + + fn create_key_with_writer( + &self, name: &str, permissions: Vec, issuer: &ApiKeyInfo, + write: impl FnOnce(&Path, &ApiKeyInfo, &str) -> io::Result<()>, + ) -> Result { + validate_name(name)?; + let permissions = validate_permissions(permissions).map_err(invalid_request)?; + let _management = self.management.lock().map_err(|_| key_store_lock_error())?; + let secret = generate_secret().map_err(internal_error)?; + let info = ApiKeyInfo { id: compute_key_id(&secret), name: name.to_string(), permissions }; + { + let keys = self.keys.read().map_err(|_| key_store_lock_error())?; + if !keys.contains_key(&issuer.id) { + return Err(LdkServerError::new( + LdkServerErrorCode::AuthError, + "Invalid credentials", + )); + } + if keys.values().any(|record| record.info.name == name) { + return Err(invalid_request(format!("API key name already exists: {name}"))); + } + if !issuer.is_admin() + && info + .permissions + .iter() + .any(|permission| !issuer.permissions.contains(permission)) + { + return Err(authorization_error( + "Cannot grant a permission that the calling key does not have", + )); + } + if keys.contains_key(&info.id) { + return Err(internal_error("Generated a duplicate API key ID")); + } + } + let path = self.directory.join(format!("{}.toml", info.id)); + write(&path, &info, &secret).map_err(internal_error)?; + let record = + Arc::new(ApiKeyRecord { info: Arc::new(info.clone()), secret: secret.clone(), path }); + self.keys.write().map_err(|_| key_store_lock_error())?.insert(info.id.clone(), record); + Ok(CreatedApiKey { info, secret }) + } + + pub(crate) fn list_keys(&self) -> Result, LdkServerError> { + let records = self.keys.read().map_err(|_| key_store_lock_error())?; + let mut keys: Vec<_> = records.values().map(|record| (*record.info).clone()).collect(); + keys.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + Ok(keys) + } + + pub(crate) fn revoke_key(&self, id: &str, issuer: &ApiKeyInfo) -> Result<(), LdkServerError> { + if !is_hex(id, 32) { + return Err(invalid_request( + "API key ID must contain exactly 32 hexadecimal characters", + )); + } + let _management = self.management.lock().map_err(|_| key_store_lock_error())?; + let keys = self.keys.read().map_err(|_| key_store_lock_error())?; + if !keys.contains_key(&issuer.id) { + return Err(LdkServerError::new(LdkServerErrorCode::AuthError, "Invalid credentials")); + } + let record = + keys.get(id).ok_or_else(|| invalid_request(format!("Unknown API key ID: {id}")))?; + if !issuer.is_admin() + && (record.info.is_admin() + || record + .info + .permissions + .iter() + .any(|permission| !issuer.permissions.contains(permission))) + { + return Err(authorization_error( + "Cannot revoke a key with permissions that the calling key does not have", + )); + } + if record.info.is_admin() + && keys.values().filter(|record| record.info.is_admin()).count() == 1 + { + return Err(invalid_request("Cannot revoke the final admin API key")); + } + + let path = record.path.clone(); + drop(keys); + match fs::remove_file(path) { + Ok(()) => {}, + // The file may have been deleted manually; still revoke the key from memory. + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(internal_error(error)), + } + self.keys.write().map_err(|_| key_store_lock_error())?.remove(id); + File::open(&self.directory) + .and_then(|directory| directory.sync_all()) + .map_err(internal_error)?; + Ok(()) + } +} + +pub(crate) fn compute_key_id(secret: &str) -> String { + let hash = sha256::Hash::hash(secret.as_bytes()); + hash[..16].to_lower_hex_string() +} + +pub(crate) enum MethodAuthorization { + Permission(&'static str), + AuthenticatedOnly, + Unknown, +} + +pub(crate) fn method_authorization(method: &str) -> MethodAuthorization { + match method { + GET_NODE_INFO_PATH | GET_BALANCES_PATH | EXPORT_PATHFINDING_SCORES_PATH => { + MethodAuthorization::Permission(NODE_READ_PERMISSION) + }, + ONCHAIN_RECEIVE_PATH => MethodAuthorization::Permission(ONCHAIN_RECEIVE_PERMISSION), + ONCHAIN_SEND_PATH => MethodAuthorization::Permission(ONCHAIN_SEND_PERMISSION), + BOLT11_RECEIVE_PATH + | BOLT11_RECEIVE_FOR_HASH_PATH + | BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH + | BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH + | BOLT12_RECEIVE_PATH + | BOLT12_RECEIVE_REFUND_PATH => MethodAuthorization::Permission(INVOICES_CREATE_PERMISSION), + BOLT11_CLAIM_FOR_ID_PATH | BOLT11_FAIL_FOR_ID_PATH => { + MethodAuthorization::Permission(PAYMENTS_CLAIM_PERMISSION) + }, + BOLT11_SEND_PATH + | BOLT11_SEND_UNDERPAYING_PATH + | BOLT12_SEND_PATH + | BOLT12_SEND_REFUND_PATH + | SPONTANEOUS_SEND_PATH + | UNIFIED_SEND_PATH => MethodAuthorization::Permission(PAYMENTS_SEND_PERMISSION), + GET_PAYMENT_DETAILS_PATH | LIST_PAYMENTS_PATH | LIST_FORWARDED_PAYMENTS_PATH => { + MethodAuthorization::Permission(PAYMENTS_READ_PERMISSION) + }, + LIST_CHANNELS_PATH => MethodAuthorization::Permission(CHANNELS_READ_PERMISSION), + SPLICE_IN_PATH | SPLICE_OUT_PATH => { + MethodAuthorization::Permission(CHANNELS_SPLICE_PERMISSION) + }, + OPEN_CHANNEL_PATH | UPDATE_CHANNEL_CONFIG_PATH | CLOSE_CHANNEL_PATH => { + MethodAuthorization::Permission(CHANNELS_MANAGE_PERMISSION) + }, + FORCE_CLOSE_CHANNEL_PATH => { + MethodAuthorization::Permission(CHANNELS_FORCE_CLOSE_PERMISSION) + }, + LIST_PEERS_PATH => MethodAuthorization::Permission(PEERS_READ_PERMISSION), + CONNECT_PEER_PATH | DISCONNECT_PEER_PATH => { + MethodAuthorization::Permission(PEERS_MANAGE_PERMISSION) + }, + SIGN_MESSAGE_PATH | BOLT12_CREATE_PAYER_PROOF_PATH => { + MethodAuthorization::Permission(MESSAGES_SIGN_PERMISSION) + }, + VERIFY_SIGNATURE_PATH => MethodAuthorization::Permission(MESSAGES_VERIFY_PERMISSION), + GRAPH_LIST_CHANNELS_PATH + | GRAPH_GET_CHANNEL_PATH + | GRAPH_LIST_NODES_PATH + | GRAPH_GET_NODE_PATH => MethodAuthorization::Permission(GRAPH_READ_PERMISSION), + DECODE_INVOICE_PATH | DECODE_OFFER_PATH => { + MethodAuthorization::Permission(UTILITIES_READ_PERMISSION) + }, + SUBSCRIBE_EVENTS_PATH => MethodAuthorization::Permission(EVENTS_READ_PERMISSION), + CREATE_API_KEY_PATH | LIST_API_KEYS_PATH | REVOKE_API_KEY_PATH => { + MethodAuthorization::Permission(API_KEYS_MANAGE_PERMISSION) + }, + GET_PERMISSIONS_PATH => MethodAuthorization::AuthenticatedOnly, + _ => MethodAuthorization::Unknown, + } +} + +pub(crate) fn compute_auth_hmac( + secret: &str, key_id: &str, method: &str, timestamp: u64, body: &[u8], +) -> Hmac { + let mut engine = HmacEngine::new(secret.as_bytes()); + ldk_server_grpc::auth::write_auth_preimage(key_id, method, timestamp, body, |part| { + engine.input(part) + }); + Hmac::from_engine(engine) +} + +fn record_from_stored(stored: StoredApiKey, path: PathBuf) -> io::Result { + if !is_hex(&stored.key, 64) { + return Err(invalid_data(format!("Invalid API key in {}", path.display()))); + } + if !is_hex(&stored.id, 32) || stored.id != compute_key_id(&stored.key) { + return Err(invalid_data(format!("Invalid API key ID in {}", path.display()))); + } + validate_name_value(&stored.name).map_err(invalid_data)?; + let permissions = validate_permissions(stored.permissions).map_err(invalid_data)?; + Ok(ApiKeyRecord { + info: Arc::new(ApiKeyInfo { id: stored.id, name: stored.name, permissions }), + secret: stored.key, + path, + }) +} + +fn validate_name(name: &str) -> Result<(), LdkServerError> { + validate_name_value(name).map_err(invalid_request) +} + +fn validate_name_value(name: &str) -> Result<(), String> { + if name.is_empty() + || name.len() > 64 + || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err( + "API key name must contain 1 to 64 ASCII letters, numbers, hyphens, or underscores" + .to_string(), + ); + } + Ok(()) +} + +fn validate_permissions(permissions: Vec) -> Result, String> { + let permissions: BTreeSet<_> = permissions.into_iter().collect(); + if permissions.is_empty() { + return Err("At least one API key permission is required".to_string()); + } + for permission in &permissions { + if !ALL_PERMISSIONS.contains(&permission.as_str()) { + return Err(format!("Unknown API key permission: {permission}")); + } + } + if permissions.contains(ADMIN_PERMISSION) && permissions.len() != 1 { + return Err("The admin permission must be used by itself".to_string()); + } + Ok(permissions) +} + +fn generate_secret() -> io::Result { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).map_err(io::Error::other)?; + Ok(bytes.to_lower_hex_string()) +} + +fn write_key_file(path: &Path, info: &ApiKeyInfo, secret: &str) -> io::Result<()> { + let permissions = info + .permissions + .iter() + .map(|permission| format!("\"{permission}\"")) + .collect::>() + .join(", "); + let contents = format!( + "id = \"{}\"\nname = \"{}\"\nkey = \"{}\"\npermissions = [{}]\n", + info.id, info.name, secret, permissions + ); + + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("api-key"); + let mut suffix = [0u8; 8]; + getrandom::getrandom(&mut suffix).map_err(io::Error::other)?; + let temporary_path = path.with_file_name(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + suffix.to_lower_hex_string() + )); + let result = (|| { + write_new(&temporary_path, contents.as_bytes(), 0o400)?; + fs::rename(&temporary_path, path)?; + if let Some(directory) = path.parent() { + File::open(directory)?.sync_all()?; + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary_path); + } + result +} + +fn is_hex(value: &str, expected_length: usize) -> bool { + value.len() == expected_length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn invalid_data(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +fn invalid_request(message: impl Into) -> LdkServerError { + LdkServerError::new(LdkServerErrorCode::InvalidRequestError, message) +} + +fn authorization_error(message: impl Into) -> LdkServerError { + LdkServerError::new(LdkServerErrorCode::AuthorizationError, message) +} + +fn key_store_lock_error() -> LdkServerError { + internal_error("API key store lock is poisoned") +} + +fn internal_error(message: impl std::fmt::Display) -> LdkServerError { + LdkServerError::new(LdkServerErrorCode::InternalServerError, message.to_string()) +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicU32, Ordering}; + + use ldk_server_grpc::endpoints::{GET_BALANCES_PATH, GET_NODE_INFO_PATH}; + use ldk_server_grpc::permissions::{API_KEYS_MANAGE_PERMISSION, NODE_READ_PERMISSION}; + + use super::*; + + static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + + #[test] + fn every_rpc_has_the_expected_authorization() { + // Keep this contract independent of the production mapping. The schema comparison + // requires each new RPC to have an explicit authorization expectation here. + let expected = [ + ("GetNodeInfo", Some("node:read")), + ("GetBalances", Some("node:read")), + ("OnchainReceive", Some("onchain:receive")), + ("OnchainSend", Some("onchain:send")), + ("Bolt11Receive", Some("invoices:create")), + ("Bolt11ReceiveForHash", Some("invoices:create")), + ("Bolt11ClaimForId", Some("payments:claim")), + ("Bolt11FailForId", Some("payments:claim")), + ("Bolt11ReceiveViaJitChannel", Some("invoices:create")), + ("Bolt11ReceiveVariableAmountViaJitChannel", Some("invoices:create")), + ("Bolt11Send", Some("payments:send")), + ("Bolt11SendUnderpaying", Some("payments:send")), + ("Bolt12Receive", Some("invoices:create")), + ("Bolt12Send", Some("payments:send")), + ("Bolt12SendRefund", Some("payments:send")), + ("Bolt12ReceiveRefund", Some("invoices:create")), + ("Bolt12CreatePayerProof", Some("messages:sign")), + ("SpontaneousSend", Some("payments:send")), + ("OpenChannel", Some("channels:manage")), + ("SpliceIn", Some("channels:splice")), + ("SpliceOut", Some("channels:splice")), + ("UpdateChannelConfig", Some("channels:manage")), + ("CloseChannel", Some("channels:manage")), + ("ForceCloseChannel", Some("channels:force_close")), + ("ListChannels", Some("channels:read")), + ("GetPaymentDetails", Some("payments:read")), + ("ListPayments", Some("payments:read")), + ("ListForwardedPayments", Some("payments:read")), + ("ConnectPeer", Some("peers:manage")), + ("DisconnectPeer", Some("peers:manage")), + ("ListPeers", Some("peers:read")), + ("SignMessage", Some("messages:sign")), + ("VerifySignature", Some("messages:verify")), + ("ExportPathfindingScores", Some("node:read")), + ("UnifiedSend", Some("payments:send")), + ("DecodeInvoice", Some("utilities:read")), + ("DecodeOffer", Some("utilities:read")), + ("GraphListChannels", Some("graph:read")), + ("GraphGetChannel", Some("graph:read")), + ("GraphListNodes", Some("graph:read")), + ("GraphGetNode", Some("graph:read")), + ("SubscribeEvents", Some("events:read")), + ("CreateApiKey", Some("api_keys:manage")), + ("ListApiKeys", Some("api_keys:manage")), + ("RevokeApiKey", Some("api_keys:manage")), + ("GetPermissions", None), + ]; + let declared_methods: BTreeSet<_> = + include_str!("../../ldk-server-grpc/src/proto/api.proto") + .lines() + .filter_map(|line| { + let mut words = line.split_whitespace(); + if words.next() != Some("rpc") { + return None; + } + Some(words.next().expect("RPC name").split('(').next().unwrap()) + }) + .collect(); + let tested_methods: BTreeSet<_> = expected.iter().map(|(method, _)| *method).collect(); + assert_eq!(tested_methods.len(), expected.len(), "Duplicate RPC in permission table"); + assert_eq!(declared_methods, tested_methods, "Update the RPC permission test table"); + + for (method, expected_permission) in expected { + let required = match (method_authorization(method), expected_permission) { + (MethodAuthorization::Permission(actual), Some(expected)) => { + assert_eq!(actual, expected, "Incorrect permission for {method}"); + actual + }, + (MethodAuthorization::AuthenticatedOnly, None) => continue, + _ => panic!("Incorrect authorization classification for {method}"), + }; + assert!(ALL_PERMISSIONS.contains(&required), "Unknown permission for {method}"); + let mut key = ApiKeyInfo { + id: "test".to_string(), + name: "test".to_string(), + permissions: BTreeSet::new(), + }; + assert!(!key.allows(required), "Key without permissions must not access {method}"); + for permission in ALL_PERMISSIONS { + key.permissions = BTreeSet::from([permission.to_string()]); + assert_eq!( + key.allows(required), + permission == "admin" || permission == required, + "Unexpected access to {method} with {permission}" + ); + } + } + } + + #[test] + fn creates_initial_admin_key() { + let directory = test_directory("initial-admin"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let keys = store.list_keys().unwrap(); + + assert_eq!(keys.len(), 1); + assert_eq!(keys[0].name, "admin"); + assert!(keys[0].is_admin()); + let admin_path = directory.join(API_KEYS_DIR).join(ADMIN_KEY_FILE); + assert!(admin_path.exists()); + assert_eq!(fs::metadata(admin_path).unwrap().permissions().mode() & 0o777, 0o400); + assert_eq!( + fs::metadata(directory.join(API_KEYS_DIR)).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn load_api_key_rejects_oversized_toml() { + let directory = test_directory("oversized-key-toml"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let path = directory.join(API_KEYS_DIR).join("oversized.toml"); + fs::write(&path, vec![b' '; API_KEY_FILE_SIZE_LIMIT + 1]).unwrap(); + drop(store); + let error = ApiKeyStore::load_or_create(&directory).err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains("exceeds")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn load_rejects_duplicate_key_names_and_ids() { + for duplicate in ["name", "ID"] { + let directory = test_directory("duplicate-key"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let (mut info, mut secret) = { + let keys = store.keys.read().unwrap(); + let admin = keys.values().next().unwrap(); + ((*admin.info).clone(), admin.secret.clone()) + }; + if duplicate == "name" { + // Same name, but a different valid secret and ID. + secret = generate_secret().unwrap(); + info.id = compute_key_id(&secret); + } else { + // Same secret and ID, but a different valid name. + info.name = "another-admin".to_string(); + } + write_key_file(&directory.join(API_KEYS_DIR).join("duplicate.toml"), &info, &secret) + .unwrap(); + drop(store); + + let error = ApiKeyStore::load_or_create(&directory).err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains(&format!("Duplicate API key {duplicate}"))); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn creates_lists_revokes_and_reloads_key() { + let directory = test_directory("lifecycle"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let created = + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + + assert_eq!(store.list_keys().unwrap().len(), 2); + assert!(created.info.allows(NODE_READ_PERMISSION)); + assert!(!created.info.is_admin()); + drop(store); + + let reloaded = ApiKeyStore::load_or_create(&directory).unwrap(); + assert_eq!(reloaded.list_keys().unwrap().len(), 2); + reloaded.revoke_key(&created.info.id, &admin).unwrap(); + assert_eq!(reloaded.list_keys().unwrap(), vec![admin]); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn revokes_key_when_its_file_is_missing() { + let directory = test_directory("revoke-missing-file"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let reader = + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + let timestamp = now(); + let signature = + compute_auth_hmac(&reader.secret, &reader.info.id, GET_NODE_INFO_PATH, timestamp, b""); + let header = format!("HMAC {}:{timestamp}:{signature}", reader.info.id); + fs::remove_file(directory.join(API_KEYS_DIR).join(format!("{}.toml", reader.info.id))) + .unwrap(); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&header), b"").is_ok()); + + store.revoke_key(&reader.info.id, &admin).unwrap(); + assert_eq!( + store.authenticate(GET_NODE_INFO_PATH, Some(&header), b"").unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + assert_eq!(store.list_keys().unwrap(), vec![admin.clone()]); + assert_eq!( + ApiKeyStore::load_or_create(&directory).unwrap().list_keys().unwrap(), + vec![admin] + ); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn revoke_rejects_malformed_ids_without_echoing_them() { + let directory = test_directory("revoke-invalid-id"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + for id in [String::new(), "a".repeat(31), "a".repeat(33), "z".repeat(32), "a".repeat(8192)] + { + let error = store.revoke_key(&id, &admin).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::InvalidRequestError); + assert_eq!(error.message, "API key ID must contain exactly 32 hexadecimal characters"); + } + assert_eq!(store.list_keys().unwrap(), vec![admin]); + fs::remove_dir_all(directory).unwrap(); + } + + #[tokio::test] + async fn authentication_continues_during_key_file_write() { + use std::time::Duration; + let directory = test_directory("slow-key-write"); + let store = Arc::new(ApiKeyStore::load_or_create(&directory).unwrap()); + let admin = store.list_keys().unwrap().remove(0); + let reader = + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + let timestamp = now(); + let signature = + compute_auth_hmac(&reader.secret, &reader.info.id, GET_NODE_INFO_PATH, timestamp, b""); + let header = format!("HMAC {}:{timestamp}:{signature}", reader.info.id); + let first = store.authenticate(GET_NODE_INFO_PATH, Some(&header), b"").unwrap(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + let writer_store = Arc::clone(&store); + let writer = tokio::task::spawn_blocking(move || { + writer_store.create_key_with_writer( + "pending", + vec![NODE_READ_PERMISSION.to_string()], + &admin, + |path, info, secret| { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + write_key_file(path, info, secret) + }, + ) + }); + started_rx.await.unwrap(); + let auth_store = Arc::clone(&store); + let auth = tokio::task::spawn_blocking(move || { + let key = auth_store.authenticate(GET_NODE_INFO_PATH, Some(&header), b"").unwrap(); + assert!(!auth_store.list_keys().unwrap().iter().any(|key| key.name == "pending")); + key + }); + let result = tokio::time::timeout(Duration::from_secs(1), auth).await; + // Release the writer even if authentication timed out, so the test cannot hang. + release_tx.send(()).unwrap(); + writer.await.unwrap().unwrap(); + let second = result.expect("Authentication waited for disk I/O").unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + assert!(store.list_keys().unwrap().iter().any(|key| key.name == "pending")); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn failed_key_write_does_not_publish_key() { + let directory = test_directory("failed-key-write"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let error = store + .create_key_with_writer( + "reader", + vec![NODE_READ_PERMISSION.to_string()], + &admin, + |_, _, _| Err(io::Error::other("injected write failure")), + ) + .unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::InternalServerError); + assert_eq!(store.list_keys().unwrap(), vec![admin.clone()]); + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn revoked_issuer_cannot_manage_keys_with_an_old_snapshot() { + let directory = test_directory("revoked-issuer"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![API_KEYS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + let reader = + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + store.revoke_key(&manager.id, &admin).unwrap(); + assert_eq!( + store + .create_key("late", vec![NODE_READ_PERMISSION.to_string()], &manager) + .unwrap_err() + .error_code, + LdkServerErrorCode::AuthError + ); + assert_eq!( + store.revoke_key(&reader.info.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + assert!(store.list_keys().unwrap().contains(&reader.info)); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn concurrent_creates_keep_key_names_unique() { + let directory = test_directory("concurrent-create"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let barrier = std::sync::Barrier::new(4); + std::thread::scope(|scope| { + let handles: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + barrier.wait(); + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin) + }) + }) + .collect(); + let results: Vec<_> = + handles.into_iter().map(|handle| handle.join().unwrap()).collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + for error in results.into_iter().filter_map(Result::err) { + assert_eq!(error.error_code, LdkServerErrorCode::InvalidRequestError); + } + }); + let reloaded = ApiKeyStore::load_or_create(&directory).unwrap(); + assert_eq!(store.list_keys().unwrap(), reloaded.list_keys().unwrap()); + assert_eq!(reloaded.list_keys().unwrap().len(), 2); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn scoped_manager_cannot_escalate_or_revoke_admin() { + let directory = test_directory("delegation"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![API_KEYS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + + let delegated = store + .create_key("delegated", vec![NODE_READ_PERMISSION.to_string()], &manager) + .unwrap(); + assert!(delegated.info.allows(NODE_READ_PERMISSION)); + assert_eq!( + store + .create_key("escalated", vec![ADMIN_PERMISSION.to_string()], &manager) + .unwrap_err() + .error_code, + LdkServerErrorCode::AuthorizationError + ); + assert_eq!( + store.revoke_key(&admin.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn scoped_manager_revokes_only_keys_within_its_permissions() { + let directory = test_directory("scoped-revocation"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![API_KEYS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + let reader = store + .create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin) + .unwrap() + .info; + let peer = store + .create_key( + "peer", + vec![NODE_READ_PERMISSION.to_string(), PAYMENTS_SEND_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + + assert_eq!( + store.revoke_key(&peer.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); + store.revoke_key(&reader.id, &manager).unwrap(); + let keys = store.list_keys().unwrap(); + assert!(keys.contains(&peer)); + assert!(!keys.contains(&reader)); + assert_eq!(ApiKeyStore::load_or_create(&directory).unwrap().list_keys().unwrap(), keys); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn splicing_requires_its_own_permission() { + let directory = test_directory("splice-permission"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key("manager", vec![CHANNELS_MANAGE_PERMISSION.to_string()], &admin) + .unwrap() + .info; + let splicer = store + .create_key("splicer", vec![CHANNELS_SPLICE_PERMISSION.to_string()], &admin) + .unwrap() + .info; + for method in [SPLICE_IN_PATH, SPLICE_OUT_PATH] { + let MethodAuthorization::Permission(permission) = method_authorization(method) else { + panic!("Splicing must require a permission"); + }; + assert!(!manager.allows(permission)); + assert!(splicer.allows(permission)); + assert!(admin.allows(permission)); + } + assert!(store + .create_key("delegated-splicer", vec![CHANNELS_SPLICE_PERMISSION.to_string()], &manager,) + .is_err()); + let reloaded = ApiKeyStore::load_or_create(&directory).unwrap(); + assert!(reloaded.list_keys().unwrap().contains(&splicer)); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn concurrent_revocations_preserve_the_final_admin() { + let directory = test_directory("concurrent-revoke"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let first = store.list_keys().unwrap().remove(0); + let second = store + .create_key("second-admin", vec![ADMIN_PERMISSION.to_string()], &first) + .unwrap() + .info; + let barrier = std::sync::Barrier::new(2); + std::thread::scope(|scope| { + let handles: Vec<_> = [&first, &second] + .into_iter() + .map(|admin| { + let store = &store; + let barrier = &barrier; + scope.spawn(move || { + barrier.wait(); + store.revoke_key(&admin.id, admin) + }) + }) + .collect(); + let results: Vec<_> = + handles.into_iter().map(|handle| handle.join().unwrap()).collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let error = results.into_iter().find_map(Result::err).unwrap(); + assert_eq!(error.error_code, LdkServerErrorCode::InvalidRequestError); + }); + let keys = store.list_keys().unwrap(); + assert_eq!(keys.len(), 1); + assert!(keys[0].is_admin()); + assert_eq!(ApiKeyStore::load_or_create(&directory).unwrap().list_keys().unwrap(), keys); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn refuses_to_revoke_final_admin() { + let directory = test_directory("final-admin"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + + let error = store.revoke_key(&admin.id, &admin).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::InvalidRequestError); + assert!(store.keys.read().unwrap().contains_key(&admin.id)); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authentication_binds_rpc_method() { + let directory = test_directory("method-binding"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let keys = store.keys.read().unwrap(); + let record = keys.values().next().unwrap(); + let body = b"framed protobuf body"; + let timestamp = now(); + let hmac = + compute_auth_hmac(&record.secret, &record.info.id, GET_NODE_INFO_PATH, timestamp, body); + let header = format!("HMAC {}:{}:{}", record.info.id, timestamp, hmac); + + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&header), body).is_ok()); + assert_eq!( + store.authenticate(GET_BALANCES_PATH, Some(&header), body).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authentication_rejects_missing_and_malformed_headers() { + let directory = test_directory("malformed-auth"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let keys = store.keys.read().unwrap(); + let record = keys.values().next().unwrap(); + let id = &record.info.id; + let timestamp = now(); + let body = b"framed protobuf body"; + let signature = compute_auth_hmac(&record.secret, id, GET_NODE_INFO_PATH, timestamp, body); + let valid_header = format!("HMAC {id}:{timestamp}:{signature}"); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&valid_header), body).is_ok()); + + let headers = [ + None, + Some(String::new()), + Some(format!("{id}:{timestamp}:{signature}")), + Some(format!("Bearer {id}:{timestamp}:{signature}")), + Some(format!("HMAC {timestamp}:{signature}")), + Some(format!("HMAC {id}:{timestamp}")), + Some(format!("{valid_header}:extra")), + Some(format!("HMAC :{timestamp}:{signature}")), + Some(format!("HMAC invalid-id:{timestamp}:{signature}")), + Some(format!("HMAC {id}:not-a-timestamp:{signature}")), + Some(format!("HMAC {id}:18446744073709551616:{signature}")), + Some(format!("HMAC {id}:{timestamp}:")), + Some(format!("HMAC {id}:{timestamp}:deadbeef")), + Some(format!("HMAC {id}:{timestamp}:{}", "z".repeat(64))), + ]; + for (index, header) in headers.iter().enumerate() { + let error = + store.authenticate(GET_NODE_INFO_PATH, header.as_deref(), body).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::AuthError, "header case {index}"); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authentication_rejects_expired_and_future_timestamps() { + let directory = test_directory("expired-auth"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let keys = store.keys.read().unwrap(); + let record = keys.values().next().unwrap(); + let id = &record.info.id; + let body = b"framed protobuf body"; + let current_time = now(); + for timestamp in [current_time - 600, current_time + 600] { + let signature = + compute_auth_hmac(&record.secret, id, GET_NODE_INFO_PATH, timestamp, body); + let header = format!("HMAC {id}:{timestamp}:{signature}"); + let error = store.authenticate(GET_NODE_INFO_PATH, Some(&header), body).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::AuthError); + assert_eq!(error.message, "Request timestamp expired"); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authentication_rejects_wrong_secrets_and_modified_bodies() { + let directory = test_directory("modified-auth"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let keys = store.keys.read().unwrap(); + let record = keys.values().next().unwrap(); + let id = &record.info.id; + let body = b"signed request body"; + let timestamp = now(); + for (secret, request_body) in [ + ("wrong secret", body.as_slice()), + (record.secret.as_str(), b"modified request body".as_slice()), + ] { + let signature = compute_auth_hmac(secret, id, GET_NODE_INFO_PATH, timestamp, body); + let header = format!("HMAC {id}:{timestamp}:{signature}"); + let error = + store.authenticate(GET_NODE_INFO_PATH, Some(&header), request_body).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::AuthError); + assert_eq!(error.message, "Invalid credentials"); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn authentication_rejects_unknown_and_revoked_keys() { + let directory = test_directory("revoked-auth"); + let store = ApiKeyStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let reader = + store.create_key("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + let body = b"framed protobuf body"; + let timestamp = now(); + let signature = + compute_auth_hmac(&reader.secret, &reader.info.id, GET_NODE_INFO_PATH, timestamp, body); + let header = format!("HMAC {}:{timestamp}:{signature}", reader.info.id); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&header), body).is_ok()); + store.revoke_key(&reader.info.id, &admin).unwrap(); + let unknown_id = compute_key_id("unknown secret"); + let unknown_signature = + compute_auth_hmac("unknown secret", &unknown_id, GET_NODE_INFO_PATH, timestamp, body); + for header in [header, format!("HMAC {unknown_id}:{timestamp}:{unknown_signature}")] { + let error = store.authenticate(GET_NODE_INFO_PATH, Some(&header), body).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::AuthError); + assert_eq!(error.message, "Invalid credentials"); + } + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn rejects_unknown_and_mixed_admin_permissions() { + assert!(validate_permissions(vec!["unknown:permission".to_string()]).is_err()); + assert!(validate_permissions(vec![ + ADMIN_PERMISSION.to_string(), + NODE_READ_PERMISSION.to_string(), + ]) + .is_err()); + assert!(matches!( + method_authorization(GET_PERMISSIONS_PATH), + MethodAuthorization::AuthenticatedOnly + )); + assert!(matches!( + method_authorization("FutureUnclassifiedRpc"), + MethodAuthorization::Unknown + )); + } + + fn now() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() + } + + fn test_directory(name: &str) -> PathBuf { + let count = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let directory = std::env::temp_dir() + .join(format!("ldk-server-api-key-test-{name}-{}-{count}", std::process::id())); + let _ = fs::remove_dir_all(&directory); + fs::create_dir(&directory).unwrap(); + directory + } +} diff --git a/ldk-server/src/main.rs b/ldk-server/src/main.rs index 0c88fac3..d471a861 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::collections::HashSet; -use std::fs; -use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -43,6 +42,7 @@ use tokio::signal::unix::SignalKind; use tokio::sync::broadcast; use crate::api::node_to_proto_custom_tlv; +use crate::api_keys::ApiKeyStore; use crate::io::persist::paginated_kv_store::PaginatedKVStore; use crate::io::persist::sqlite_store::SqliteStore; use crate::io::persist::{ @@ -54,11 +54,9 @@ use crate::util::config::{load_config, ArgsConfig, ChainSource}; use crate::util::logger::{LogConfig, ServerLogger}; use crate::util::metrics::Metrics; use crate::util::proto_adapter::{forwarded_payment_to_proto, payment_to_proto}; +use crate::util::systemd; use crate::util::tls::get_or_generate_tls_config; -use crate::util::{create_dir_all_private, systemd, write_new}; -const API_KEY_FILE: &str = "api_key"; -const API_KEY_LEN: usize = 32; const FULL_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("GIT_HASH"), ")"); pub fn get_default_data_dir() -> Option { @@ -145,10 +143,10 @@ fn main() { }, }; - let api_key = match load_or_generate_api_key(&network_dir) { - Ok(key) => key, + let api_key_store = match ApiKeyStore::load_or_create(&network_dir) { + Ok(store) => Arc::new(store), Err(e) => { - eprintln!("Failed to load or generate API key: {e}"); + eprintln!("Failed to load or create API keys: {e}"); std::process::exit(-1); }, }; @@ -709,7 +707,7 @@ fn main() { let node_service = NodeService::new( Arc::clone(&node), Arc::clone(&paginated_store), - api_key.clone(), + Arc::clone(&api_key_store), metrics.clone(), metrics_auth_header.clone(), event_sender.clone(), @@ -965,45 +963,6 @@ fn closure_reason_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); - - let file = match fs::File::open(&api_key_path) { - Ok(file) => Some(file), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, - Err(e) => return Err(e), - }; - - if let Some(file) = file { - let mut key_bytes = Vec::with_capacity(API_KEY_LEN + 1); - file.take((API_KEY_LEN + 1) as u64).read_to_end(&mut key_bytes)?; - if key_bytes.len() != API_KEY_LEN { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidData, - format!( - "API key file '{}' must contain exactly {API_KEY_LEN} bytes", - api_key_path.display() - ), - )); - } - Ok(key_bytes.to_lower_hex_string()) - } else { - // Ensure the storage directory exists - create_dir_all_private(storage_dir)?; - - // Generate a 32-byte random API key - let mut key_bytes = [0u8; API_KEY_LEN]; - getrandom::getrandom(&mut key_bytes).map_err(std::io::Error::other)?; - - write_new(&api_key_path, &key_bytes, 0o400)?; - - debug!("Generated new API key at {}", api_key_path.display()); - Ok(key_bytes.to_lower_hex_string()) - } -} - fn build_payment_claimable_proto( payment: Payment, custom_records: &[CustomTlvRecord], claim_deadline: Option, claimable_amount_msat: u64, payment_id: String, @@ -1025,23 +984,6 @@ mod tests { use super::*; - #[test] - fn load_api_key_rejects_invalid_lengths() { - let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir() - .join(format!("ldk-server-api-key-length-{}-{nonce}", std::process::id())); - fs::create_dir_all(&dir).unwrap(); - let path = dir.join(API_KEY_FILE); - - for len in [0, 1, API_KEY_LEN - 1, API_KEY_LEN + 1] { - fs::write(&path, vec![0x42; len]).unwrap(); - let error = load_or_generate_api_key(&dir).unwrap_err(); - assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); - } - - fs::remove_dir_all(dir).unwrap(); - } - #[test] fn test_is_channel_open_failure_classification() { assert!(is_channel_open_failure(Some(&ClosureReason::FundingTimedOut))); diff --git a/ldk-server/src/service.rs b/ldk-server/src/service.rs index 8f9c7cc9..d2f3b2b7 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -15,30 +15,34 @@ use http_body_util::{BodyExt, Limited}; use hyper::body::Incoming; use hyper::service::Service; use hyper::{HeaderMap, Request, Response}; -use ldk_node::bitcoin::hashes::hmac::{Hmac, HmacEngine}; -use ldk_node::bitcoin::hashes::{sha256, Hash, HashEngine}; use ldk_node::Node; +use ldk_server_grpc::api::{ + ApiKey, CreateApiKeyRequest, CreateApiKeyResponse, GetPermissionsRequest, + GetPermissionsResponse, ListApiKeysRequest, ListApiKeysResponse, RevokeApiKeyRequest, + RevokeApiKeyResponse, +}; use ldk_server_grpc::endpoints::{ BOLT11_CLAIM_FOR_ID_PATH, BOLT11_FAIL_FOR_ID_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, BOLT11_RECEIVE_PATH, BOLT11_RECEIVE_VARIABLE_AMOUNT_VIA_JIT_CHANNEL_PATH, BOLT11_RECEIVE_VIA_JIT_CHANNEL_PATH, BOLT11_SEND_PATH, BOLT11_SEND_UNDERPAYING_PATH, BOLT12_CREATE_PAYER_PROOF_PATH, BOLT12_RECEIVE_PATH, BOLT12_RECEIVE_REFUND_PATH, BOLT12_SEND_PATH, BOLT12_SEND_REFUND_PATH, CLOSE_CHANNEL_PATH, CONNECT_PEER_PATH, - DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, EXPORT_PATHFINDING_SCORES_PATH, - FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_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, LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, - ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, - SPONTANEOUS_SEND_PATH, SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, - VERIFY_SIGNATURE_PATH, + CREATE_API_KEY_PATH, DECODE_INVOICE_PATH, DECODE_OFFER_PATH, DISCONNECT_PEER_PATH, + EXPORT_PATHFINDING_SCORES_PATH, FORCE_CLOSE_CHANNEL_PATH, GET_BALANCES_PATH, GET_METRICS_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_API_KEYS_PATH, + LIST_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, + ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_API_KEY_PATH, + SIGN_MESSAGE_PATH, SPLICE_IN_PATH, SPLICE_OUT_PATH, SPONTANEOUS_SEND_PATH, + SUBSCRIBE_EVENTS_PATH, UNIFIED_SEND_PATH, UPDATE_CHANNEL_CONFIG_PATH, VERIFY_SIGNATURE_PATH, }; use ldk_server_grpc::events::EventEnvelope; use ldk_server_grpc::grpc::{ decode_grpc_body, encode_grpc_frame, grpc_error_response, grpc_response, parse_grpc_timeout, validate_grpc_request, GrpcBody, GrpcStatus, GRPC_STATUS_DEADLINE_EXCEEDED, GRPC_STATUS_FAILED_PRECONDITION, GRPC_STATUS_INTERNAL, GRPC_STATUS_INVALID_ARGUMENT, - GRPC_STATUS_UNAUTHENTICATED, GRPC_STATUS_UNAVAILABLE, GRPC_STATUS_UNIMPLEMENTED, + GRPC_STATUS_PERMISSION_DENIED, GRPC_STATUS_UNAUTHENTICATED, GRPC_STATUS_UNAVAILABLE, + GRPC_STATUS_UNIMPLEMENTED, }; use prost::Message; use tokio::sync::{broadcast, mpsc}; @@ -85,6 +89,7 @@ use crate::api::spontaneous_send::handle_spontaneous_send_request; use crate::api::unified_send::handle_unified_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::{method_authorization, ApiKeyInfo, ApiKeyStore, MethodAuthorization}; use crate::io::persist::paginated_kv_store::PaginatedKVStore; use crate::util::metrics::Metrics; @@ -97,7 +102,7 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; #[derive(Clone)] pub(crate) struct NodeService { context: Arc, - api_key: String, + api_key_store: Arc, metrics: Option>, metrics_auth_header: Option, event_sender: broadcast::Sender, @@ -106,65 +111,14 @@ pub(crate) struct NodeService { impl NodeService { pub(crate) fn new( - node: Arc, paginated_kv_store: Arc, api_key: String, - metrics: Option>, metrics_auth_header: Option, - event_sender: broadcast::Sender, + node: Arc, paginated_kv_store: Arc, + api_key_store: Arc, metrics: Option>, + metrics_auth_header: Option, event_sender: broadcast::Sender, shutdown_rx: tokio::sync::watch::Receiver, ) -> Self { let context = Arc::new(Context { node, paginated_kv_store }); - Self { context, api_key, metrics, metrics_auth_header, event_sender, shutdown_rx } - } -} - -// Maximum allowed time difference between client timestamp and server time (1 minute) -const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; - -fn compute_auth_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> Hmac { - 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) -} - -/// Validates HMAC authentication from request headers. -/// The signature covers the timestamp and raw gRPC request body bytes. -fn validate_auth(req: &Request, api_key: &str, body: &[u8]) -> Result<(), LdkServerError> { - let auth_err = |msg: &str| LdkServerError::new(LdkServerErrorCode::AuthError, msg.to_string()); - - let auth_header = req - .headers() - .get("x-auth") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| auth_err("Missing x-auth metadata"))?; - - let auth_data = - auth_header.strip_prefix("HMAC ").ok_or_else(|| auth_err("Invalid x-auth format"))?; - - let (timestamp_str, provided_hmac_hex) = - auth_data.split_once(':').ok_or_else(|| auth_err("Invalid x-auth format"))?; - - let timestamp = timestamp_str.parse::().map_err(|_| auth_err("Invalid timestamp"))?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| auth_err("System time error"))? - .as_secs(); - - if now.abs_diff(timestamp) > AUTH_TIMESTAMP_TOLERANCE_SECS { - return Err(auth_err("Request timestamp expired")); - } - - let expected_hmac = compute_auth_hmac(api_key, timestamp, body); - - let provided_hmac = provided_hmac_hex - .parse::>() - .map_err(|_| auth_err("Invalid HMAC in x-auth"))?; - - if expected_hmac != provided_hmac { - return Err(auth_err("Invalid credentials")); + Self { context, api_key_store, metrics, metrics_auth_header, event_sender, shutdown_rx } } - - Ok(()) } pub(crate) struct Context { @@ -257,7 +211,7 @@ impl Service> for NodeService { }; let is_streaming = method == SUBSCRIBE_EVENTS_PATH; - let api_key = self.api_key.clone(); + let api_key_store = Arc::clone(&self.api_key_store); let event_sender = self.event_sender.clone(); let shutdown_rx = self.shutdown_rx.clone(); let (request_parts, request_body) = req.into_parts(); @@ -271,10 +225,29 @@ impl Service> for NodeService { Err(status) => return Ok(grpc_error_response(status)), }; - let auth_req = Request::from_parts(request_parts, ()); - if let Err(e) = validate_auth(&auth_req, &api_key, &body_bytes) { - let status = ldk_error_to_grpc_status(e); - return Ok(grpc_error_response(status)); + let auth_header = + request_parts.headers.get("x-auth").and_then(|value| value.to_str().ok()); + let authenticated_key = + match api_key_store.authenticate(&method, auth_header, &body_bytes) { + Ok(key) => key, + Err(error) => return Ok(grpc_error_response(ldk_error_to_grpc_status(error))), + }; + match method_authorization(&method) { + MethodAuthorization::Permission(permission) => { + if !authenticated_key.allows(permission) { + return Ok(grpc_error_response(GrpcStatus::new( + GRPC_STATUS_PERMISSION_DENIED, + format!("API key requires permission: {permission}"), + ))); + } + }, + MethodAuthorization::AuthenticatedOnly => {}, + MethodAuthorization::Unknown => { + return Ok(grpc_error_response(GrpcStatus::new( + GRPC_STATUS_UNIMPLEMENTED, + format!("Unknown method: {method}"), + ))); + }, } match method.as_str() { @@ -419,6 +392,7 @@ impl Service> for NodeService { handle_grpc_unary(context, body_bytes, handle_decode_offer_request).await }, SUBSCRIBE_EVENTS_PATH => { + // Authorization applies when the subscription starts; revocation does not close it. let mut shutdown_rx = shutdown_rx; let mut rx = event_sender.subscribe(); let (tx, mpsc_rx) = mpsc::channel::>(64); @@ -462,6 +436,33 @@ impl Service> for NodeService { }); Ok(grpc_response(GrpcBody::Stream { rx: mpsc_rx, done: false })) }, + CREATE_API_KEY_PATH => { + let store = Arc::clone(&api_key_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_create_api_key_request(store, authenticated_key, request) + }) + .await + }, + LIST_API_KEYS_PATH => { + let store = Arc::clone(&api_key_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_list_api_keys_request(store, request) + }) + .await + }, + REVOKE_API_KEY_PATH => { + let store = Arc::clone(&api_key_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_revoke_api_key_request(store, authenticated_key, request) + }) + .await + }, + GET_PERMISSIONS_PATH => { + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_get_permissions_request(authenticated_key, request) + }) + .await + }, _ => { let status = GrpcStatus::new( GRPC_STATUS_UNIMPLEMENTED, @@ -487,11 +488,53 @@ impl Service> for NodeService { } } +async fn handle_create_api_key_request( + store: Arc, issuer: Arc, request: CreateApiKeyRequest, +) -> Result { + let created = tokio::task::spawn_blocking(move || { + store.create_key(&request.name, request.permissions, &issuer) + }) + .await + .map_err(|error| { + LdkServerError::new(LdkServerErrorCode::InternalServerError, error.to_string()) + })??; + Ok(CreateApiKeyResponse { + api_key: Some(api_key_to_proto(created.info)), + secret: created.secret, + }) +} + +async fn handle_list_api_keys_request( + store: Arc, _request: ListApiKeysRequest, +) -> Result { + let api_keys = store.list_keys()?.into_iter().map(api_key_to_proto).collect(); + Ok(ListApiKeysResponse { api_keys }) +} + +async fn handle_revoke_api_key_request( + store: Arc, issuer: Arc, request: RevokeApiKeyRequest, +) -> Result { + tokio::task::spawn_blocking(move || store.revoke_key(&request.id, &issuer)).await.map_err( + |error| LdkServerError::new(LdkServerErrorCode::InternalServerError, error.to_string()), + )??; + Ok(RevokeApiKeyResponse {}) +} + +async fn handle_get_permissions_request( + authenticated_key: Arc, _request: GetPermissionsRequest, +) -> Result { + Ok(GetPermissionsResponse { api_key: Some(api_key_to_proto((*authenticated_key).clone())) }) +} + +fn api_key_to_proto(info: ApiKeyInfo) -> ApiKey { + ApiKey { id: info.id, name: info.name, permissions: info.permissions.into_iter().collect() } +} + async fn handle_grpc_unary< T: Message + Default, R: Message, Fut: Future> + Send, - F: Fn(Arc, T) -> Fut + Send, + F: FnOnce(Arc, T) -> Fut + Send, >( context: Arc, body_bytes: bytes::Bytes, handler: F, ) -> Result, hyper::Error> { @@ -574,6 +617,7 @@ pub(crate) fn ldk_error_to_grpc_status(e: LdkServerError) -> GrpcStatus { let code = match e.error_code { LdkServerErrorCode::InvalidRequestError => GRPC_STATUS_INVALID_ARGUMENT, LdkServerErrorCode::AuthError => GRPC_STATUS_UNAUTHENTICATED, + LdkServerErrorCode::AuthorizationError => GRPC_STATUS_PERMISSION_DENIED, LdkServerErrorCode::LightningError => GRPC_STATUS_FAILED_PRECONDITION, LdkServerErrorCode::InternalServerError => GRPC_STATUS_INTERNAL, }; @@ -584,85 +628,6 @@ pub(crate) fn ldk_error_to_grpc_status(e: LdkServerError) -> GrpcStatus { mod tests { use super::*; - fn compute_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> String { - compute_auth_hmac(api_key, timestamp, body).to_string() - } - - fn create_test_request(auth_header: Option) -> Request<()> { - let mut builder = - Request::builder().method("POST").header("content-type", "application/grpc+proto"); - if let Some(header) = auth_header { - builder = builder.header("x-auth", header); - } - builder.body(()).unwrap() - } - - #[test] - fn test_validate_auth_success() { - let api_key = "test_api_key"; - let body = b"test 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 auth_header = format!("HMAC {timestamp}:{hmac}"); - let req = create_test_request(Some(auth_header)); - - assert!(validate_auth(&req, api_key, body).is_ok()); - } - - #[test] - fn test_validate_auth_missing_header() { - let req = create_test_request(None); - let result = validate_auth(&req, "test_key", b"test body"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); - } - - #[test] - fn test_validate_auth_invalid_format() { - let req = create_test_request(Some("12345:deadbeef".to_string())); - let result = validate_auth(&req, "test_key", b"test body"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); - } - - #[test] - fn test_validate_auth_wrong_key() { - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let hmac = compute_hmac("wrong_key", timestamp, b"test body"); - let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}"))); - - let result = validate_auth(&req, "test_api_key", b"test body"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); - } - - #[test] - fn test_validate_auth_wrong_body() { - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); - let hmac = compute_hmac("test_api_key", timestamp, b"signed body"); - let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}"))); - - let result = validate_auth(&req, "test_api_key", b"modified body"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); - } - - #[test] - fn test_validate_auth_expired_timestamp() { - let timestamp = - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() - - 600; - let hmac = compute_hmac("test_api_key", timestamp, b"test body"); - let req = create_test_request(Some(format!("HMAC {timestamp}:{hmac}"))); - - let result = validate_auth(&req, "test_api_key", b"test body"); - assert!(result.is_err()); - assert_eq!(result.unwrap_err().error_code, LdkServerErrorCode::AuthError); - } - #[test] fn test_request_content_length_missing() { let headers = HeaderMap::new();