From f0a66b9d1d16b673c2321aea83e2777d07cc1af5 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 8 Sep 2026 14:05:38 -0500 Subject: [PATCH] Replace API keys with scoped macaroons Use standard v2 macaroons so clients can restrict permissions, RPC methods, and expiry without access to server root keys. Support separate credentials with independent revocation through gRPC, the CLI, and MCP. Carry caller restrictions into newly issued credentials to prevent privilege escalation. Replace request HMAC headers with bearer macaroons over TLS. Clients must use the new credentials; existing event streams retain admission- time authorization. Add reference vectors and tests for token parsing, attenuation, persistence, and live authentication. AI assistance: OpenAI Codex. --- CONTRIBUTING.md | 21 +- docs/api-guide.md | 113 +- docs/configuration.md | 6 +- docs/getting-started.md | 26 +- docs/operations.md | 26 +- e2e-tests/src/lib.rs | 31 +- e2e-tests/tests/e2e.rs | 202 ++- e2e-tests/tests/mcp.rs | 74 +- ldk-server-cli/README.md | 2 +- ldk-server-cli/src/main.rs | 141 +- ldk-server-client/README.md | 19 +- ldk-server-client/src/client.rs | 111 +- ldk-server-client/src/config.rs | 122 +- ldk-server-client/src/error.rs | 4 + ldk-server-client/src/lib.rs | 2 + ldk-server-client/src/macaroon.rs | 66 + ldk-server-grpc/src/api.rs | 94 ++ ldk-server-grpc/src/endpoints.rs | 4 + ldk-server-grpc/src/grpc.rs | 1 + ldk-server-grpc/src/lib.rs | 2 + ldk-server-grpc/src/macaroon.rs | 263 ++++ ldk-server-grpc/src/permissions.rs | 80 ++ ldk-server-grpc/src/proto/api.proto | 59 + ldk-server-grpc/tests/data/macaroons-v2.txt | 7 + ldk-server-mcp/CLAUDE.md | 4 +- ldk-server-mcp/README.md | 10 +- ldk-server-mcp/src/config.rs | 43 +- ldk-server-mcp/src/main.rs | 2 +- ldk-server-mcp/src/protocol.rs | 28 +- ldk-server-mcp/src/tools/handlers.rs | 61 +- ldk-server-mcp/src/tools/mod.rs | 24 + ldk-server-mcp/src/tools/schema.rs | 29 + ldk-server-mcp/tests/integration.rs | 28 +- ldk-server/src/api/error.rs | 4 + ldk-server/src/macaroons.rs | 1347 +++++++++++++++++++ ldk-server/src/main.rs | 74 +- ldk-server/src/service.rs | 273 ++-- ldk-server/src/util/config.rs | 3 +- 38 files changed, 2931 insertions(+), 475 deletions(-) create mode 100644 ldk-server-client/src/macaroon.rs create mode 100644 ldk-server-grpc/src/macaroon.rs create mode 100644 ldk-server-grpc/src/permissions.rs create mode 100644 ldk-server-grpc/tests/data/macaroons-v2.txt create mode 100644 ldk-server/src/macaroons.rs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9fa15f3..d5ccc23c 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/macaroons.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..db6386d8 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -15,24 +15,93 @@ underlying LDK Node documentation. ## Authentication -Every gRPC request must include an `x-auth` metadata header with an HMAC-SHA256 signature: +Every gRPC request must include a `macaroon` metadata header containing a hex-encoded v2 +binary macaroon: +```text +macaroon: ``` -x-auth: HMAC : -``` -Where: +Macaroons use the standard HMAC-SHA256 key derivation and signature chain. The server supports +first-party caveats only. It rejects third-party caveats, unknown conditions, malformed tokens, +and tokens larger than 4096 binary bytes (8192 hex characters), with at most 32 caveats. +An optional location field is a routing hint and is never used for authorization. + +A macaroon is a bearer credential: anyone who obtains it can use its permitted operations. +TLS is required. There is no per-request signature, body binding, or automatic replay protection. +The old `x-auth` HMAC scheme and API keys are no longer accepted. Upgrade clients together with +the server and supply the generated `macaroons/admin.macaroon` file or a scoped macaroon. +The old `api_key` file is not imported. + +### Caveats and delegation -- `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)` - - `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 - the 5-byte gRPC message frame +All caveats must pass. Supported conditions use these exact forms: -The server rejects requests where the timestamp differs from the server's clock by more than -**60 seconds**. +| Caveat | Meaning | +|--------|---------| +| `permissions = node:read,payments:read` | Permit only these capabilities | +| `method = GetNodeInfo` | Permit only this RPC method name | +| `time-before = 1800000000` | Require server Unix time to be strictly less than this value | + +Additional permission caveats intersect existing permissions. Adding `permissions = admin` +to a restricted token does not restore admin access. Additional expiry conditions can only +shorten its lifetime. Unknown or malformed conditions deny access. + +Restrict a token locally, without contacting the server: + +```bash +ldk-server-cli attenuate-macaroon "$MACAROON" \ + --caveat 'permissions = node:read' \ + --caveat 'method = GetNodeInfo' \ + --caveat "time-before = $EXPIRY_UNIX_SECONDS" +``` + +The command prints a hex token. The Rust client provides `macaroon::attenuate_macaroon` for the +same operation. Give the restricted copy to the application and keep the original private. + +Each `CreateMacaroon` call creates an independent root ID. Locally restricted copies retain +the parent's ID; revoking that ID invalidates all such copies. To revoke clients independently, +issue a separate macaroon for each client. The server cannot list copies made locally. +Tokens created through the API inherit all the caller's caveats as well as their requested +permissions. They have independent revocation IDs, so revoking the issuing credential does not +revoke those separately issued tokens. Root keys are never returned by the API. + +Authorization, including expiry, is checked when a request or event subscription starts. +Revocation and expiry do not close an existing event stream. Reconnecting requires a valid token. + +### Macaroon Permissions + +Each macaroon has one or more capabilities. New RPCs are denied to scoped macaroons 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 | +| `macaroons:manage` | Create, list, and revoke macaroons without privilege escalation | + +Use `CreateMacaroon`, `ListMacaroons`, `RevokeMacaroon`, and `GetPermissions` to manage credentials. +`CreateMacaroon` returns the hex bearer credential in `token`; list operations return metadata +only. `GetPermissions` reports the effective permissions and caveats of the calling token. The CLI also provides `readonly`, `invoice`, and +`admin` presets. MCP exposes the same operations as `create_macaroon`, `list_macaroons`, +`revoke_macaroon`, and `get_permissions` tools. ## TLS @@ -67,9 +136,10 @@ Errors are returned as standard gRPC status codes: | gRPC Code | Meaning | |---------------------------|------------------------------------------------------------------| | `INVALID_ARGUMENT` (3) | Malformed request or invalid parameters | +| `PERMISSION_DENIED` (7) | Valid macaroon without the required capability or with an unsatisfied caveat | | `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 | +| `UNAUTHENTICATED` (16) | Missing, invalid, or revoked macaroon | The `grpc-message` trailer contains a human-readable error description. @@ -232,6 +302,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. +### Macaroon Management + +| RPC | Description | +|------------------|---------------------------------------------------------------| +| `CreateMacaroon` | Create a scoped macaroon and return its token | +| `ListMacaroons` | List root IDs, names, permissions, and inherited caveats | +| `RevokeMacaroon` | Revoke a key for new requests | +| `GetPermissions` | Return the calling token’s ID, name, effective permissions, and caveats | + +The first three RPCs require `macaroons: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..5aef1460 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -207,7 +207,11 @@ 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 + macaroons/ + admin.macaroon # Hex-encoded initial admin bearer token (0400) + roots/ # Server-only root keys (0700); never share this directory + admin.toml # Initial root key and metadata (0400) + .toml # Independently revocable root keys (0400) 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..8a36f5be 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,28 +73,34 @@ gRPC service listening on 127.0.0.1:3536 NODE_URI: @
``` -Two files are auto-generated on first run: +The admin macaroon 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 macaroon | `//macaroons/admin.macaroon` | 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 +### Reading the Macaroon -The API key file contains raw bytes. To get the hex string the CLI and client library expect: +The CLI reads the admin macaroon automatically from the configured storage directory. No +manual extraction is needed. To use the admin macaroon with another client, read +`//macaroons/admin.macaroon`. The entire file is the hex-encoded token. +Do not copy files from the server-only `macaroons/roots/` directory. + +Create a restricted macaroon for an application instead of copying the admin macaroon: ```bash -xxd -p -c 64 ~/.ldk-server/bitcoin/api_key +ldk-server-cli create-macaroon my-app --preset readonly +ldk-server-cli create-macaroon invoice-app --preset invoice ``` ## First Commands If the CLI and server share the same machine and use the default storage directory, the CLI -auto-discovers the API key and TLS certificate, so no flags are needed: +auto-discovers the macaroon and TLS certificate, so no flags are needed: ```bash # Check the node is running @@ -113,7 +119,7 @@ details explicitly: ```bash ldk-server-cli \ --base-url localhost:3536 \ - --api-key \ + --macaroon \ --tls-cert /path/to/tls.crt \ get-node-info ``` diff --git a/docs/operations.md b/docs/operations.md index 178a7226..505a816d 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) +- Macaroon credentials (can be replaced, but clients will need new tokens) - 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,21 @@ the following config to `/etc/logrotate.d/ldk-server` (adjust the log path to ma ## Security -### API Key - -- 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 +### Macaroons + +- An unrestricted admin token is generated at `/macaroons/admin.macaroon`. +- Root keys stay in `/macaroons/roots/`. Never give these files to clients. +- Use `create-macaroon` for independently revocable clients and `attenuate-macaroon` for local + restrictions. Use the minimum permissions each client needs. +- Treat tokens as secrets. A copied token grants its capabilities to the holder. +- Revoking a root ID blocks new requests from it and all its locally restricted copies. + Existing event streams continue until the client disconnects or the server stops. Expiry is + also checked at subscription start only. +- Restoring old root-key backups can restore revoked access. Preserve current revocation state + when restoring a node, or replace its credentials. +- The last unrestricted admin root cannot be revoked through the API. To rotate the initial + admin token, create a new admin macaroon, save its token securely, then revoke the old ID. + Update the default `admin.macaroon` file or pass `--macaroon` to use the new token. ### TLS @@ -188,7 +196,7 @@ To allow clients to connect from other machines: (e.g., `0.0.0.0:3536`). 3. **Distribute the TLS certificate:** Copy `/tls.crt` to each client machine. Clients must pin this certificate since it is self-signed. -4. **Share the API key:** Provide the hex-encoded API key to authorized clients. +4. **Share the macaroon:** Provide the hex-encoded macaroon to authorized clients. If you regenerate the TLS certificate (by deleting `tls.crt` and `tls.key` and restarting), all clients will need the new certificate. diff --git a/e2e-tests/src/lib.rs b/e2e-tests/src/lib.rs index c63df6f0..469d76c5 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; @@ -97,7 +96,7 @@ pub struct LdkServerHandle { pub p2p_port: u16, pub storage_dir: PathBuf, pub config_path: PathBuf, - pub api_key: String, + pub macaroon: String, pub tls_cert_path: PathBuf, pub node_id: String, client: LdkServerClient, @@ -343,23 +342,21 @@ impl LdkServerHandle { } }); - // Wait for the api_key and tls.crt files to appear in the network subdir + // Wait for the admin macaroon and TLS certificate files to appear. let network_dir = storage_dir.join("regtest"); - let api_key_path = network_dir.join("api_key"); + let macaroon_path = network_dir.join("macaroons").join("admin.macaroon"); let tls_cert_path = storage_dir.join("tls.crt"); - wait_for_file(&api_key_path, Duration::from_secs(30)).await; + wait_for_file(&macaroon_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 macaroon = std::fs::read_to_string(&macaroon_path).unwrap().trim().to_string(); // Read TLS cert let tls_cert_pem = std::fs::read(&tls_cert_path).unwrap(); let base_url = format!("127.0.0.1:{grpc_port}"); - let client = LdkServerClient::new(base_url, api_key.clone(), &tls_cert_pem).unwrap(); + let client = LdkServerClient::new(base_url, macaroon.clone(), &tls_cert_pem).unwrap(); let mut handle = Self { child: Some(child), @@ -367,7 +364,7 @@ impl LdkServerHandle { p2p_port, storage_dir, config_path, - api_key, + macaroon, tls_cert_path, node_id: String::new(), client, @@ -547,10 +544,14 @@ pub struct McpHandle { impl McpHandle { pub fn start(server: &LdkServerHandle) -> Self { + Self::start_with_macaroon(server, &server.macaroon) + } + + pub fn start_with_macaroon(server: &LdkServerHandle, macaroon: &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_MACAROON", macaroon) .env("LDK_TLS_CERT_PATH", server.tls_cert_path.to_str().unwrap()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -602,8 +603,8 @@ pub fn run_cli_raw(handle: &LdkServerHandle, args: &[&str]) -> String { let output = Command::new(&cli_path) .arg("--base-url") .arg(handle.base_url()) - .arg("--api-key") - .arg(&handle.api_key) + .arg("--macaroon") + .arg(&handle.macaroon) .arg("--tls-cert") .arg(handle.tls_cert_path.to_str().unwrap()) .args(args) @@ -758,9 +759,7 @@ pub async fn setup_funded_channel( .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - amount: Some(open_channel_request::Amount::ChannelAmountSats( - channel_amount_sats, - )), + amount: Some(open_channel_request::Amount::ChannelAmountSats(channel_amount_sats)), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, diff --git a/e2e-tests/tests/e2e.rs b/e2e-tests/tests/e2e.rs index 05dea885..0c12900b 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,164 @@ async fn test_cli_get_balances() { assert_eq!(output["total_lightning_balance_sats"], 0); } +#[tokio::test] +async fn test_scoped_macaroon_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-macaroon", "readonly-client", "--preset", "readonly"]); + let key_id = created["macaroon"]["id"].as_str().unwrap(); + let secret = created["token"].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.macaroon.unwrap().name, "readonly-client"); + assert_eq!( + client.onchain_receive(OnchainReceiveRequest {}).await.unwrap_err().error_code, + AuthorizationError + ); + assert_eq!( + client.list_macaroons(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + + let keys = run_cli(&server, &["list-macaroons"]); + assert!(keys["macaroons"].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-macaroon", name, "--permissions", permission]); + let scoped_client = LdkServerClient::new( + format!("127.0.0.1:{}", server.grpc_port), + created["token"].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 + ); + } + + // Offline attenuation must affect both unary and streaming authorization. + let attenuated = ldk_server_client::macaroon::attenuate_macaroon( + secret, + &["permissions = node:read".into(), "method = GetNodeInfo".into()], + ) + .unwrap(); + let restricted = LdkServerClient::new( + format!("127.0.0.1:{}", server.grpc_port), + attenuated.clone(), + &certificate, + ) + .unwrap(); + restricted.get_node_info(Default::default()).await.unwrap(); + assert_eq!( + restricted.get_balances(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + assert_eq!(restricted.subscribe_events().await.err().unwrap().error_code, AuthorizationError); + let expired = + ldk_server_client::macaroon::attenuate_macaroon(secret, &["time-before = 0".into()]) + .unwrap(); + let expired = + LdkServerClient::new(format!("127.0.0.1:{}", server.grpc_port), expired, &certificate) + .unwrap(); + assert_eq!( + expired.get_node_info(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + + let output = std::process::Command::new(e2e_tests::cli_binary_path()) + .args([ + "--base-url", + "invalid.invalid:1", + "--tls-cert", + "/no-certificate-needed", + "attenuate-macaroon", + secret, + "--caveat", + "permissions = node:read", + "--caveat", + "method = GetNodeInfo", + ]) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert_eq!(String::from_utf8(output.stdout).unwrap().trim(), attenuated); + + run_cli(&server, &["revoke-macaroon", key_id]); + assert_eq!( + restricted.get_node_info(Default::default()).await.unwrap_err().error_code, + AuthError + ); + + 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-macaroon", "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["token"].as_str().unwrap().to_string(), + &certificate, + ) + .unwrap(); + let mut events = client.subscribe_events().await.unwrap(); + + run_cli(&server_a, &["revoke-macaroon", created["macaroon"]["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(); @@ -439,13 +599,7 @@ async fn open_channel_via_cli(channel_amount: &str) { let addr = format!("127.0.0.1:{}", server_b.p2p_port); let output = run_cli( &server_a, - &[ - "open-channel", - server_b.node_id(), - &addr, - channel_amount, - "--announce-channel", - ], + &["open-channel", server_b.node_id(), &addr, channel_amount, "--announce-channel"], ); assert!(!output["user_channel_id"].as_str().unwrap().is_empty()); } @@ -482,9 +636,7 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_closed() { .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - amount: Some(open_channel_request::Amount::ChannelAmountSats( - 100_000, - )), + amount: Some(open_channel_request::Amount::ChannelAmountSats(100_000)), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, @@ -512,7 +664,10 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_closed() { assert!(pending_a.reason.is_none()); assert_eq!(pending_a.closure_initiator, ChannelClosureInitiator::Unspecified as i32); assert!(pending_a.former_temporary_channel_id.as_deref().is_some_and(|id| !id.is_empty())); - assert_ne!(pending_a.former_temporary_channel_id.as_deref(), Some(pending_a.channel_id.as_str())); + assert_ne!( + pending_a.former_temporary_channel_id.as_deref(), + Some(pending_a.channel_id.as_str()) + ); let pending_b = wait_for_event(&mut events_b, |e| { matches!( @@ -650,9 +805,7 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_force_close .open_channel(OpenChannelRequest { node_pubkey: server_b.node_id().to_string(), address: format!("127.0.0.1:{}", server_b.p2p_port), - amount: Some(open_channel_request::Amount::ChannelAmountSats( - 100_000, - )), + amount: Some(open_channel_request::Amount::ChannelAmountSats(100_000)), push_to_counterparty_msat: None, channel_config: None, announce_channel: true, @@ -680,7 +833,10 @@ async fn test_subscribe_events_channel_state_lifecycle_pending_ready_force_close assert!(pending_a.reason.is_none()); assert_eq!(pending_a.closure_initiator, ChannelClosureInitiator::Unspecified as i32); assert!(pending_a.former_temporary_channel_id.as_deref().is_some_and(|id| !id.is_empty())); - assert_ne!(pending_a.former_temporary_channel_id.as_deref(), Some(pending_a.channel_id.as_str())); + assert_ne!( + pending_a.former_temporary_channel_id.as_deref(), + Some(pending_a.channel_id.as_str()) + ); let pending_b = wait_for_event(&mut events_b, |e| { matches!( @@ -1272,14 +1428,11 @@ async fn splice_in_via_cli(splice_amount: &str) { let mut events_a = server_a.client().subscribe_events().await.unwrap(); - let output = run_cli( - &server_a, - &["splice-in", &user_channel_id, server_b.node_id(), splice_amount], - ); + let output = + run_cli(&server_a, &["splice-in", &user_channel_id, server_b.node_id(), splice_amount]); assert!(output.is_object()); - let event_a = - wait_for_event(&mut events_a, |e| matches!(e, Event::SpliceNegotiated(_))).await; + let event_a = wait_for_event(&mut events_a, |e| matches!(e, Event::SpliceNegotiated(_))).await; match &event_a.event { Some(Event::SpliceNegotiated(splice_negotiated)) => { assert_eq!(splice_negotiated.user_channel_id, user_channel_id); @@ -1662,10 +1815,7 @@ async fn test_hodl_invoice_fail() { panic!("expected PaymentFailed"); }; assert!(!failed.payment.as_ref().unwrap().payment_id.is_empty()); - assert_eq!( - failed.reason, - Some(PaymentFailureReason::RecipientRejected as i32) - ); + assert_eq!(failed.reason, Some(PaymentFailureReason::RecipientRejected as i32)); } #[tokio::test] diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 3ae00766..a95dd822 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_macaroon_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_macaroon", "arguments": {"name": "mcp-reader", "permissions": ["node:read"]}})); + let created = tool_result_json(&created); + let id = created["macaroon"]["id"].as_str().unwrap(); + let secret = created["token"].as_str().unwrap(); + assert!(ldk_server_client::macaroon::attenuate_macaroon(secret, &[]).is_ok()); + let listed = admin.call(2, "tools/call", json!({"name": "list_macaroons", "arguments": {}})); + let listed = tool_result_json(&listed); + assert!(listed["macaroons"].as_array().unwrap().iter().any(|key| key["id"] == id)); + assert!(!listed.to_string().contains(secret)); + let mut reader = McpHandle::start_with_macaroon(&server, secret); + let permissions = + reader.call(1, "tools/call", json!({"name": "get_permissions", "arguments": {}})); + let permissions = tool_result_json(&permissions); + assert_eq!(permissions["macaroon"]["id"], id); + assert_eq!(permissions["macaroon"]["permissions"], json!(["node:read"])); + let denied = reader.call(2, "tools/call", json!({"name": "list_macaroons", "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_macaroon", "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(); @@ -54,17 +92,25 @@ async fn test_mcp_live_tool_calls() { let server = LdkServerHandle::start(&bitcoind).await; let mut mcp = McpHandle::start(&server); - let node_info = mcp.call(1, "tools/call", json!({ - "name": "get_node_info", - "arguments": {} - })); + let node_info = mcp.call( + 1, + "tools/call", + json!({ + "name": "get_node_info", + "arguments": {} + }), + ); let node_info_json = tool_result_json(&node_info); assert_eq!(node_info_json["node_id"], server.node_id()); - let onchain_receive = mcp.call(2, "tools/call", json!({ - "name": "onchain_receive", - "arguments": {} - })); + let onchain_receive = mcp.call( + 2, + "tools/call", + json!({ + "name": "onchain_receive", + "arguments": {} + }), + ); let onchain_receive_json = tool_result_json(&onchain_receive); assert!(onchain_receive_json["address"].as_str().unwrap().starts_with("bcrt1")); @@ -80,10 +126,14 @@ async fn test_mcp_live_tool_calls() { .await .unwrap(); - let decode_invoice = mcp.call(3, "tools/call", json!({ - "name": "decode_invoice", - "arguments": { "invoice": invoice.invoice } - })); + let decode_invoice = mcp.call( + 3, + "tools/call", + json!({ + "name": "decode_invoice", + "arguments": { "invoice": invoice.invoice } + }), + ); let decode_invoice_json = tool_result_json(&decode_invoice); assert_eq!(decode_invoice_json["destination"], server.node_id()); assert_eq!(decode_invoice_json["description"], "mcp decode"); diff --git a/ldk-server-cli/README.md b/ldk-server-cli/README.md index d28caa16..a9247b03 100644 --- a/ldk-server-cli/README.md +++ b/ldk-server-cli/README.md @@ -34,7 +34,7 @@ When using custom paths or connecting remotely: ```bash ldk-server-cli \ --base-url localhost:3536 \ - --api-key \ + --macaroon \ --tls-cert /path/to/tls.crt \ get-node-info ``` diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 1b951197..5a00ede3 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -10,17 +10,18 @@ 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; use ldk_server_client::config::{ - get_default_config_path, load_config, read_tls_certificate, resolve_api_key, resolve_base_url, - resolve_cert_path, DEFAULT_GRPC_SERVICE_ADDRESS, + get_default_config_path, load_config, read_tls_certificate, resolve_base_url, + resolve_cert_path, resolve_macaroon, DEFAULT_GRPC_SERVICE_ADDRESS, }; 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, CreateMacaroonRequest, CreateMacaroonResponse, + 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, ListChannelsRequest, ListChannelsResponse, + ListForwardedPaymentsRequest, ListMacaroonsRequest, ListMacaroonsResponse, ListPaymentsRequest, + ListPeersRequest, ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, + OnchainSendRequest, OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, + RevokeMacaroonRequest, RevokeMacaroonResponse, 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,8 +97,8 @@ struct Cli { )] base_url: Option, - #[arg(short, long, help = format!("API key for authentication. Defaults by reading {DEFAULT_DIR}/[network]/api_key"))] - api_key: Option, + #[arg(short, long, help = format!("macaroon for authentication. Defaults by reading {DEFAULT_DIR}/[network]/macaroons/admin.macaroon"))] + macaroon: Option, #[arg(short, long, help = format!("Path to the server's TLS certificate file (PEM format). Defaults to {DEFAULT_DIR}/tls.crt"))] tls_cert: Option, @@ -637,6 +642,42 @@ enum Commands { #[arg(help = "The hex-encoded node ID to look up")] node_id: String, }, + #[command(about = "Create an macaroon with scoped permissions")] + CreateMacaroon { + #[arg(help = "A unique human-readable name for the macaroon")] + 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 = "Restrict a macaroon locally without contacting the server")] + AttenuateMacaroon { + #[arg(help = "Hex-encoded macaroon to restrict")] + token: String, + #[arg( + long = "caveat", + required = true, + help = "Repeat for each condition, e.g. 'permissions = node:read' or 'time-before = 1800000000'" + )] + caveats: Vec, + }, + #[command(about = "List macaroons without their secrets")] + ListMacaroons, + #[command(about = "Revoke an macaroon")] + RevokeMacaroon { + #[arg(help = "The hex-encoded macaroon ID")] + id: String, + }, + #[command(about = "Show permissions for the current macaroon")] + GetPermissions, #[command(about = "Generate shell completions for the CLI")] Completions { #[arg( @@ -647,9 +688,38 @@ enum Commands { }, } +#[derive(Clone, Copy, Debug, ValueEnum)] +enum MacaroonPreset { + Readonly, + Invoice, + Admin, +} + +impl MacaroonPreset { + 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(); + if let Commands::AttenuateMacaroon { token, caveats } = &cli.command { + match ldk_server_client::macaroon::attenuate_macaroon(token, caveats) { + Ok(token) => println!("{token}"), + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + }, + } + return; + } // short-circuit if generating completions if let Commands::Completions { shell } = cli.command { @@ -673,13 +743,13 @@ async fn main() { }, }; - let api_key = resolve_api_key(cli.api_key, config.as_ref()) + let macaroon = resolve_macaroon(cli.macaroon, config.as_ref()) .unwrap_or_else(|e| { - eprintln!("Failed to resolve API key: {e}"); + eprintln!("Failed to resolve macaroon: {e}"); 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!("macaroon not provided. Use --macaroon or ensure the admin key exists at {DEFAULT_DIR}/[network]/macaroons/admin.macaroon"); std::process::exit(1); }); @@ -696,7 +766,7 @@ async fn main() { std::process::exit(1); }); - let client = LdkServerClient::new(base_url, api_key, &server_cert_pem).unwrap_or_else(|e| { + let client = LdkServerClient::new(base_url, macaroon, &server_cert_pem).unwrap_or_else(|e| { eprintln!("Failed to create client: {e}"); std::process::exit(1); }); @@ -1301,6 +1371,28 @@ async fn main() { client.graph_get_node(GraphGetNodeRequest { node_id }).await, ); }, + Commands::AttenuateMacaroon { .. } => unreachable!("Handled before connecting"), + Commands::CreateMacaroon { name, permissions, preset } => { + let permissions = preset.map(MacaroonPreset::permissions).unwrap_or(permissions); + handle_response_result::<_, CreateMacaroonResponse>( + client.create_macaroon(CreateMacaroonRequest { name, permissions }).await, + ); + }, + Commands::ListMacaroons => { + handle_response_result::<_, ListMacaroonsResponse>( + client.list_macaroons(ListMacaroonsRequest {}).await, + ); + }, + Commands::RevokeMacaroon { id } => { + handle_response_result::<_, RevokeMacaroonResponse>( + client.revoke_macaroon(RevokeMacaroonRequest { id }).await, + ); + }, + Commands::GetPermissions => { + handle_response_result::<_, GetPermissionsResponse>( + client.get_permissions(GetPermissionsRequest {}).await, + ); + }, Commands::Completions { .. } => unreachable!("Handled above"), } } @@ -1461,6 +1553,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..647ba03d 100644 --- a/ldk-server-client/README.md +++ b/ldk-server-client/README.md @@ -10,14 +10,14 @@ 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(); +let macaroon = "your_hex_macaroon".to_string(); let client = LdkServerClient::new( "localhost:3536".to_string(), - api_key, + macaroon, &cert_pem, ).unwrap(); @@ -28,10 +28,10 @@ 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. +The client sends macaroon metadata automatically. Pass the hex-encoded v2 bearer macaroon +(found in `//macaroons/admin.macaroon` for the initial admin token) 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..a862a790 100644 --- a/ldk-server-client/src/client.rs +++ b/ldk-server-client/src/client.rs @@ -8,10 +8,7 @@ // licenses. use std::io::Cursor; -use std::time::{SystemTime, UNIX_EPOCH}; -use bitcoin_hashes::hmac::{Hmac, HmacEngine}; -use bitcoin_hashes::{sha256, Hash, HashEngine}; use hyper::body::HttpBody as _; use hyper::{Body as HyperBody, Client as HyperClient, Request as HyperRequest, Version}; use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder}; @@ -25,18 +22,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, CreateMacaroonRequest, CreateMacaroonResponse, + 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, ListChannelsRequest, ListChannelsResponse, + ListForwardedPaymentsRequest, ListForwardedPaymentsResponse, ListMacaroonsRequest, + ListMacaroonsResponse, ListPaymentsRequest, ListPaymentsResponse, ListPeersRequest, + ListPeersResponse, OnchainReceiveRequest, OnchainReceiveResponse, OnchainSendRequest, + OnchainSendResponse, OpenChannelRequest, OpenChannelResponse, RevokeMacaroonRequest, + RevokeMacaroonResponse, SignMessageRequest, SignMessageResponse, SpliceInRequest, SpliceInResponse, SpliceOutRequest, SpliceOutResponse, SpontaneousSendRequest, SpontaneousSendResponse, SubscribeEventsRequest, UnifiedSendRequest, UnifiedSendResponse, UpdateChannelConfigRequest, UpdateChannelConfigResponse, VerifySignatureRequest, @@ -48,12 +47,13 @@ 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, + CREATE_MACAROON_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_CHANNELS_PATH, LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, + LIST_PEERS_PATH, ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, + REVOKE_MACAROON_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, }; @@ -61,7 +61,7 @@ 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 +71,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>; @@ -96,17 +97,18 @@ pub struct LdkServerClient { base_url: String, client: Client, streaming_client: StreamingClient, - api_key: String, + macaroon: String, } impl LdkServerClient { /// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint. /// /// `base_url` should not include the scheme, e.g., `localhost:3000`. - /// `api_key` is used for HMAC-based authentication. + /// `macaroon` is a hex-encoded v2 bearer macaroon. /// `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 { + pub fn new(base_url: String, macaroon: String, server_cert_pem: &[u8]) -> Result { + crate::macaroon::parse_macaroon(&macaroon)?; 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 +118,7 @@ impl LdkServerClient { .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; - Ok(Self { base_url, client, streaming_client, api_key }) - } - - /// 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 { - 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); - let hmac_result = Hmac::::from_engine(hmac_engine); - - format!("HMAC {}:{}", timestamp, hmac_result) + Ok(Self { base_url, client, streaming_client, macaroon }) } /// Retrieve the latest node info like `node_id`, `current_best_block` etc. @@ -461,6 +446,34 @@ impl LdkServerClient { self.grpc_unary(&request, GRAPH_GET_NODE_PATH).await } + /// Create a macaroon with the specified permissions. + pub async fn create_macaroon( + &self, request: CreateMacaroonRequest, + ) -> Result { + self.grpc_unary(&request, CREATE_MACAROON_PATH).await + } + + /// List macaroons without returning their secrets. + pub async fn list_macaroons( + &self, request: ListMacaroonsRequest, + ) -> Result { + self.grpc_unary(&request, LIST_MACAROONS_PATH).await + } + + /// Revoke a macaroon by ID. + pub async fn revoke_macaroon( + &self, request: RevokeMacaroonRequest, + ) -> Result { + self.grpc_unary(&request, REVOKE_MACAROON_PATH).await + } + + /// Return metadata and permissions for the calling macaroon. + 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 +489,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.macaroon.clone(); let response = self .client @@ -484,7 +497,7 @@ impl LdkServerClient { .header("content-type", "application/grpc+proto") .header("content-length", content_length) .header("te", "trailers") - .header("x-auth", auth_header) + .header("macaroon", auth_header) .body(grpc_body) .send() .await @@ -518,7 +531,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.macaroon.clone(); let response = self .streaming_client @@ -528,7 +541,7 @@ impl LdkServerClient { .header("content-type", "application/grpc+proto") .header("content-length", content_length) .header("te", "trailers") - .header("x-auth", auth_header) + .header("macaroon", auth_header) .body(HyperBody::from(grpc_body)) .map_err(|e| { LdkServerError::new( @@ -606,6 +619,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 +901,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..0a479bd5 100644 --- a/ldk-server-client/src/config.rs +++ b/ldk-server-client/src/config.rs @@ -10,21 +10,21 @@ //! Shared `ldk-server` client configuration. //! //! Parses the TOML configuration file used by the `ldk-server` daemon and exposes helpers for -//! locating the server's TLS certificate and API key on disk, so multiple clients (CLI, MCP +//! locating the server's TLS certificate and macaroon on disk, so multiple clients (CLI, MCP //! bridge, etc.) can resolve connection credentials in a consistent way. 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 MACAROONS_DIR: &str = "macaroons"; +const ADMIN_MACAROON_FILE: &str = "admin.macaroon"; +const MACAROON_FILE_SIZE_LIMIT: usize = ldk_server_grpc::macaroon::MAX_MACAROON_BYTES * 2; /// 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 +57,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 macaroon file. +pub fn get_default_admin_macaroon_path(network: &str) -> Option { + get_default_data_dir() + .map(|path| path.join(network).join(MACAROONS_DIR).join(ADMIN_MACAROON_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 macaroon file inside the given storage directory. +pub fn admin_macaroon_path_for_storage_dir(storage_dir: &str, network: &str) -> PathBuf { + PathBuf::from(storage_dir).join(network).join(MACAROONS_DIR).join(ADMIN_MACAROON_FILE) } /// Path of the server's TLS certificate inside the given storage directory. @@ -153,15 +154,14 @@ pub fn resolve_base_url(override_url: Option, config: Option<&Config>) - .unwrap_or_else(default_grpc_service_address) } -/// Resolves the API key used to authenticate against the `ldk-server` gRPC endpoint. +/// Resolves the macaroon 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 macaroon 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. -pub fn resolve_api_key( +/// 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_macaroon( override_key: Option, config: Option<&Config>, ) -> Result, String> { if override_key.is_some() { @@ -170,37 +170,18 @@ 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_macaroon(&admin_macaroon_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_macaroon_path(&network) { + Some(path) => read_admin_macaroon(&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,20 @@ fn read_to_string_with_limit(path: &Path, limit: usize) -> io::Result { .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) } +fn read_admin_macaroon(path: &Path) -> Result, String> { + let contents = match read_to_string_with_limit(path, MACAROON_FILE_SIZE_LIMIT) { + Ok(contents) => contents, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("Failed to read macaroon file '{}': {error}", path.display())) + }, + }; + let token = contents.trim(); + crate::macaroon::parse_macaroon(token) + .map_err(|_| format!("Invalid macaroon in '{}'", path.display()))?; + Ok(Some(token.to_string())) +} + /// 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 @@ -246,10 +241,14 @@ fn default_grpc_service_address() -> String { #[cfg(test)] mod tests { + use std::fs; + use std::sync::atomic::{AtomicU32, Ordering}; + 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_base_url, resolve_macaroon, Config, + CONFIG_FILE_SIZE_LIMIT, DEFAULT_GRPC_SERVICE_ADDRESS, TLS_CERT_FILE_SIZE_LIMIT, }; + static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); #[test] fn config_defaults_grpc_service_address() { @@ -368,4 +367,41 @@ mod tests { std::fs::remove_file(path).unwrap(); } + + #[test] + fn resolve_macaroon_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("macaroons"); + fs::create_dir_all(&admin_directory).unwrap(); + let token = "0201000207746573742d6964000006203846eea2ed59d53650493222c2380a3540668ade20044e28f9cb1e0b5b4e4429".to_string(); + fs::write(admin_directory.join("admin.macaroon"), &token).unwrap(); + let config: Config = toml::from_str(&format!( + r#" + [node] + network = "regtest" + + [storage.disk] + dir_path = "{}" + "#, + directory.display() + )) + .unwrap(); + + assert_eq!(resolve_macaroon(None, Some(&config)).unwrap(), Some(token)); + let admin_path = admin_directory.join("admin.macaroon"); + for contents in [ + "not hexadecimal".to_string(), + "deadbeef".to_string(), + "00".repeat(super::MACAROON_FILE_SIZE_LIMIT), + ] { + fs::write(&admin_path, contents).unwrap(); + assert!(resolve_macaroon(None, Some(&config)).is_err()); + } + fs::remove_file(&admin_path).unwrap(); + assert_eq!(super::read_admin_macaroon(&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-client/src/lib.rs b/ldk-server-client/src/lib.rs index ff67cd9e..48b51508 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -15,6 +15,8 @@ /// Implements a [`LdkServerClient`](client::LdkServerClient) to access a hosted instance of LDK Server. pub mod client; +pub mod macaroon; + /// Shared configuration loading and credential resolution logic reused by `ldk-server` clients. #[cfg(feature = "serde")] pub mod config; diff --git a/ldk-server-client/src/macaroon.rs b/ldk-server-client/src/macaroon.rs new file mode 100644 index 00000000..f031c0fa --- /dev/null +++ b/ldk-server-client/src/macaroon.rs @@ -0,0 +1,66 @@ +// 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. + +//! Offline restriction of macaroon credentials. No server connection or root key is needed. + +use bitcoin_hashes::hmac::{Hmac, HmacEngine}; +use bitcoin_hashes::{sha256, Hash, HashEngine}; +use hex_conservative::{DisplayHex, FromHex}; +use ldk_server_grpc::macaroon::{Macaroon, MAX_MACAROON_BYTES}; + +pub(crate) fn parse_macaroon(token: &str) -> Result { + if token.len() > MAX_MACAROON_BYTES * 2 { + return Err("Macaroon exceeds size limit".into()); + } + let data = Vec::::from_hex(token).map_err(|_| "Macaroon must be hexadecimal")?; + Macaroon::deserialize(&data).map_err(str::to_string) +} + +/// Add caveats to a hex-encoded v2 macaroon, without contacting the server. +/// +/// Supported server conditions are `permissions = node:read,payments:read`, +/// `method = GetNodeInfo`, and `time-before = 1800000000` (exclusive Unix seconds). +/// Every caveat must pass; permission sets intersect and expiry can only become earlier. +/// Unknown conditions can be encoded but the server will deny them. +/// The returned token is a bearer credential and must be kept private. +pub fn attenuate_macaroon(token: &str, caveats: &[String]) -> Result { + let mut macaroon = parse_macaroon(token)?; + for caveat in caveats { + macaroon + .attenuate(caveat.as_bytes(), |key, data| { + let mut engine = HmacEngine::::new(key); + engine.input(data); + Hmac::from_engine(engine).to_byte_array() + }) + .map_err(str::to_string)?; + } + Ok(macaroon.serialize().to_lower_hex_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attenuation_matches_reference_implementation() { + let tokens: Vec<_> = include_str!("../../ldk-server-grpc/tests/data/macaroons-v2.txt") + .lines() + .filter(|line| !line.starts_with('#')) + .map(|line| line.split_whitespace().nth(2).unwrap()) + .collect(); + let first = attenuate_macaroon(tokens[0], &["permissions = node:read".into()]).unwrap(); + assert_eq!(first, tokens[1]); + assert_eq!( + attenuate_macaroon(&first, &["method = GetNodeInfo".into()]).unwrap(), + tokens[2] + ); + assert!(attenuate_macaroon("deadbeef", &[]).is_err()); + assert!(attenuate_macaroon(&"00".repeat(MAX_MACAROON_BYTES + 1), &[]).is_err()); + } +} diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index bbb7c756..0ebe18c0 100644 --- a/ldk-server-grpc/src/api.rs +++ b/ldk-server-grpc/src/api.rs @@ -1437,3 +1437,97 @@ pub struct DecodeOfferResponse { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SubscribeEventsRequest {} +/// Public metadata for a macaroon root ID. The bearer token and root key are not 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 Macaroon { + /// 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 root (effective capabilities in GetPermissions). + #[prost(string, repeated, tag = "3")] + pub permissions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Additional restrictions. All caveats must pass. + #[prost(string, repeated, tag = "4")] + pub caveats: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// Create a macaroon 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 CreateMacaroonRequest { + /// 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 macaroon and its hex-encoded v2 bearer token. The root key is never returned. +#[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 CreateMacaroonResponse { + #[prost(message, optional, tag = "1")] + pub macaroon: ::core::option::Option, + #[prost(string, tag = "2")] + pub token: ::prost::alloc::string::String, +} +/// List server-issued roots without returning tokens or root keys. +#[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 ListMacaroonsRequest {} +#[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 ListMacaroonsResponse { + #[prost(message, repeated, tag = "1")] + pub macaroons: ::prost::alloc::vec::Vec, +} +/// Revoke a macaroon 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 RevokeMacaroonRequest { + #[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 RevokeMacaroonResponse {} +/// Return metadata and permissions for the calling macaroon. +#[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 macaroon: ::core::option::Option, +} diff --git a/ldk-server-grpc/src/endpoints.rs b/ldk-server-grpc/src/endpoints.rs index 2314dd17..27a02f5c 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_MACAROON_PATH: &str = "CreateMacaroon"; +pub const LIST_MACAROONS_PATH: &str = "ListMacaroons"; +pub const REVOKE_MACAROON_PATH: &str = "RevokeMacaroon"; +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..228d189b 100644 --- a/ldk-server-grpc/src/lib.rs +++ b/ldk-server-grpc/src/lib.rs @@ -14,6 +14,8 @@ pub mod endpoints; pub mod error; pub mod events; pub mod grpc; +pub mod macaroon; +pub mod permissions; #[cfg(feature = "serde")] pub mod serde_utils; pub mod types; diff --git a/ldk-server-grpc/src/macaroon.rs b/ldk-server-grpc/src/macaroon.rs new file mode 100644 index 00000000..f5e54abc --- /dev/null +++ b/ldk-server-grpc/src/macaroon.rs @@ -0,0 +1,263 @@ +// 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. + +//! Macaroon v2 binary encoding and first-party HMAC chaining. +//! +//! Callers supply HMAC-SHA256 from their existing cryptographic library. Verification must +//! use a constant-time HMAC verifier. Third-party caveats and other formats are rejected. +//! Format: . + +/// Maximum binary token size. Hex transport uses twice this many bytes. +pub const MAX_MACAROON_BYTES: usize = 4096; +/// Maximum number of first-party caveats. +pub const MAX_CAVEATS: usize = 32; +const KEY_GENERATOR: &[u8] = b"macaroons-key-generator"; + +/// A parsed macaroon. Parsing alone does not authenticate it or validate its caveats. +#[derive(Clone)] +pub struct Macaroon { + location: Option>, + identifier: Vec, + caveats: Vec>, + signature: [u8; 32], +} + +impl Macaroon { + /// Mint a macaroon using the standard root-key derivation and HMAC-SHA256. + pub fn mint( + root_key: &[u8], identifier: &[u8], hmac: impl Fn(&[u8], &[u8]) -> [u8; 32], + ) -> Result { + if identifier.is_empty() || identifier.len() > MAX_MACAROON_BYTES { + return Err("Invalid macaroon identifier size"); + } + let key = hmac(KEY_GENERATOR, root_key); + let macaroon = Self { + location: None, + identifier: identifier.to_vec(), + caveats: Vec::new(), + signature: hmac(&key, identifier), + }; + macaroon.check_size()?; + Ok(macaroon) + } + + /// The untrusted identifier used to select a server-side root key. + pub fn identifier(&self) -> &[u8] { + &self.identifier + } + + /// Conditions that must all pass after signature verification. + pub fn caveats(&self) -> &[Vec] { + &self.caveats + } + + /// Add a restriction without the root key. This cannot remove existing restrictions. + pub fn attenuate( + &mut self, caveat: &[u8], hmac: impl Fn(&[u8], &[u8]) -> [u8; 32], + ) -> Result<(), &'static str> { + if caveat.is_empty() + || caveat.len() > MAX_MACAROON_BYTES + || self.caveats.len() >= MAX_CAVEATS + { + return Err("Invalid macaroon caveat size or count"); + } + self.caveats.push(caveat.to_vec()); + if let Err(error) = self.check_size() { + self.caveats.pop(); + return Err(error); + } + self.signature = hmac(&self.signature, caveat); + Ok(()) + } + + /// Verify the signature using HMAC-SHA256 and a constant-time HMAC verifier. + /// This does not check caveat conditions: the caller must enforce every condition. + pub fn verify_signature( + &self, root_key: &[u8], hmac: impl Fn(&[u8], &[u8]) -> [u8; 32], + verify: impl Fn(&[u8], &[u8], &[u8; 32]) -> bool, + ) -> bool { + let key = hmac(KEY_GENERATOR, root_key); + let Some((last, preceding)) = self.caveats.split_last() else { + return verify(&key, &self.identifier, &self.signature); + }; + let mut signature = hmac(&key, &self.identifier); + for caveat in preceding { + signature = hmac(&signature, caveat); + } + verify(&signature, last, &self.signature) + } + + /// Serialize in standard v2 binary format. + pub fn serialize(&self) -> Vec { + let mut out = vec![2]; + if let Some(location) = &self.location { + packet(&mut out, 1, location); + } + packet(&mut out, 2, &self.identifier); + out.push(0); + for caveat in &self.caveats { + packet(&mut out, 2, caveat); + out.push(0); + } + out.push(0); + packet(&mut out, 6, &self.signature); + out + } + + fn check_size(&self) -> Result<(), &'static str> { + if self.identifier.is_empty() + || self.identifier.len() > MAX_MACAROON_BYTES + || self.serialize().len() > MAX_MACAROON_BYTES + { + return Err("Invalid macaroon size"); + } + Ok(()) + } + + /// Parse one bounded v2 token. Reject unknown fields, third-party caveats and trailing bytes. + pub fn deserialize(mut data: &[u8]) -> Result { + if data.len() > MAX_MACAROON_BYTES || data.first() != Some(&2) { + return Err("Invalid macaroon size or version"); + } + data = &data[1..]; + let mut location = None; + let (mut kind, mut value) = read_packet(&mut data)?; + if kind == 1 { + location = Some(value.to_vec()); + (kind, value) = read_packet(&mut data)?; + } + if kind != 2 || value.is_empty() { + return Err("Invalid macaroon identifier"); + } + let identifier = value.to_vec(); + if read_packet(&mut data)?.0 != 0 { + return Err("Invalid macaroon header"); + } + let mut caveats = Vec::new(); + loop { + let (kind, value) = read_packet(&mut data)?; + if kind == 0 { + break; + } + if kind != 2 || value.is_empty() || caveats.len() >= MAX_CAVEATS { + return Err("Unsupported or invalid macaroon caveat"); + } + caveats.push(value.to_vec()); + if read_packet(&mut data)?.0 != 0 { + return Err("Unsupported macaroon caveat fields"); + } + } + let (kind, value) = read_packet(&mut data)?; + if kind != 6 || !data.is_empty() { + return Err("Invalid macaroon signature field"); + } + let signature = value.try_into().map_err(|_| "Invalid macaroon signature size")?; + Ok(Self { location, identifier, caveats, signature }) + } +} + +fn packet(out: &mut Vec, kind: u8, value: &[u8]) { + out.push(kind); + let mut length = value.len(); + while length >= 128 { + out.push((length as u8 & 127) | 128); + length >>= 7; + } + out.push(length as u8); + out.extend_from_slice(value); +} + +fn varint(data: &mut &[u8]) -> Result { + let mut value = 0usize; + for shift in (0..35).step_by(7) { + let (&byte, rest) = data.split_first().ok_or("Truncated macaroon field")?; + *data = rest; + if shift == 28 && byte > 7 { + return Err("Macaroon varint overflow"); + } + value |= ((byte & 127) as usize) << shift; + if byte & 128 == 0 { + if shift != 0 && byte == 0 { + return Err("Noncanonical macaroon varint"); + } + return Ok(value); + } + } + Err("Macaroon varint overflow") +} + +fn read_packet<'a>(data: &mut &'a [u8]) -> Result<(usize, &'a [u8]), &'static str> { + let kind = varint(data)?; + if kind == 0 { + return Ok((0, &[])); + } + let length = varint(data)?; + let value = data.get(..length).ok_or("Truncated macaroon payload")?; + *data = &data[length..]; + Ok((kind, value)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode(hex: &str) -> Vec { + hex.as_bytes() + .chunks_exact(2) + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + + #[test] + fn reference_tokens_roundtrip_and_reject_truncation() { + for line in + include_str!("../tests/data/macaroons-v2.txt").lines().filter(|l| !l.starts_with('#')) + { + let fields: Vec<_> = line.split_whitespace().collect(); + let bytes = decode(fields[2]); + let macaroon = Macaroon::deserialize(&bytes).unwrap(); + assert_eq!(macaroon.identifier(), decode(fields[1])); + assert_eq!(macaroon.serialize(), bytes); + for end in 0..bytes.len() { + assert!(Macaroon::deserialize(&bytes[..end]).is_err()); + } + let mut trailing = bytes.clone(); + trailing.push(0); + assert!(Macaroon::deserialize(&trailing).is_err()); + } + } + + #[test] + fn rejects_unsupported_fields_and_bad_lengths() { + let header = [2, 2, 1, b'i', 0]; + for body in [ + vec![2, 1, b'c', 4, 1, b'v', 0], // Third-party verification identifier. + vec![1, 1, b'l', 2, 1, b'c', 0], // Third-party location. + vec![3, 1, b'x', 0], // Unknown field. + vec![2, 1, b'c', 2, 1, b'd', 0], // Duplicate identifier. + vec![2, 0, 0], // Empty caveat. + vec![2, 255, 255, 255, 255, 127], // Overflow. + vec![2, 128, 0], // Noncanonical length. + ] { + let mut bytes = header.to_vec(); + bytes.extend(body); + bytes.extend([0, 6, 32]); + bytes.extend([0; 32]); + assert!(Macaroon::deserialize(&bytes).is_err()); + } + assert!(Macaroon::deserialize(&vec![2; MAX_MACAROON_BYTES + 1]).is_err()); + let mut bytes = header.to_vec(); + for _ in 0..MAX_CAVEATS + 1 { + bytes.extend([2, 1, b'c', 0]); + } + bytes.extend([0, 6, 32]); + bytes.extend([0; 32]); + assert!(Macaroon::deserialize(&bytes).is_err()); + } +} diff --git a/ldk-server-grpc/src/permissions.rs b/ldk-server-grpc/src/permissions.rs new file mode 100644 index 00000000..79f70085 --- /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 MACAROONS_MANAGE_PERMISSION: &str = "macaroons:manage"; + +/// All permissions accepted when an macaroon 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, + MACAROONS_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..f8aa3fd3 100644 --- a/ldk-server-grpc/src/proto/api.proto +++ b/ldk-server-grpc/src/proto/api.proto @@ -1032,6 +1032,57 @@ message DecodeOfferResponse { // Node automatically fails the HTLC backward at its claim_deadline. message SubscribeEventsRequest {} +// Public metadata for a macaroon root ID. The bearer token and root key are not included. +message Macaroon { + // 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 root (effective capabilities in GetPermissions). + repeated string permissions = 3; + + // Additional restrictions. All caveats must pass. + repeated string caveats = 4; +} + +// Create a macaroon with the specified capabilities. +message CreateMacaroonRequest { + // 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 macaroon and its hex-encoded v2 bearer token. The root key is never returned. +message CreateMacaroonResponse { + Macaroon macaroon = 1; + string token = 2; +} + +// List server-issued roots without returning tokens or root keys. +message ListMacaroonsRequest {} + +message ListMacaroonsResponse { + repeated Macaroon macaroons = 1; +} + +// Revoke a macaroon by ID. +message RevokeMacaroonRequest { + string id = 1; +} + +message RevokeMacaroonResponse {} + +// Return metadata and permissions for the calling macaroon. +message GetPermissionsRequest {} + +message GetPermissionsResponse { + Macaroon macaroon = 1; +} + service LightningNode { // Retrieve the latest node info. rpc GetNodeInfo(GetNodeInfoRequest) returns (GetNodeInfoResponse); @@ -1118,4 +1169,12 @@ service LightningNode { rpc GraphGetNode(GraphGetNodeRequest) returns (GraphGetNodeResponse); // Subscribe to a stream of server events. rpc SubscribeEvents(SubscribeEventsRequest) returns (stream events.EventEnvelope); + // Create a macaroon. Requires macaroons:manage or admin permission. + rpc CreateMacaroon(CreateMacaroonRequest) returns (CreateMacaroonResponse); + // List macaroons. Requires macaroons:manage or admin permission. + rpc ListMacaroons(ListMacaroonsRequest) returns (ListMacaroonsResponse); + // Revoke a macaroon. Requires macaroons:manage or admin permission. + rpc RevokeMacaroon(RevokeMacaroonRequest) returns (RevokeMacaroonResponse); + // Return permissions for the calling key. + rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse); } diff --git a/ldk-server-grpc/tests/data/macaroons-v2.txt b/ldk-server-grpc/tests/data/macaroons-v2.txt new file mode 100644 index 00000000..2e3ea9f2 --- /dev/null +++ b/ldk-server-grpc/tests/data/macaroons-v2.txt @@ -0,0 +1,7 @@ +# Generated by pymacaroons 0.13.0 (v2). Test keys only. +# root-key-hex identifier-hex binary-token-hex +746573742d6b6579 746573742d6964 0201000207746573742d6964000006203846eea2ed59d53650493222c2380a3540668ade20044e28f9cb1e0b5b4e4429 +746573742d6b6579 746573742d6964 0201000207746573742d69640002177065726d697373696f6e73203d206e6f64653a7265616400000620fcd6db26ff34eca91e70ec1822ebb0e5ebb0ba3968cdb6544b900d9142a57659 +746573742d6b6579 746573742d6964 0201000207746573742d69640002177065726d697373696f6e73203d206e6f64653a726561640002146d6574686f64203d204765744e6f6465496e666f000006209b07f14715fb254956a20e98a24f3ee5b1fd3c222cbc421c5edfad495b044f10 +000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f 62696e617279006964 02010a6c646b2d736572766572020962696e61727900696400021874696d652d6265666f7265203d20313830303030303030300002257065726d697373696f6e73203d206e6f64653a726561642c7061796d656e74733a7265616400000620e5167b931c338ac40deafce9eca075f97db5868df847fda9f0a65f140a0f2e17 +78787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878 6964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964 02010002c801696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696469646964696400028201636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630000062011d8b595cb4e2ce92b69e94b192a7f4ae64e1c64e762ad0c9d8f0dc0a844441a diff --git a/ldk-server-mcp/CLAUDE.md b/ldk-server-mcp/CLAUDE.md index 0a17e8b8..5cd7d585 100644 --- a/ldk-server-mcp/CLAUDE.md +++ b/ldk-server-mcp/CLAUDE.md @@ -42,9 +42,9 @@ src/ The server reads configuration in this precedence order (highest first): -1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` +1. **Environment variables**: `LDK_BASE_URL`, `LDK_MACAROON`, `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}/macaroons/admin.macaroon` 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..aef57d05 100644 --- a/ldk-server-mcp/README.md +++ b/ldk-server-mcp/README.md @@ -17,9 +17,9 @@ cargo build -p ldk-server-mcp --release The server reads configuration in this precedence order (highest wins): -1. **Environment variables**: `LDK_BASE_URL`, `LDK_API_KEY`, `LDK_TLS_CERT_PATH` +1. **Environment variables**: `LDK_BASE_URL`, `LDK_MACAROON`, `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}/macaroons/admin.macaroon` The TOML config format is the same as used by [ `ldk-server-cli`](https://github.com/lightningdevkit/ldk-server/tree/main/ldk-server-cli): @@ -39,7 +39,7 @@ cert_path = "/path/to/tls.crt" ```bash export LDK_BASE_URL="localhost:3000" -export LDK_API_KEY="your_hex_encoded_api_key" +export LDK_MACAROON="your_hex_encoded_macaroon" export LDK_TLS_CERT_PATH="/path/to/tls.crt" cargo run -p ldk-server-mcp --release ``` @@ -64,7 +64,7 @@ Add the following to your Claude Desktop MCP configuration (`claude_desktop_conf "command": "/path/to/ldk-server-mcp", "env": { "LDK_BASE_URL": "localhost:3000", - "LDK_API_KEY": "your_hex_encoded_api_key", + "LDK_MACAROON": "your_hex_encoded_macaroon", "LDK_TLS_CERT_PATH": "/path/to/tls.crt" } } @@ -83,7 +83,7 @@ Add to your Claude Code MCP settings (`.claude/settings.json`): "command": "/path/to/ldk-server-mcp", "env": { "LDK_BASE_URL": "localhost:3000", - "LDK_API_KEY": "your_hex_encoded_api_key", + "LDK_MACAROON": "your_hex_encoded_macaroon", "LDK_TLS_CERT_PATH": "/path/to/tls.crt" } } diff --git a/ldk-server-mcp/src/config.rs b/ldk-server-mcp/src/config.rs index f8c066d9..9c8a5fb6 100644 --- a/ldk-server-mcp/src/config.rs +++ b/ldk-server-mcp/src/config.rs @@ -10,22 +10,22 @@ use std::path::PathBuf; use ldk_server_client::config::{ - get_default_config_path, load_config, read_tls_certificate, resolve_api_key, resolve_base_url, - resolve_cert_path, + get_default_config_path, load_config, read_tls_certificate, resolve_base_url, + resolve_cert_path, resolve_macaroon, }; pub struct ResolvedConfig { pub base_url: String, - pub api_key: String, + pub macaroon: String, pub tls_cert_pem: Vec, } pub fn resolve_config(config_path: Option) -> Result { let env_base_url = std::env::var("LDK_BASE_URL").ok(); - let env_api_key = std::env::var("LDK_API_KEY").ok(); + let env_macaroon = std::env::var("LDK_MACAROON").ok(); let env_tls_cert_path = std::env::var("LDK_TLS_CERT_PATH").ok().map(PathBuf::from); let env_overrides_complete = - env_base_url.is_some() && env_api_key.is_some() && env_tls_cert_path.is_some(); + env_base_url.is_some() && env_macaroon.is_some() && env_tls_cert_path.is_some(); let explicit_config_path = config_path.map(PathBuf::from); let config_path = explicit_config_path.clone().or_else(get_default_config_path); @@ -40,8 +40,8 @@ pub fn resolve_config(config_path: Option) -> Result) -> Result c, Err(e) => { eprintln!("Error: Failed to create client: {e}"); diff --git a/ldk-server-mcp/src/protocol.rs b/ldk-server-mcp/src/protocol.rs index d9d08e94..fabbf137 100644 --- a/ldk-server-mcp/src/protocol.rs +++ b/ldk-server-mcp/src/protocol.rs @@ -15,6 +15,8 @@ pub const PARSE_ERROR: i64 = -32700; pub const METHOD_NOT_FOUND: i64 = -32601; pub const INVALID_PARAMS: i64 = -32602; pub const INTERNAL_ERROR: i64 = -32603; +pub const AUTHENTICATION_ERROR: i64 = -32001; +pub const PERMISSION_DENIED: i64 = -32002; /// Classified error produced by MCP tool handlers. The `code` is reused for JSON-RPC error /// responses at the envelope level, and for categorising the error text that gets surfaced @@ -38,6 +40,8 @@ impl McpError { match self.code { INVALID_PARAMS => "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..ad7d57bf 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, + CreateMacaroonRequest, 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, ListChannelsRequest, + ListForwardedPaymentsRequest, ListMacaroonsRequest, ListPaymentsRequest, ListPeersRequest, + OnchainReceiveRequest, OnchainSendRequest, OpenChannelRequest, RevokeMacaroonRequest, + 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_macaroon( + client: &LdkServerClient, args: Value, +) -> Result { + let request: CreateMacaroonRequest = parse_request(args)?; + let response = client.create_macaroon(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_list_macaroons( + client: &LdkServerClient, args: Value, +) -> Result { + let request: ListMacaroonsRequest = parse_request(args)?; + let response = client.list_macaroons(request).await.map_err(McpError::from)?; + serialize_response(response) +} + +pub async fn handle_revoke_macaroon( + client: &LdkServerClient, args: Value, +) -> Result { + let request: RevokeMacaroonRequest = parse_request(args)?; + let response = client.revoke_macaroon(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, CreateMacaroonRequest, + RevokeMacaroonRequest, }; use super::*; const NODE_PUBKEY: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + #[test] + fn parses_macaroon_management_arguments() { + let request: CreateMacaroonRequest = + 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..178ea0d7 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 macaroon metadata and permissions", + schema::get_permissions_schema, + |client, args| Box::pin(handlers::handle_get_permissions(client, args)), + ), + tool_spec( + "revoke_macaroon", + "Revoke a macaroon for new requests", + schema::revoke_macaroon_schema, + |client, args| Box::pin(handlers::handle_revoke_macaroon(client, args)), + ), + tool_spec( + "list_macaroons", + "List macaroon metadata without secrets", + schema::list_macaroons_schema, + |client, args| Box::pin(handlers::handle_list_macaroons(client, args)), + ), + tool_spec( + "create_macaroon", + "Create a macaroon with scoped permissions and return its bearer token", + schema::create_macaroon_schema, + |client, args| Box::pin(handlers::handle_create_macaroon(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..46c8d910 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_macaroon_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_macaroons_schema() -> Value { + json!({"type": "object", "properties": {}, "required": []}) +} + +pub fn revoke_macaroon_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..1b072c8b 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_macaroon", "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_macaroons", "list_channels", "list_forwarded_payments", "list_payments", @@ -47,6 +50,7 @@ const EXPECTED_TOOLS: [&str; NUM_TOOLS] = [ "onchain_receive", "onchain_send", "open_channel", + "revoke_macaroon", "sign_message", "splice_in", "splice_out", @@ -74,7 +78,7 @@ impl McpProcess { fn spawn() -> Self { let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_ldk-server-mcp")) .env("LDK_BASE_URL", "localhost:19999") - .env("LDK_API_KEY", "deadbeef") + .env("LDK_MACAROON", "0201000207746573742d6964000006203846eea2ed59d53650493222c2380a3540668ade20044e28f9cb1e0b5b4e4429") .env("LDK_TLS_CERT_PATH", test_cert_path()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) @@ -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/macaroons.rs b/ldk-server/src/macaroons.rs new file mode 100644 index 00000000..fd8e9e55 --- /dev/null +++ b/ldk-server/src/macaroons.rs @@ -0,0 +1,1347 @@ +// 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, FromHex}; +use ldk_node::bitcoin::hashes::{sha256, Hash}; +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_MACAROON_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_CHANNELS_PATH, + LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, + ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_MACAROON_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::macaroon::{Macaroon, MAX_MACAROON_BYTES}; +use ldk_server_grpc::permissions::{ + ADMIN_PERMISSION, ALL_PERMISSIONS, CHANNELS_FORCE_CLOSE_PERMISSION, CHANNELS_MANAGE_PERMISSION, + CHANNELS_READ_PERMISSION, CHANNELS_SPLICE_PERMISSION, EVENTS_READ_PERMISSION, + GRAPH_READ_PERMISSION, INVOICES_CREATE_PERMISSION, MACAROONS_MANAGE_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 ring::hmac; +use serde::Deserialize; + +use crate::api::error::{LdkServerError, LdkServerErrorCode}; +use crate::util::{create_dir_all_private, read_to_string_with_limit, write_new}; + +const MACAROON_FILE_SIZE_LIMIT: usize = 16384; +const MACAROONS_DIR: &str = "macaroons"; +const ADMIN_KEY_FILE: &str = "admin.toml"; +const ADMIN_MACAROON_FILE: &str = "admin.macaroon"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MacaroonInfo { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) permissions: BTreeSet, + pub(crate) caveats: Vec, +} + +impl MacaroonInfo { + 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 CreatedMacaroon { + pub(crate) info: MacaroonInfo, + pub(crate) token: String, +} + +#[derive(Debug)] +struct MacaroonRecord { + info: Arc, + secret: String, + path: PathBuf, +} + +#[derive(Deserialize)] +struct StoredMacaroon { + id: String, + name: String, + key: String, + permissions: Vec, + #[serde(default)] + caveats: Vec, +} + +pub(crate) struct MacaroonStore { + keys: RwLock>>, + management: Mutex<()>, + directory: PathBuf, +} + +impl MacaroonStore { + pub(crate) fn load_or_create(storage_dir: &Path) -> io::Result { + let macaroon_dir = storage_dir.join(MACAROONS_DIR); + create_dir_all_private(&macaroon_dir)?; + fs::set_permissions(&macaroon_dir, fs::Permissions::from_mode(0o700))?; + let directory = macaroon_dir.join("roots"); + 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("macaroon store lock is poisoned"))? + .is_empty() + { + store.create_initial_admin()?; + } + let admin_path = macaroon_dir.join(ADMIN_MACAROON_FILE); + if !admin_path.try_exists()? { + let keys = + store.keys.get_mut().map_err(|_| invalid_data("Macaroon store lock poisoned"))?; + if let Some(admin) = keys + .values() + .find(|record| record.path.file_name().is_some_and(|name| name == ADMIN_KEY_FILE)) + { + let token = mint_token(&admin.info, &admin.secret).map_err(invalid_data)?; + write_private_file(&admin_path, token.as_bytes())?; + } + } + Ok(store) + } + + fn load_key_files(&mut self) -> io::Result<()> { + let keys = + self.keys.get_mut().map_err(|_| io::Error::other("macaroon 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, MACAROON_FILE_SIZE_LIMIT)?; + let stored: StoredMacaroon = toml::from_str(&contents).map_err(|error| { + invalid_data(format!("Failed to parse macaroon 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 macaroon name: {}", record.info.name))); + } + if keys.insert(record.info.id.clone(), Arc::new(record)).is_some() { + return Err(invalid_data("Duplicate macaroon ID")); + } + } + Ok(()) + } + + fn create_initial_admin(&mut self) -> io::Result<()> { + let secret = generate_secret()?; + let info = MacaroonInfo { + id: compute_key_id(&secret), + name: "admin".to_string(), + permissions: BTreeSet::from([ADMIN_PERMISSION.to_string()]), + caveats: Vec::new(), + }; + let path = self.directory.join(ADMIN_KEY_FILE); + write_key_file(&path, &info, &secret)?; + self.keys + .get_mut() + .map_err(|_| io::Error::other("macaroon store lock is poisoned"))? + .insert( + info.id.clone(), + Arc::new(MacaroonRecord { info: Arc::new(info), secret, path }), + ); + + Ok(()) + } + + pub(crate) fn authenticate( + &self, method: &str, auth_header: Option<&str>, + ) -> Result, LdkServerError> { + let invalid = + || LdkServerError::new(LdkServerErrorCode::AuthError, "Invalid macaroon credentials"); + let token = auth_header.ok_or_else(invalid)?; + if token.len() > MAX_MACAROON_BYTES * 2 { + return Err(invalid()); + } + let bytes = Vec::::from_hex(token).map_err(|_| invalid())?; + let macaroon = Macaroon::deserialize(&bytes).map_err(|_| invalid())?; + let id = std::str::from_utf8(macaroon.identifier()).map_err(|_| invalid())?; + let record = self + .keys + .read() + .map_err(|_| key_store_lock_error())? + .get(id) + .cloned() + .ok_or_else(invalid)?; + let root = Vec::::from_hex(&record.secret).map_err(|_| invalid())?; + if !macaroon.verify_signature(&root, sign, |key, data, signature| { + hmac::verify(&hmac::Key::new(hmac::HMAC_SHA256, key), data, signature).is_ok() + }) { + return Err(invalid()); + } + let mut info = (*record.info).clone(); + info.caveats = macaroon + .caveats() + .iter() + .map(|caveat| { + String::from_utf8(caveat.clone()) + .map_err(|_| authorization_error("Invalid macaroon caveat")) + }) + .collect::>()?; + // Stored limits remain an upper bound, including for tokens minted through the API. + for caveat in record.info.caveats.iter().chain(info.caveats.iter()) { + check_caveat(caveat, method, &mut info.permissions)?; + } + Ok(Arc::new(info)) + } + + // Call management operations from a blocking thread. Authentication never takes this mutex. + pub(crate) fn create_key( + &self, name: &str, permissions: Vec, issuer: &MacaroonInfo, + ) -> Result { + self.create_key_with_writer(name, permissions, issuer, write_key_file) + } + + fn create_key_with_writer( + &self, name: &str, permissions: Vec, issuer: &MacaroonInfo, + write: impl FnOnce(&Path, &MacaroonInfo, &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())?; + if !issuer.allows(MACAROONS_MANAGE_PERMISSION) { + return Err(authorization_error("Macaroon management permission required")); + } + let mut issuer_permissions = issuer.permissions.clone(); + for caveat in &issuer.caveats { + check_caveat(caveat, CREATE_MACAROON_PATH, &mut issuer_permissions)?; + } + if !issuer_permissions.contains(ADMIN_PERMISSION) + && !issuer_permissions.contains(MACAROONS_MANAGE_PERMISSION) + { + return Err(authorization_error("Macaroon management permission required")); + } + let secret = generate_secret().map_err(internal_error)?; + let info = MacaroonInfo { + id: compute_key_id(&secret), + name: name.to_string(), + permissions, + caveats: issuer.caveats.clone(), + }; + let token = mint_token(&info, &secret).map_err(invalid_request)?; + { + 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!("macaroon name already exists: {name}"))); + } + if !issuer_permissions.contains(ADMIN_PERMISSION) + && 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 macaroon ID")); + } + } + let path = self.directory.join(format!("{}.toml", info.id)); + write(&path, &info, &secret).map_err(internal_error)?; + let record = + Arc::new(MacaroonRecord { info: Arc::new(info.clone()), secret: secret.clone(), path }); + self.keys.write().map_err(|_| key_store_lock_error())?.insert(info.id.clone(), record); + Ok(CreatedMacaroon { info, token }) + } + + 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: &MacaroonInfo) -> Result<(), LdkServerError> { + if !is_hex(id, 32) { + return Err(invalid_request( + "macaroon ID must contain exactly 32 hexadecimal characters", + )); + } + let _management = self.management.lock().map_err(|_| key_store_lock_error())?; + if !issuer.allows(MACAROONS_MANAGE_PERMISSION) { + return Err(authorization_error("Macaroon management permission required")); + } + let mut issuer_permissions = issuer.permissions.clone(); + for caveat in &issuer.caveats { + check_caveat(caveat, REVOKE_MACAROON_PATH, &mut issuer_permissions)?; + } + if !issuer_permissions.contains(ADMIN_PERMISSION) + && !issuer_permissions.contains(MACAROONS_MANAGE_PERMISSION) + { + return Err(authorization_error("Macaroon management permission required")); + } + 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 macaroon ID: {id}")))?; + if !issuer_permissions.contains(ADMIN_PERMISSION) + && (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 is_unrestricted_admin(&record.info) + && keys.values().filter(|record| is_unrestricted_admin(&record.info)).count() == 1 + { + return Err(invalid_request("Cannot revoke the final admin macaroon")); + } + + 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_MACAROON_PATH | LIST_MACAROONS_PATH | REVOKE_MACAROON_PATH => { + MethodAuthorization::Permission(MACAROONS_MANAGE_PERMISSION) + }, + GET_PERMISSIONS_PATH => MethodAuthorization::AuthenticatedOnly, + _ => MethodAuthorization::Unknown, + } +} + +fn sign(key: &[u8], data: &[u8]) -> [u8; 32] { + hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, key), data).as_ref().try_into().unwrap() +} + +fn mint_token(info: &MacaroonInfo, secret: &str) -> Result { + let root = Vec::::from_hex(secret).map_err(|_| "Invalid macaroon root key")?; + let mut macaroon = Macaroon::mint(&root, info.id.as_bytes(), sign)?; + let permissions = info.permissions.iter().cloned().collect::>().join(","); + macaroon.attenuate(format!("permissions = {permissions}").as_bytes(), sign)?; + for caveat in &info.caveats { + macaroon.attenuate(caveat.as_bytes(), sign)?; + } + Ok(macaroon.serialize().to_lower_hex_string()) +} + +fn check_caveat( + caveat: &str, method: &str, permissions: &mut BTreeSet, +) -> Result<(), LdkServerError> { + if let Some(value) = caveat.strip_prefix("permissions = ") { + let allowed = validate_permissions(value.split(',').map(str::to_string).collect()) + .map_err(authorization_error)?; + if permissions.contains(ADMIN_PERMISSION) { + *permissions = allowed; + } else if !allowed.contains(ADMIN_PERMISSION) { + permissions.retain(|p| allowed.contains(p)); + } + } else if let Some(value) = caveat.strip_prefix("time-before = ") { + let expiry = + value.parse::().map_err(|_| authorization_error("Invalid expiry caveat"))?; + if value != expiry.to_string() { + return Err(authorization_error("Invalid expiry caveat")); + } + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(internal_error)? + .as_secs(); + if now >= expiry { + return Err(authorization_error("Macaroon expired")); + } + } else if let Some(value) = caveat.strip_prefix("method = ") { + if value != method { + return Err(authorization_error("Macaroon does not allow this RPC method")); + } + } else { + return Err(authorization_error("Unknown macaroon caveat")); + } + Ok(()) +} + +fn is_unrestricted_admin(info: &MacaroonInfo) -> bool { + info.is_admin() && info.caveats.iter().all(|c| c == "permissions = admin") +} + +fn record_from_stored(stored: StoredMacaroon, path: PathBuf) -> io::Result { + if !is_hex(&stored.key, 64) { + return Err(invalid_data(format!("Invalid macaroon in {}", path.display()))); + } + if !is_hex(&stored.id, 32) || stored.id != compute_key_id(&stored.key) { + return Err(invalid_data(format!("Invalid macaroon ID in {}", path.display()))); + } + validate_name_value(&stored.name).map_err(invalid_data)?; + let permissions = validate_permissions(stored.permissions).map_err(invalid_data)?; + if stored.caveats.len() >= ldk_server_grpc::macaroon::MAX_CAVEATS + || stored.caveats.iter().any(|c| !c.is_ascii() || c.bytes().any(|b| b < 32 || b == 127)) + { + return Err(invalid_data("Invalid stored macaroon caveats")); + } + Ok(MacaroonRecord { + info: Arc::new(MacaroonInfo { + id: stored.id, + name: stored.name, + permissions, + caveats: stored.caveats, + }), + 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( + "macaroon 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 macaroon permission is required".to_string()); + } + for permission in &permissions { + if !ALL_PERMISSIONS.contains(&permission.as_str()) { + return Err(format!("Unknown macaroon 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: &MacaroonInfo, secret: &str) -> io::Result<()> { + let permissions = info + .permissions + .iter() + .map(|permission| format!("\"{permission}\"")) + .collect::>() + .join(", "); + let contents = format!( + "id = \"{}\"\nname = \"{}\"\nkey = \"{}\"\npermissions = [{}]\ncaveats = {:?}\n", + info.id, info.name, secret, permissions, info.caveats + ); + + write_private_file(path, contents.as_bytes()) +} + +fn write_private_file(path: &Path, contents: &[u8]) -> io::Result<()> { + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or("macaroon"); + 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, 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("macaroon 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::{MACAROONS_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")), + ("CreateMacaroon", Some("macaroons:manage")), + ("ListMacaroons", Some("macaroons:manage")), + ("RevokeMacaroon", Some("macaroons: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 = MacaroonInfo { + id: "test".to_string(), + name: "test".to_string(), + permissions: BTreeSet::new(), + caveats: Vec::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 = MacaroonStore::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(MACAROONS_DIR).join("roots").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(MACAROONS_DIR)).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn load_macaroon_rejects_oversized_toml() { + let directory = test_directory("oversized-key-toml"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let path = directory.join(MACAROONS_DIR).join("roots").join("oversized.toml"); + fs::write(&path, vec![b' '; MACAROON_FILE_SIZE_LIMIT + 1]).unwrap(); + drop(store); + let error = MacaroonStore::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 = MacaroonStore::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(MACAROONS_DIR).join("roots").join("duplicate.toml"), + &info, + &secret, + ) + .unwrap(); + drop(store); + + let error = MacaroonStore::load_or_create(&directory).err().unwrap(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains(&format!("Duplicate macaroon {duplicate}"))); + fs::remove_dir_all(directory).unwrap(); + } + } + + #[test] + fn creates_lists_revokes_and_reloads_key() { + let directory = test_directory("lifecycle"); + let store = MacaroonStore::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 = MacaroonStore::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 = MacaroonStore::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 header = reader.token.clone(); + fs::remove_file( + directory.join(MACAROONS_DIR).join("roots").join(format!("{}.toml", reader.info.id)), + ) + .unwrap(); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&header)).is_ok()); + + store.revoke_key(&reader.info.id, &admin).unwrap(); + assert_eq!( + store.authenticate(GET_NODE_INFO_PATH, Some(&header)).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + assert_eq!(store.list_keys().unwrap(), vec![admin.clone()]); + assert_eq!( + MacaroonStore::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 = MacaroonStore::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, "macaroon 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(MacaroonStore::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 header = reader.token.clone(); + let first = store.authenticate(GET_NODE_INFO_PATH, Some(&header)).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)).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_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 = MacaroonStore::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 = MacaroonStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![MACAROONS_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 = MacaroonStore::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 = MacaroonStore::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 = MacaroonStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![MACAROONS_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 = MacaroonStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let manager = store + .create_key( + "manager", + vec![MACAROONS_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!(MacaroonStore::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 = MacaroonStore::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 = MacaroonStore::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 = MacaroonStore::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!(MacaroonStore::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 = MacaroonStore::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 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 restrict(token: &str, caveats: &[&str]) -> String { + let bytes = Vec::::from_hex(token).unwrap(); + let mut m = Macaroon::deserialize(&bytes).unwrap(); + for caveat in caveats { + m.attenuate(caveat.as_bytes(), sign).unwrap(); + } + m.serialize().to_lower_hex_string() + } + + fn admin_token(store: &MacaroonStore) -> String { + let keys = store.keys.read().unwrap(); + let record = keys.values().find(|r| r.info.name == "admin").unwrap(); + mint_token(&record.info, &record.secret).unwrap() + } + + #[test] + fn standard_signatures_match_reference_implementation() { + for (index, line) in include_str!("../../ldk-server-grpc/tests/data/macaroons-v2.txt") + .lines() + .filter(|line| !line.starts_with('#')) + .enumerate() + { + let fields: Vec<_> = line.split_whitespace().collect(); + let root = Vec::::from_hex(fields[0]).unwrap(); + let id = Vec::::from_hex(fields[1]).unwrap(); + let mut bytes = Vec::::from_hex(fields[2]).unwrap(); + let m = Macaroon::deserialize(&bytes).unwrap(); + let verify = |key: &[u8], data: &[u8], sig: &[u8; 32]| { + hmac::verify(&hmac::Key::new(hmac::HMAC_SHA256, key), data, sig).is_ok() + }; + assert!(m.verify_signature(&root, sign, verify)); + assert!(!m.verify_signature(b"incorrect root", sign, verify)); + let mut issued = Macaroon::mint(&root, &id, sign).unwrap(); + for caveat in m.caveats() { + issued.attenuate(caveat, sign).unwrap(); + } + if index != 3 { + // The reference writes an empty location; our issuer omits this optional hint. + let mut without_empty_location = bytes.clone(); + assert_eq!(&without_empty_location[1..3], &[1, 0]); + without_empty_location.drain(1..3); + assert_eq!(issued.serialize(), without_empty_location); + } // Case 3 has a location hint. + *bytes.last_mut().unwrap() ^= 1; + assert!(!Macaroon::deserialize(&bytes).unwrap().verify_signature(&root, sign, verify)); + } + } + + #[test] + fn attenuation_intersects_permissions_and_enforces_all_conditions() { + let directory = test_directory("attenuation"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let admin = admin_token(&store); + let token = restrict( + &admin, + &[ + "permissions = node:read,payments:read", + "permissions = node:read", + "permissions = admin", + ], + ); + let reader = store.authenticate(GET_NODE_INFO_PATH, Some(&token)).unwrap(); + assert_eq!(reader.permissions, BTreeSet::from([NODE_READ_PERMISSION.to_string()])); + assert!(!reader.is_admin()); + assert!(store.create_key("escalated", vec![ADMIN_PERMISSION.into()], &reader).is_err()); + let disjoint = restrict(&token, &["permissions = invoices:create"]); + assert!(store + .authenticate(GET_NODE_INFO_PATH, Some(&disjoint)) + .unwrap() + .permissions + .is_empty()); + let method = restrict(&token, &["method = GetNodeInfo"]); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&method)).is_ok()); + assert!(store.authenticate(GET_BALANCES_PATH, Some(&method)).is_err()); + let future = format!("time-before = {}", now() + 3600); + assert!(store + .authenticate(GET_NODE_INFO_PATH, Some(&restrict(&token, &[&future]))) + .is_ok()); + for caveat in [ + "time-before = 0", + "time-before = 00", + "time-before = -1", + "time-before = 18446744073709551616", + "unknown = true", + "permissions = unknown", + "permissions = admin,node:read", + "permissions = ", + ] { + assert!( + store.authenticate(GET_NODE_INFO_PATH, Some(&restrict(&token, &[caveat]))).is_err(), + "{caveat}" + ); + } + let expired = restrict(&token, &["time-before = 0", &future]); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&expired)).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn rejects_forgery_and_revokes_all_attenuated_copies() { + let directory = test_directory("revocation"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let admin = store.list_keys().unwrap().remove(0); + let reader = store.create_key("reader", vec![NODE_READ_PERMISSION.into()], &admin).unwrap(); + let token = restrict(&reader.token, &["method = GetNodeInfo"]); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&token)).is_ok()); + // Change a caveat without recomputing the chain. + let bytes = Vec::::from_hex(&token).unwrap(); + let mut modified = bytes.clone(); + let offset = modified.windows(11).position(|w| w == b"GetNodeInfo").unwrap(); + modified[offset] = b'X'; + assert!(store + .authenticate(GET_NODE_INFO_PATH, Some(&modified.to_lower_hex_string())) + .is_err()); + // Remove the last caveat while retaining the final signature. + let mut removed = Vec::::from_hex(&reader.token).unwrap(); + let len = removed.len(); + removed[len - 32..].copy_from_slice(&bytes[bytes.len() - 32..]); + assert!(store + .authenticate(GET_NODE_INFO_PATH, Some(&removed.to_lower_hex_string())) + .is_err()); + for header in [None, Some(""), Some("HMAC old-auth"), Some("deadbeef")] { + assert_eq!( + store.authenticate(GET_NODE_INFO_PATH, header).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + } + let root = { store.keys.read().unwrap().get(&reader.info.id).unwrap().secret.clone() }; + assert_ne!(reader.token, root); + store.revoke_key(&reader.info.id, &admin).unwrap(); + for credential in [&reader.token, &token] { + assert_eq!( + store.authenticate(GET_NODE_INFO_PATH, Some(credential)).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + assert!(MacaroonStore::load_or_create(&directory) + .unwrap() + .authenticate(GET_NODE_INFO_PATH, Some(credential)) + .is_err()); + } + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&admin_token(&store))).is_ok()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn issued_children_inherit_expiry_and_method_restrictions() { + let directory = test_directory("inherited-caveats"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let expiry = format!("time-before = {}", now() + 3600); + let token = restrict(&admin_token(&store), &[&expiry, "method = CreateMacaroon"]); + let issuer = store.authenticate(CREATE_MACAROON_PATH, Some(&token)).unwrap(); + let created = + store.create_key("child", vec![NODE_READ_PERMISSION.into()], &issuer).unwrap(); + assert!(created.info.caveats.contains(&expiry)); + assert!(created.info.caveats.contains(&"method = CreateMacaroon".to_string())); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&created.token)).is_err()); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert!(reloaded.authenticate(GET_NODE_INFO_PATH, Some(&created.token)).is_err()); + let mut stale = (*issuer).clone(); + stale.caveats.push("time-before = 0".into()); + assert!(store.create_key("expired", vec![NODE_READ_PERMISSION.into()], &stale).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn attenuation_limits_leave_token_unchanged() { + let mut token = Macaroon::mint(b"key", b"id", sign).unwrap(); + for _ in 0..ldk_server_grpc::macaroon::MAX_CAVEATS { + token.attenuate(b"permissions = admin", sign).unwrap(); + } + let original = token.serialize(); + assert!(token.attenuate(b"permissions = admin", sign).is_err()); + assert_eq!(token.serialize(), original); + let mut token = Macaroon::mint(b"key", b"id", sign).unwrap(); + let original = token.serialize(); + assert!(token.attenuate(&vec![b'x'; MAX_MACAROON_BYTES], sign).is_err()); + assert_eq!(token.serialize(), original); + } + + #[test] + fn bootstrap_token_is_private_and_recovers_with_the_same_root() { + let directory = test_directory("bootstrap-token"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let path = directory.join(MACAROONS_DIR).join(ADMIN_MACAROON_FILE); + let original = fs::read_to_string(&path).unwrap(); + assert_eq!(fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o400); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&original)).unwrap().is_admin()); + let roots = store.list_keys().unwrap(); + fs::remove_file(&path).unwrap(); + drop(store); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(reloaded.list_keys().unwrap(), roots); + assert_eq!(fs::read_to_string(path).unwrap(), original); + fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn restricted_admin_does_not_replace_the_last_unrestricted_admin() { + let directory = test_directory("restricted-admin"); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let token = restrict(&admin_token(&store), &["method = CreateMacaroon"]); + let issuer = store.authenticate(CREATE_MACAROON_PATH, Some(&token)).unwrap(); + store.create_key("restricted-admin", vec![ADMIN_PERMISSION.into()], &issuer).unwrap(); + let admin = store.list_keys().unwrap().into_iter().find(|i| i.name == "admin").unwrap(); + assert!(store.revoke_key(&admin.id, &admin).is_err()); + fs::remove_dir_all(directory).unwrap(); + } + + 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-macaroon-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..a541dba9 100644 --- a/ldk-server/src/main.rs +++ b/ldk-server/src/main.rs @@ -9,13 +9,12 @@ mod api; mod io; +mod macaroons; 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}; @@ -49,16 +48,15 @@ use crate::io::persist::{ FORWARDED_PAYMENTS_PERSISTENCE_PRIMARY_NAMESPACE, FORWARDED_PAYMENTS_PERSISTENCE_SECONDARY_NAMESPACE, }; +use crate::macaroons::MacaroonStore; use crate::service::NodeService; 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 macaroon_store = match MacaroonStore::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 macaroons: {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(&macaroon_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..5f3e9880 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::{ + CreateMacaroonRequest, CreateMacaroonResponse, GetPermissionsRequest, GetPermissionsResponse, + ListMacaroonsRequest, ListMacaroonsResponse, Macaroon, RevokeMacaroonRequest, + RevokeMacaroonResponse, +}; 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_MACAROON_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_CHANNELS_PATH, + LIST_FORWARDED_PAYMENTS_PATH, LIST_MACAROONS_PATH, LIST_PAYMENTS_PATH, LIST_PEERS_PATH, + ONCHAIN_RECEIVE_PATH, ONCHAIN_SEND_PATH, OPEN_CHANNEL_PATH, REVOKE_MACAROON_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}; @@ -86,6 +90,7 @@ 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::io::persist::paginated_kv_store::PaginatedKVStore; +use crate::macaroons::{method_authorization, MacaroonInfo, MacaroonStore, MethodAuthorization}; use crate::util::metrics::Metrics; /// gRPC path prefix for the LightningNode service. @@ -97,7 +102,7 @@ const MAX_BODY_SIZE: usize = 10 * 1024 * 1024; #[derive(Clone)] pub(crate) struct NodeService { context: Arc, - api_key: String, + macaroon_store: Arc, metrics: Option>, metrics_auth_header: Option, event_sender: broadcast::Sender, @@ -106,67 +111,16 @@ 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, + macaroon_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 } + Self { context, macaroon_store, 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")); - } - - Ok(()) -} - pub(crate) struct Context { pub(crate) node: Arc, pub(crate) paginated_kv_store: Arc, @@ -257,7 +211,7 @@ impl Service> for NodeService { }; let is_streaming = method == SUBSCRIBE_EVENTS_PATH; - let api_key = self.api_key.clone(); + let macaroon_store = Arc::clone(&self.macaroon_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,28 @@ 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("macaroon").and_then(|value| value.to_str().ok()); + let authenticated_key = match macaroon_store.authenticate(&method, auth_header) { + 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!("macaroon 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 +391,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 +435,33 @@ impl Service> for NodeService { }); Ok(grpc_response(GrpcBody::Stream { rx: mpsc_rx, done: false })) }, + CREATE_MACAROON_PATH => { + let store = Arc::clone(&macaroon_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_create_macaroon_request(store, authenticated_key, request) + }) + .await + }, + LIST_MACAROONS_PATH => { + let store = Arc::clone(&macaroon_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_list_macaroons_request(store, request) + }) + .await + }, + REVOKE_MACAROON_PATH => { + let store = Arc::clone(&macaroon_store); + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_revoke_macaroon_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 +487,58 @@ impl Service> for NodeService { } } +async fn handle_create_macaroon_request( + store: Arc, issuer: Arc, request: CreateMacaroonRequest, +) -> 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(CreateMacaroonResponse { + macaroon: Some(macaroon_to_proto(created.info)), + token: created.token, + }) +} + +async fn handle_list_macaroons_request( + store: Arc, _request: ListMacaroonsRequest, +) -> Result { + let macaroons = store.list_keys()?.into_iter().map(macaroon_to_proto).collect(); + Ok(ListMacaroonsResponse { macaroons }) +} + +async fn handle_revoke_macaroon_request( + store: Arc, issuer: Arc, request: RevokeMacaroonRequest, +) -> Result { + tokio::task::spawn_blocking(move || store.revoke_key(&request.id, &issuer)).await.map_err( + |error| LdkServerError::new(LdkServerErrorCode::InternalServerError, error.to_string()), + )??; + Ok(RevokeMacaroonResponse {}) +} + +async fn handle_get_permissions_request( + authenticated_key: Arc, _request: GetPermissionsRequest, +) -> Result { + Ok(GetPermissionsResponse { macaroon: Some(macaroon_to_proto((*authenticated_key).clone())) }) +} + +fn macaroon_to_proto(info: MacaroonInfo) -> Macaroon { + Macaroon { + id: info.id, + name: info.name, + permissions: info.permissions.into_iter().collect(), + caveats: info.caveats, + } +} + 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 +621,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 +632,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(); diff --git a/ldk-server/src/util/config.rs b/ldk-server/src/util/config.rs index 48b8de6f..232a2d74 100644 --- a/ldk-server/src/util/config.rs +++ b/ldk-server/src/util/config.rs @@ -1291,7 +1291,8 @@ fn parse_host_port(addr: &str) -> io::Result<(String, u16)> { #[cfg(test)] mod tests { - use std::{fs, str::FromStr}; + use std::fs; + use std::str::FromStr; use clap::Parser; use ldk_node::bitcoin::secp256k1::PublicKey;