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();