Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
61 changes: 57 additions & 4 deletions docs/api-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <unix_timestamp>:<hmac_hex>
x-auth: HMAC <key_id>:<unix_timestamp>:<hmac_hex>
```

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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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):
Expand Down
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ Two resolution methods are supported via the `mode` field:
tls.crt # TLS certificate (PEM)
tls.key # TLS private key (PEM)
<network>/ # 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
Expand Down
19 changes: 12 additions & 7 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,22 +73,27 @@ gRPC service listening on 127.0.0.1:3536
NODE_URI: <node_id>@<address>
```

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 | `<storage_dir>/<network>/api_key` | 32-byte random key (stored as raw bytes) |
| TLS certificate | `<storage_dir>/tls.crt` | Self-signed ECDSA P-256 certificate |
| File | Location | Purpose |
|-----------------|---------------------------------------------------|-------------------------------------|
| Admin API key | `<storage_dir>/<network>/api_keys/admin.toml` | Unrestricted API credential |
| TLS certificate | `<storage_dir>/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
`<storage_dir>/<network>/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
Expand Down
15 changes: 8 additions & 7 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<network_dir>/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 `<network_dir>/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

Expand Down
20 changes: 13 additions & 7 deletions e2e-tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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())
Expand Down
109 changes: 108 additions & 1 deletion e2e-tests/tests/e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading