diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9fa15f3..dc8c1f9d 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 +``` + +Run the end-to-end tests from their separate workspace: + +```bash +cargo test --manifest-path e2e-tests/Cargo.toml -- --test-threads=4 ``` ## Code Quality @@ -50,7 +56,15 @@ 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/authorization.rs`. + Unmapped methods return `UNIMPLEMENTED`, even for admin tokens. +6. Add CLI command in `ldk-server-cli/src/main.rs` +7. For a unary RPC, add the MCP tool in `ldk-server-mcp/src/tools/` and update the tool list test + in `ldk-server-mcp/tests/integration.rs`. Add a live test in `e2e-tests/tests/mcp.rs` if applicable. +8. Test allowed and denied requests, including admin access. + +If the RPC needs a new permission, add it to `ldk-server-grpc/src/permissions.rs` and +`ALL_PERMISSIONS`. Update the presets that need it and add it to `docs/api-guide.md`. ## Configuration diff --git a/Cargo.lock b/Cargo.lock index 8fba40c5..16791e91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1340,6 +1340,7 @@ dependencies = [ "hyper-util", "ldk-node", "ldk-server-grpc", + "ldk-server-macaroons", "log", "prost", "ring", @@ -1368,11 +1369,10 @@ dependencies = [ name = "ldk-server-client" version = "0.1.0" dependencies = [ - "bitcoin_hashes", - "hex-conservative 0.2.2", "hyper 0.14.32", "hyper-rustls 0.24.2", "ldk-server-grpc", + "ldk-server-macaroons", "prost", "reqwest 0.11.27", "rustls 0.21.12", @@ -1400,6 +1400,14 @@ dependencies = [ "tonic", ] +[[package]] +name = "ldk-server-macaroons" +version = "0.1.0" +dependencies = [ + "hex-conservative 0.2.2", + "ring", +] + [[package]] name = "ldk-server-mcp" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f9ccb552..baf1f477 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server", "ldk-server-mcp"] +members = ["ldk-server-cli", "ldk-server-client", "ldk-server-grpc", "ldk-server", "ldk-server-mcp", "ldk-server-macaroons"] exclude = ["e2e-tests"] [profile.release] diff --git a/README.md b/README.md index e69d65a1..81e21d2b 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ a Lightning node while exposing a robust, language-agnostic API via [Protocol Bu - `ldk-server-cli`: CLI client for the server API - `ldk-server-client`: Rust client library for authenticated TLS gRPC calls - `ldk-server-grpc`: generated protobuf and shared gRPC types +- `ldk-server-macaroons`: shared token parsing, signing, derivation, and request binding - `ldk-server-mcp`: stdio MCP bridge exposing unary `ldk-server` RPCs as MCP tools ### Features diff --git a/docs/api-guide.md b/docs/api-guide.md index 71feb52e..5c6ff9bd 100644 --- a/docs/api-guide.md +++ b/docs/api-guide.md @@ -15,24 +15,107 @@ underlying LDK Node documentation. ## Authentication -Every gRPC request must include an `x-auth` metadata header with an HMAC-SHA256 signature: +Each request needs a hex-encoded v2 macaroon in the `macaroon` header: +```text +macaroon: ``` -x-auth: HMAC : + +Keep your original macaroon private. The client uses it to make a token for each request, +as described below. All requests use TLS. + +You can add caveats to make a restricted copy of your macaroon without contacting the server. +A caveat limits its permissions, allowed methods, or expiry time. Added caveats can only reduce +access. See [Restrictions](#restrictions) for examples. + +### Request binding + +The Rust client, CLI, and MCP handle request binding automatically. They keep your macaroon +private and send a copy tied to the request's method, body, and time. + +Custom clients must add one final caveat: + +```text +request = ``` -Where: +Use Unix time in seconds, a method name such as `OnchainSend`, and the lowercase SHA-256 hash +of the exact gRPC body, including its five-byte frame header. Rust clients can use +`macaroon::bind_macaroon_to_request`. + +Client and server clocks must be within 60 seconds. The token cannot authorize a different +request, but the same request can still be replayed while the token is valid. + +See [Request proof format](request-binding.md) for exact encoding rules and an example. + +### Restrictions -- `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 +A caveat is a condition that limits what a macaroon can do. All caveats must pass: -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` | Allow only these permissions | +| `method = GetNodeInfo` | Allow only this RPC method | +| `time-before = 1800000000` | Expire at this Unix time in seconds | + +Added caveats can only reduce access. They cannot restore permissions or extend the expiry time. + +Derive a restricted copy without contacting the server: + +```bash +ldk-server-cli derive-macaroon "$MACAROON" \ + --caveat 'permissions = node:read' \ + --caveat 'method = GetNodeInfo' \ + --caveat "time-before = $EXPIRY_UNIX_SECONDS" +``` + +The command prints a hex token. Rust clients can use `macaroon::derive_macaroon`. +Give the copy to the application and keep the original private. + +### Create and revoke tokens + +Use `CreateMacaroon` to give each client a token you can revoke separately. New tokens keep +all the caller's restrictions. Revoking the caller's token does not revoke these new tokens. + +Copies made with `derive-macaroon` share the original token's ID. Revoking that ID blocks +all those copies. The server cannot list copies made locally. + +Revocation and expiry block new requests. Existing event streams stay open until the client +disconnects or the server stops. Reconnecting requires a valid token. + +See [Macaroon Management](#macaroon-management) for the RPCs and +[Operations](operations.md#macaroons) for storage and recovery. + +### Macaroon Permissions + +Choose the permissions each client needs, or use `admin` by itself for full access. +The CLI also has `readonly`, `invoice`, and `admin` presets. +RPCs with no permission mapping return `UNIMPLEMENTED`, even for admin tokens. + +| Permission | 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 within your permissions | + +MCP provides token management through `create_macaroon`, `list_macaroons`, `revoke_macaroon`, +and `get_permissions`. ## TLS @@ -67,9 +150,10 @@ Errors are returned as standard gRPC status codes: | gRPC Code | Meaning | |---------------------------|------------------------------------------------------------------| | `INVALID_ARGUMENT` (3) | Malformed request or invalid parameters | +| `PERMISSION_DENIED` (7) | Missing permission or a caveat that does not pass | | `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 +316,18 @@ 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 macaroon and return its private hex token in `token` | +| `ListMacaroons` | List IDs, names, permissions, and caveats, without secrets | +| `RevokeMacaroon` | Revoke a macaroon by ID | +| `GetPermissions` | Show the caller's ID, name, usable permissions, and caveats | + +The first three RPCs require `macaroons:manage` or `admin`. You can only create or revoke +tokens whose permissions you have. The last unrestricted admin token cannot be revoked. + ### Metrics Metrics are served as a plain HTTP GET endpoint (not gRPC): diff --git a/docs/configuration.md b/docs/configuration.md index e045cf17..c83b2cda 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 # Admin token (0400) + roots/ # Private root keys (0700) + admin.toml # Admin root key and permissions (0400) + .toml # Root key and permissions from CreateMacaroon (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..f8326f6b 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -73,28 +73,32 @@ gRPC service listening on 127.0.0.1:3536 NODE_URI: @
``` -Two files are auto-generated on first run: +The server creates these files 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` | Full API access | +| TLS certificate | `/tls.crt` | Secure client connections | The default storage directory is `~/.ldk-server/` on Linux and `~/Library/Application Support/ldk-server/` on macOS. -### Reading the API Key +### Client Macaroons -The API key file contains raw bytes. To get the hex string the CLI and client library expect: +The CLI reads `admin.macaroon` automatically. This file contains a hex token. +Keep it private, and never give clients files from `macaroons/roots/`. + +Create a restricted token for each application: ```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 +117,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..69256b4b 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,39 @@ the following config to `/etc/logrotate.d/ldk-server` (adjust the log path to ma ## Security -### API Key +### Macaroons -- 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 +Keep `/macaroons/` private. It contains the default admin token in `admin.macaroon` +and the server's root keys in `roots/`. Give clients tokens, never root keys. + +Use `create-macaroon` to give each client a token you can revoke separately. Use `derive-macaroon` +to make a restricted copy. Give each client only the permissions it needs. +See the [API guide](api-guide.md#authentication) for restrictions and request binding. + +To replace an admin token, create and save a new admin token, then revoke the old ID. +Replace `admin.macaroon` with the new token or pass it with `--macaroon`. +The API prevents revocation of the last unrestricted admin token. + +#### Recovery + +Back up `roots/` and `admin.macaroon` together. Old root files can restore revoked access. +Deleting all roots invalidates every token; the server creates a new admin token on restart. + +At startup, the server repairs a missing or invalid `admin.macaroon` from `roots/admin.toml` +and logs the change. It keeps valid tokens, even if restricted or expired. It warns if the +file holds a request token; replace that file with a reusable token. + +If the original admin root is gone but other roots remain, the server warns instead of replacing +it. Use another admin token to create a replacement and save it as `admin.macaroon`. + +Duplicate root names or IDs stop startup. Move conflicting files out of `roots/` and restart. +Files ending in `.tmp` are ignored. + +Root-file caveat edits take effect after restart. `GetPermissions` shows them, and newly issued +tokens inherit them. Tokens issued earlier have separate roots and do not change. + +The server logs successful token creation and revocation, including who made the change and +which token it affects. Logs contain no tokens or root secrets. ### TLS @@ -188,7 +214,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/docs/request-binding.md b/docs/request-binding.md new file mode 100644 index 00000000..384b6c77 --- /dev/null +++ b/docs/request-binding.md @@ -0,0 +1,73 @@ +# Request proof format + +This document gives the wire format for custom clients. The Rust client, CLI, and MCP +create request proofs automatically. See the [API guide](api-guide.md#authentication) +for normal use and supported policy caveats. + +## Create a request token + +1. Start with a private, reusable v2 macaroon. It must not contain a `request = ` caveat. +2. Encode the protobuf request and its gRPC frame header. Hash these exact bytes with SHA-256. +3. Add the proof below as the final first-party caveat. Use standard macaroon signing: + `new_signature = HMAC-SHA256(previous_signature, proof_bytes)`. Both signatures are raw + 32-byte values; `proof_bytes` are the ASCII caveat text. +4. Serialize the resulting v2 macaroon and hex-encode it. Send it in the `macaroon` metadata + header over TLS, with no prefix. Keep the reusable macaroon private. + +Do not edit or replace the proof in a request token. Make each request token from the reusable +macaroon. Rust code can use `ldk_server_macaroons::bind_macaroon_to_request`. + +## Caveat grammar + +```text +request = +``` + +The text is case-sensitive. Each space shown is exactly one ASCII space. There must be no +leading or trailing whitespace, newline, or extra field. + +| Field | Encoding | +|-------|----------| +| `unix-seconds` | Unix time in whole seconds, from `0` through `18446744073709551615`. Decimal digits only; no sign or leading zeros, except `0` itself. | +| `RpcMethod` | The short RPC name, such as `GetNodeInfo`. From 1 to 128 ASCII letters or digits (`A-Z`, `a-z`, `0-9`). Do not include the service name or URL path. | +| `body-sha256` | Exactly 64 lowercase hex characters (`0-9`, `a-f`), representing the SHA-256 hash of the exact gRPC body sent. | + +There must be exactly one request proof, in the final caveat position. All policy caveats go +before it. The proof is not a reusable restriction and is not inherited by newly issued +credentials or returned by `GetPermissions`. + +## Body bytes + +Hash the five-byte gRPC frame header followed by the protobuf bytes: + +```text +00 || protobuf_length_as_4_byte_big_endian_integer || protobuf_bytes +``` + +The first byte is the compression flag. The server accepts only uncompressed requests. +Do not hash HTTP headers, HTTP/2 frame headers, or a JSON form of the request. Do not encode +the protobuf again after hashing; send the same bytes. The server limits the request body, +including the gRPC frame header, to 10 MiB. + +An empty `GetNodeInfo` request has five zero bytes. At Unix time `1800000000`, its proof is: + +```text +request = 1800000000 GetNodeInfo 8855508aade16ec573d21e6a485dfd0a7624085c1a14b5ecdd6485de0c6839a4 +``` + +The final row in the [reference vectors](../ldk-server-macaroons/tests/data/macaroons-v2.txt) +contains this proof and its signed token. Its fixed timestamp is for format checks. + +## Limits and checks + +- A token can contain at most 4096 binary bytes (8192 hex characters) and 32 caveats, + including the proof. Transport hex can use either case; the body hash inside the proof + must be lowercase. +- Reusable credentials must reserve one caveat slot and 228 bytes for the largest proof: + 224 bytes of text plus four bytes of v2 encoding. The Rust helpers check this reserve. +- The timestamp must differ from server time by at most 60 seconds, in either direction. + The server checks it before and after reading the body. It also checks the body hash, + method, policy expiry, and revocation before it runs the RPC. +- The proof prevents use for a different request. An identical request can still be replayed + while the token is valid; there is no single-use check. Active streams continue after + their initial request passes authentication. diff --git a/e2e-tests/Cargo.lock b/e2e-tests/Cargo.lock index 4aa04d85..bd06043e 100644 --- a/e2e-tests/Cargo.lock +++ b/e2e-tests/Cargo.lock @@ -1331,11 +1331,10 @@ dependencies = [ name = "ldk-server-client" version = "0.1.0" dependencies = [ - "bitcoin_hashes", - "hex-conservative", "hyper 0.14.32", "hyper-rustls 0.24.2", "ldk-server-grpc", + "ldk-server-macaroons", "prost", "reqwest 0.11.27", "rustls 0.21.12", @@ -1355,6 +1354,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "ldk-server-macaroons" +version = "0.1.0" +dependencies = [ + "hex-conservative", + "ring", +] + [[package]] name = "leb128fmt" version = "0.1.0" diff --git a/e2e-tests/build.rs b/e2e-tests/build.rs index 10ccd3a0..dda8a615 100644 --- a/e2e-tests/build.rs +++ b/e2e-tests/build.rs @@ -56,6 +56,8 @@ fn main() { println!("cargo:rerun-if-changed=../ldk-server-cli/Cargo.toml"); println!("cargo:rerun-if-changed=../ldk-server-client/src"); println!("cargo:rerun-if-changed=../ldk-server-client/Cargo.toml"); + println!("cargo:rerun-if-changed=../ldk-server-macaroons/src"); + println!("cargo:rerun-if-changed=../ldk-server-macaroons/Cargo.toml"); println!("cargo:rerun-if-changed=../ldk-server-grpc/src"); println!("cargo:rerun-if-changed=../ldk-server-grpc/Cargo.toml"); println!("cargo:rerun-if-changed=../ldk-server-mcp/src"); 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..5923e434 100644 --- a/e2e-tests/tests/e2e.rs +++ b/e2e-tests/tests/e2e.rs @@ -439,13 +439,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 +476,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 +504,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 +645,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 +673,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 +1268,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 +1655,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/macaroons.rs b/e2e-tests/tests/macaroons.rs new file mode 100644 index 00000000..093faa5c --- /dev/null +++ b/e2e-tests/tests/macaroons.rs @@ -0,0 +1,296 @@ +// 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::time::Duration; + +use e2e_tests::{ + mine_and_sync, run_cli, setup_funded_channel, wait_for_event, LdkServerHandle, TestBitcoind, +}; +use ldk_server_client::client::LdkServerClient; +use ldk_server_client::error::LdkServerErrorCode::{ + AuthError, AuthorizationError, InvalidRequestError, +}; +use ldk_server_client::macaroon::{derive_macaroon, Macaroon}; +use ldk_server_grpc::api::{GetNodeInfoRequest, GetPermissionsRequest, OnchainReceiveRequest}; +use ldk_server_grpc::events::event_envelope::Event; +use ldk_server_grpc::events::ChannelState; + +fn client_with_macaroon(server: &LdkServerHandle, token: impl Into) -> LdkServerClient { + let certificate = std::fs::read(&server.tls_cert_path).unwrap(); + LdkServerClient::new(server.base_url(), token.into(), &certificate).unwrap() +} + +#[tokio::test] +async fn test_scoped_macaroon_lifecycle() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start_with_config(&bitcoind, |params| { + let log_path = params.storage_dir.join("macaroon-audit.log"); + e2e_tests::TestConfigBuilder::new(params) + .log(Some("Info"), log_path.to_str().unwrap()) + .build() + }) + .await; + + let created = run_cli(&server, &["create-macaroon", "readonly-client", "--preset", "readonly"]); + let root_id = created["macaroon"]["id"].as_str().unwrap(); + let secret = created["token"].as_str().unwrap(); + let client = client_with_macaroon(&server, secret.to_string()); + + client.get_node_info(GetNodeInfoRequest {}).await.unwrap(); + let permissions = client.get_permissions(GetPermissionsRequest {}).await.unwrap(); + let info = permissions.macaroon.unwrap(); + assert_eq!(info.name, "readonly-client"); + assert!(info.caveats.iter().any(|c| c.starts_with("permissions = "))); + 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 roots = run_cli(&server, &["list-macaroons"]); + assert!(roots["macaroons"].as_array().unwrap().iter().any(|info| info["id"] == root_id)); + // Offline derivation must affect both unary and streaming authorization. + let derived = ldk_server_client::macaroon::derive_macaroon( + secret, + &["permissions = node:read".into(), "method = GetNodeInfo".into()], + ) + .unwrap(); + let restricted = client_with_macaroon(&server, derived.clone()); + let root_file = std::fs::read_to_string( + server.storage_dir.join(format!("regtest/macaroons/roots/{root_id}.toml")), + ) + .unwrap(); + let root_secret = + root_file.lines().find_map(|line| line.strip_prefix("key = ")).unwrap().trim_matches('"'); + run_cli(&server, &["revoke-macaroon", &root_id.to_ascii_uppercase()]); + let audit = std::fs::read_to_string(server.storage_dir.join("macaroon-audit.log")).unwrap(); + let issuer = + server.client().get_permissions(Default::default()).await.unwrap().macaroon.unwrap().id; + for action in ["Created", "Revoked"] { + assert!(audit.contains(&format!( + "{action} macaroon: issuer={issuer} id={root_id} name=readonly-client permissions=" + ))); + } + assert!(!audit.contains(secret), "Audit log must not contain the token"); + assert!(!audit.contains(root_secret), "Audit log must not contain the root secret"); + assert_eq!( + restricted.get_node_info(Default::default()).await.unwrap_err().error_code, + AuthError + ); + + assert_eq!( + client.subscribe_events().await.err().expect("Revoked root must not subscribe").error_code, + AuthError + ); + assert_eq!( + client.get_node_info(GetNodeInfoRequest {}).await.unwrap_err().error_code, + AuthError + ); +} + +#[tokio::test] +async fn test_macaroon_splice_permissions() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + // 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 = + client_with_macaroon(&server, created["token"].as_str().unwrap().to_string()); + 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 + ); + } +} + +#[tokio::test] +async fn test_macaroon_restrictions() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let created = run_cli(&server, &["create-macaroon", "reader", "--preset", "readonly"]); + let secret = created["token"].as_str().unwrap(); + // Offline derivation must affect both unary and streaming authorization. + let derived = ldk_server_client::macaroon::derive_macaroon( + secret, + &["permissions = node:read".into(), "method = GetNodeInfo".into()], + ) + .unwrap(); + let restricted = client_with_macaroon(&server, derived.clone()); + 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); +} + +#[tokio::test] +async fn test_macaroon_expiry() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + let created = run_cli(&server, &["create-macaroon", "reader", "--preset", "readonly"]); + let secret = created["token"].as_str().unwrap(); + let expired = + ldk_server_client::macaroon::derive_macaroon(secret, &["time-before = 0".into()]).unwrap(); + let expired = client_with_macaroon(&server, expired); + assert_eq!( + expired.get_node_info(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + + let expiry = std::time::SystemTime::now() + std::time::Duration::from_secs(3); + let expiry_seconds = expiry.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs(); + let expiry_caveat = format!("time-before = {expiry_seconds}"); + let expiring = + ldk_server_client::macaroon::derive_macaroon(secret, std::slice::from_ref(&expiry_caveat)) + .unwrap(); + let expiring = client_with_macaroon(&server, expiring.to_ascii_uppercase()); + expiring.get_node_info(Default::default()).await.unwrap(); + assert!(expiring + .get_permissions(Default::default()) + .await + .unwrap() + .macaroon + .unwrap() + .caveats + .contains(&expiry_caveat)); + let events = expiring.subscribe_events().await.unwrap(); + tokio::time::sleep(expiry.duration_since(std::time::SystemTime::now()).unwrap_or_default()) + .await; + assert_eq!( + expiring.get_node_info(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); + assert_eq!(expiring.subscribe_events().await.err().unwrap().error_code, AuthorizationError); + drop(events); +} + +#[tokio::test] +async fn test_macaroon_delegation() { + let bitcoind = TestBitcoind::new(); + let server = LdkServerHandle::start(&bitcoind).await; + // Request proofs must not become policy on newly issued credentials. + let manager = run_cli( + &server, + &[ + "create-macaroon", + "delegating-manager", + "--permissions", + "macaroons:manage", + "node:read", + ], + ); + let manager_expiry = format!( + "time-before = {}", + (std::time::SystemTime::now() + Duration::from_secs(3600)) + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + ); + let manager_token = ldk_server_client::macaroon::derive_macaroon( + manager["token"].as_str().unwrap(), + std::slice::from_ref(&manager_expiry), + ) + .unwrap(); + let manager_client = client_with_macaroon(&server, manager_token); + let child = manager_client + .create_macaroon(ldk_server_grpc::api::CreateMacaroonRequest { + name: "delegated-manager".into(), + permissions: vec!["macaroons:manage".into(), "node:read".into()], + }) + .await + .unwrap(); + let child_client = client_with_macaroon(&server, child.token); + let grandchild = child_client + .create_macaroon(ldk_server_grpc::api::CreateMacaroonRequest { + name: "delegated-reader".into(), + permissions: vec!["node:read".into()], + }) + .await + .unwrap(); + let grandchild_client = client_with_macaroon(&server, grandchild.token); + grandchild_client.get_node_info(Default::default()).await.unwrap(); + let info = + grandchild_client.get_permissions(Default::default()).await.unwrap().macaroon.unwrap(); + assert!(info.caveats.contains(&manager_expiry)); + assert!(!info.caveats.iter().any(|c| c.starts_with("request = "))); + assert!(!info.caveats.iter().any(|c| c == "method = CreateMacaroon")); + assert_eq!( + grandchild_client.onchain_receive(Default::default()).await.unwrap_err().error_code, + AuthorizationError + ); +} + +#[test] +fn test_offline_macaroon_derivation() { + let token = Macaroon::mint(b"test root", b"test-id").unwrap().to_hex(); + let secret = token.as_str(); + let derived = + derive_macaroon(secret, &["permissions = node:read".into(), "method = GetNodeInfo".into()]) + .unwrap(); + let output = std::process::Command::new(e2e_tests::cli_binary_path()) + .args([ + "--base-url", + "invalid.invalid:1", + "--tls-cert", + "/no-certificate-needed", + "derive-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(), derived); +} + +#[tokio::test] +async fn test_revoking_a_root_keeps_existing_event_streams_open() { + 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 client = client_with_macaroon(&server_a, created["token"].as_str().unwrap().to_string()); + 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 root 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; +} diff --git a/e2e-tests/tests/mcp.rs b/e2e-tests/tests/mcp.rs index 3ae00766..e3b49c1f 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::derive_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..65811346 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -10,17 +10,19 @@ use std::fmt::Write; use std::path::PathBuf; +use clap::builder::{PossibleValuesParser, TypedValueParser}; use clap::{CommandFactory, Parser, Subcommand}; 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,21 +35,23 @@ 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::MacaroonPreset; use ldk_server_client::ldk_server_grpc::types::{ bolt11_invoice_description, Bolt11InvoiceDescription, ChannelConfig, CustomTlvRecord, PayerProofOptions, RouteParametersConfig, @@ -92,8 +96,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!("Hex macaroon. Defaults to the token in {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 +641,48 @@ enum Commands { #[arg(help = "The hex-encoded node ID to look up")] node_id: String, }, + #[command(about = "Create a macaroon with chosen permissions")] + CreateMacaroon { + #[arg(help = "A unique name for the macaroon")] + name: String, + #[arg( + short, + long, + num_args = 1.., + conflicts_with = "preset", + required_unless_present = "preset", + help = "Permissions to grant, such as node:read or invoices:create" + )] + permissions: Vec, + #[arg( + long, + value_parser = PossibleValuesParser::new(MacaroonPreset::ALL.map(MacaroonPreset::name)) + .try_map(|value| value.parse::()), + conflicts_with = "permissions", + help = "Use a permission preset" + )] + preset: Option, + }, + #[command(about = "Derive a restricted copy without contacting the server")] + DeriveMacaroon { + #[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 a 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( @@ -650,6 +696,16 @@ enum Commands { #[tokio::main] async fn main() { let cli = Cli::parse(); + if let Commands::DeriveMacaroon { token, caveats } = &cli.command { + match ldk_server_client::macaroon::derive_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 +729,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 +752,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 +1357,28 @@ async fn main() { client.graph_get_node(GraphGetNodeRequest { node_id }).await, ); }, + 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::DeriveMacaroon { .. } => unreachable!("Handled before connecting"), Commands::Completions { .. } => unreachable!("Handled above"), } } @@ -1461,6 +1539,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", @@ -1473,6 +1552,41 @@ fn handle_error(e: LdkServerError) -> ! { mod tests { use super::*; + #[test] + fn macaroon_presets_parse_and_appear_in_help() { + for (name, expected) in [ + ("readonly", MacaroonPreset::Readonly), + ("invoice", MacaroonPreset::Invoice), + ("admin", MacaroonPreset::Admin), + ] { + let cli = Cli::try_parse_from([ + "ldk-server-cli", + "create-macaroon", + "test", + "--preset", + name, + ]) + .unwrap(); + let Commands::CreateMacaroon { preset, .. } = cli.command else { + panic!("Expected CreateMacaroon"); + }; + assert_eq!(preset, Some(expected)); + } + assert!(Cli::try_parse_from([ + "ldk-server-cli", + "create-macaroon", + "test", + "--preset", + "unknown", + ]) + .is_err()); + let help = Cli::try_parse_from(["ldk-server-cli", "create-macaroon", "--help"]) + .err() + .unwrap() + .to_string(); + assert!(help.contains("[possible values: readonly, invoice, admin]")); + } + #[tokio::test] async fn fetch_paginated_collects_multiple_pages() { let response = fetch_paginated( diff --git a/ldk-server-client/Cargo.toml b/ldk-server-client/Cargo.toml index 677655ab..0f8f788e 100644 --- a/ldk-server-client/Cargo.toml +++ b/ldk-server-client/Cargo.toml @@ -19,10 +19,9 @@ serde = ["dep:serde", "dep:toml", "ldk-server-grpc/serde"] [dependencies] ldk-server-grpc = { path = "../ldk-server-grpc" } +ldk-server-macaroons = { path = "../ldk-server-macaroons" } reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] } prost = { version = "0.11.6", default-features = false, features = ["std", "prost-derive"] } -bitcoin_hashes = "0.14" -hex-conservative = { version = "0.2", default-features = false, features = ["std"] } hyper = { version = "0.14", default-features = false, features = ["client", "http2", "runtime", "tcp"] } hyper-rustls = { version = "0.24", default-features = false, features = ["http2", "tls12", "tokio-runtime"] } rustls = "0.21" diff --git a/ldk-server-client/README.md b/ldk-server-client/README.md index e6e50adb..11459c77 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,16 @@ 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. +Pass your hex macaroon and the server's TLS certificate to `LdkServerClient::new`. +The default files are `//macaroons/admin.macaroon` and +`/tls.crt`. + +The client keeps your macaroon private and sends a copy tied to each request's method, body, +and time. Keep client and server clocks within 60 seconds. The same request can still be +replayed while its token is valid. + +For custom transports, use `macaroon::bind_macaroon_to_request`. +See [Request binding](../docs/api-guide.md#request-binding) for the required body format. ## Event Streaming @@ -39,7 +45,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 +64,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 +107,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..6393a787 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,19 @@ 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. + /// Pass a private hex-encoded v2 `macaroon`. The client sends a copy tied to each + /// request's method, body, and time. /// `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_reusable_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 +119,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 +447,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 + } + + /// Show the calling macaroon's ID, name, permissions, and caveats. + 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. @@ -468,6 +482,11 @@ impl LdkServerClient { self.grpc_server_streaming(&SubscribeEventsRequest {}, SUBSCRIBE_EVENTS_PATH).await } + fn request_macaroon(&self, method: &str, body: &[u8]) -> Result { + crate::macaroon::bind_macaroon_to_request(&self.macaroon, method, body) + .map_err(|message| LdkServerError::new(InternalError, message)) + } + /// Send a unary gRPC request and decode the response. async fn grpc_unary( &self, request: &Rq, method: &str, @@ -476,7 +495,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.request_macaroon(method, &grpc_body)?; let response = self .client @@ -484,7 +503,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 +537,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.request_macaroon(method, &grpc_body)?; let response = self .streaming_client @@ -528,7 +547,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 +625,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 +907,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..ccd52f24 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 = crate::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 admin macaroon path for this network. +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) +/// Admin macaroon path for this storage directory and network. +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,54 +154,32 @@ 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. +/// Find the macaroon to use for requests. /// -/// 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. -/// -/// 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( - override_key: Option, config: Option<&Config>, +/// Use `override_macaroon` if supplied. Otherwise, look for `admin.macaroon` in the configured +/// storage directory, then the default data directory. +/// Return an error if a file cannot be read, is too large, or contains an invalid token. +pub fn resolve_macaroon( + override_macaroon: Option, config: Option<&Config>, ) -> Result, String> { - if override_key.is_some() { - return Ok(override_key); + if override_macaroon.is_some() { + return Ok(override_macaroon); } 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 +198,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_reusable_macaroon(token) + .map_err(|error| format!("Invalid macaroon in '{}': {error}", 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 +239,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 +365,47 @@ 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.clone())); + let admin_path = admin_directory.join("admin.macaroon"); + let bound = + crate::macaroon::bind_macaroon_to_request(&token, "GetNodeInfo", &[0; 5]).unwrap(); + fs::write(&admin_path, &bound).unwrap(); + let error = resolve_macaroon(None, Some(&config)).unwrap_err(); + assert!(error.contains("request-bound macaroon cannot be used as a reusable credential")); + assert!(!error.contains(&bound)); + 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..8e1ca6b6 100644 --- a/ldk-server-client/src/lib.rs +++ b/ldk-server-client/src/lib.rs @@ -15,6 +15,9 @@ /// Implements a [`LdkServerClient`](client::LdkServerClient) to access a hosted instance of LDK Server. pub mod client; +/// Macaroon credentials and request binding. +pub use ldk_server_macaroons as macaroon; + /// Shared configuration loading and credential resolution logic reused by `ldk-server` clients. #[cfg(feature = "serde")] pub mod config; diff --git a/ldk-server-grpc/src/api.rs b/ldk-server-grpc/src/api.rs index bbb7c756..95c60677 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 {} +/// Macaroon details, without the token or root key. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct MacaroonInfo { + /// The hex ID used to revoke this macaroon. + #[prost(string, tag = "1")] + pub id: ::prost::alloc::string::String, + /// The name chosen when this macaroon was created. + #[prost(string, tag = "2")] + pub name: ::prost::alloc::string::String, + /// Granted permissions. GetPermissions returns those the caller can use. + #[prost(string, repeated, tag = "3")] + pub permissions: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Restrictions that must all pass. + #[prost(string, repeated, tag = "4")] + pub caveats: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +/// Create a macaroon with chosen permissions. +#[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 name for the macaroon. + #[prost(string, tag = "1")] + pub name: ::prost::alloc::string::String, + /// The permissions 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 new macaroon details and its private hex-encoded v2 token. +#[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 macaroons created by the server, without secrets. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[cfg_attr(feature = "serde", serde(default))] +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct 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 {} +/// Show the calling macaroon's ID, name, permissions, and caveats. +#[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..dda9dfea 100644 --- a/ldk-server-grpc/src/lib.rs +++ b/ldk-server-grpc/src/lib.rs @@ -14,6 +14,7 @@ pub mod endpoints; pub mod error; pub mod events; pub mod grpc; +pub mod permissions; #[cfg(feature = "serde")] pub mod serde_utils; pub mod types; diff --git a/ldk-server-grpc/src/permissions.rs b/ldk-server-grpc/src/permissions.rs new file mode 100644 index 00000000..14ae2cef --- /dev/null +++ b/ldk-server-grpc/src/permissions.rs @@ -0,0 +1,121 @@ +// 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 a 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, +]; + +/// Named permission sets for issuing macaroons. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MacaroonPreset { + Readonly, + Invoice, + Admin, +} + +impl MacaroonPreset { + /// All supported presets, in display order. + pub const ALL: [Self; 3] = [Self::Readonly, Self::Invoice, Self::Admin]; + + /// The lowercase name of this preset. + pub fn name(self) -> &'static str { + match self { + Self::Readonly => "readonly", + Self::Invoice => "invoice", + Self::Admin => "admin", + } + } + + /// Permissions granted by this preset. + pub 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()], + } + } +} + +impl std::str::FromStr for MacaroonPreset { + type Err = &'static str; + + fn from_str(name: &str) -> Result { + Self::ALL.into_iter().find(|preset| preset.name() == name).ok_or("Unknown macaroon preset") + } +} diff --git a/ldk-server-grpc/src/proto/api.proto b/ldk-server-grpc/src/proto/api.proto index 2f3bbab8..35678def 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 {} +// Macaroon details, without the token or root key. +message MacaroonInfo { + // The hex ID used to revoke this macaroon. + string id = 1; + + // The name chosen when this macaroon was created. + string name = 2; + + // Granted permissions. GetPermissions returns those the caller can use. + repeated string permissions = 3; + + // Restrictions that must all pass. + repeated string caveats = 4; +} + +// Create a macaroon with chosen permissions. +message CreateMacaroonRequest { + // A unique name for the macaroon. + string name = 1; + + // The permissions to grant. Use "admin" by itself for unrestricted access. + repeated string permissions = 2; +} + +// The new macaroon details and its private hex-encoded v2 token. +message CreateMacaroonResponse { + MacaroonInfo macaroon = 1; + string token = 2; +} + +// List macaroons created by the server, without secrets. +message ListMacaroonsRequest {} + +message ListMacaroonsResponse { + repeated MacaroonInfo macaroons = 1; +} + +// Revoke a macaroon by ID. +message RevokeMacaroonRequest { + string id = 1; +} + +message RevokeMacaroonResponse {} + +// Show the calling macaroon's ID, name, permissions, and caveats. +message GetPermissionsRequest {} + +message GetPermissionsResponse { + MacaroonInfo 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); + // Show permissions for the calling macaroon. + rpc GetPermissions(GetPermissionsRequest) returns (GetPermissionsResponse); } diff --git a/ldk-server-macaroons/Cargo.toml b/ldk-server-macaroons/Cargo.toml new file mode 100644 index 00000000..3c7fffe6 --- /dev/null +++ b/ldk-server-macaroons/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ldk-server-macaroons" +version = "0.1.0" +authors = ["benthecarman ", "Elias Rohrer "] +homepage = "https://lightningdevkit.org/" +license = "MIT OR Apache-2.0" +edition = "2021" +rust-version = "1.85" +description = "Macaroon credentials and request binding for LDK Server." +repository = "https://github.com/lightningdevkit/ldk-server/" +readme = "README.md" + +[dependencies] +ring = { version = "0.17", default-features = false } +hex-conservative = { version = "0.2", default-features = false, features = ["std"] } diff --git a/ldk-server-macaroons/README.md b/ldk-server-macaroons/README.md new file mode 100644 index 00000000..f1dd51b8 --- /dev/null +++ b/ldk-server-macaroons/README.md @@ -0,0 +1,17 @@ +# ldk-server-macaroons + +Shared macaroon code for LDK Server and its clients. Supports v2 tokens, first-party caveats, +and request binding. Signatures use HMAC-SHA256 with constant-time verification. + +- `Macaroon::from_hex` parses a token. `verify_signature` checks its signature. +- `parse_reusable_macaroon` rejects request tokens and checks room for request binding. +- `derive_macaroon` makes a restricted copy without contacting the server. +- `bind_macaroon_to_request` makes a token tied to a method, body, and the current time. + +The server must still check every caveat, permissions, expiry, and revocation. +It also owns root generation and storage. This crate has no storage or networking code. + +See the [API guide](https://github.com/lightningdevkit/ldk-server/blob/main/docs/api-guide.md#authentication) +for usage and supported restrictions, and the +[request proof format](https://github.com/lightningdevkit/ldk-server/blob/main/docs/request-binding.md) +for exact encoding rules. diff --git a/ldk-server-macaroons/src/credential.rs b/ldk-server-macaroons/src/credential.rs new file mode 100644 index 00000000..34a6d9b9 --- /dev/null +++ b/ldk-server-macaroons/src/credential.rs @@ -0,0 +1,191 @@ +// 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. + +//! Derive restricted macaroons and bind them to requests without contacting the server. + +use crate::{Macaroon, RequestBinding, REQUEST_CAVEAT_PREFIX}; + +/// Parse a private reusable credential and check that it has room for request binding. +/// This rejects request tokens but does not verify the signature or enforce caveats. +pub fn parse_reusable_macaroon(token: &str) -> Result { + let macaroon = Macaroon::from_hex(token).map_err(str::to_string)?; + if macaroon.has_request_proof() { + return Err("A request-bound macaroon cannot be used as a reusable credential".into()); + } + macaroon.check_request_capacity().map_err(str::to_string)?; + Ok(macaroon) +} + +/// Derive a restricted copy of a hex-encoded v2 macaroon. Keep both tokens private. +/// +/// Caveats can limit permissions (`permissions = node:read,payments:read`), +/// the RPC method (`method = GetNodeInfo`), or expiry (`time-before = 1800000000`). +/// Expiry is a Unix time in seconds. All caveats must pass; added caveats can only reduce access. +/// This function can encode unknown conditions, but the server rejects them. +pub fn derive_macaroon(token: &str, caveats: &[String]) -> Result { + let mut macaroon = parse_reusable_macaroon(token)?; + for caveat in caveats { + if caveat.starts_with(REQUEST_CAVEAT_PREFIX) { + return Err("Use bind_macaroon_to_request to create a request proof".into()); + } + macaroon.attenuate(caveat.as_bytes()).map_err(str::to_string)?; + } + macaroon.check_request_capacity().map_err(str::to_string)?; + Ok(macaroon.to_hex()) +} + +/// Make a token tied to an RPC method, body, and the current time. +/// +/// Keep the original macaroon private. Send the returned token in the `macaroon` header. +/// Use a method name such as `GetNodeInfo`. `body` must include the exact gRPC bytes sent, +/// including the five-byte frame header. Clocks must be within 60 seconds. +/// The same request can still be replayed while the token is valid. +pub fn bind_macaroon_to_request(token: &str, method: &str, body: &[u8]) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| "System time is before the Unix epoch")? + .as_secs(); + bind_macaroon_to_request_at(token, method, body, timestamp) +} + +/// Bind a request with an explicit Unix timestamp in seconds. +/// +/// Use this with a custom clock or in tests. The same token, method, and body rules as +/// [`bind_macaroon_to_request`] apply. The server still requires a fresh timestamp. +pub fn bind_macaroon_to_request_at( + token: &str, method: &str, body: &[u8], timestamp: u64, +) -> Result { + let binding = RequestBinding::new(method, body, timestamp); + let mut macaroon = parse_reusable_macaroon(token)?; + macaroon.attenuate(binding.caveat()?.as_bytes()).map_err(str::to_string)?; + Ok(macaroon.to_hex()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::MAX_MACAROON_BYTES; + + #[test] + fn request_proof_matches_external_reference() { + let rows: Vec<_> = include_str!("../tests/data/macaroons-v2.txt") + .lines() + .filter(|line| !line.starts_with('#')) + .collect(); + let credential = rows[1].split_whitespace().nth(2).unwrap(); + let expected = rows.last().unwrap().split_whitespace().nth(2).unwrap(); + assert_eq!( + bind_macaroon_to_request_at(credential, "GetNodeInfo", &[0; 5], 1800000000).unwrap(), + expected + ); + } + + #[test] + fn request_binding_preserves_private_credential_and_signs_exact_body() { + let root = b"test root"; + let mut credential = Macaroon::mint(root, b"test id").unwrap(); + credential.attenuate(b"permissions = node:read").unwrap(); + let token = credential.to_hex(); + let body = b"\0\0\0\0\x03abc"; + let header = bind_macaroon_to_request(&token, "GetNodeInfo", body).unwrap(); + assert_ne!(header, token); + assert_eq!(parse_reusable_macaroon(&token).unwrap().caveats().len(), 1); + let transmitted = Macaroon::from_hex(&header).unwrap(); + assert_eq!(&transmitted.caveats()[..1], credential.caveats()); + let binding = RequestBinding::parse(transmitted.caveats().last().unwrap()).unwrap(); + assert_eq!(binding.method, "GetNodeInfo"); + assert!(binding.matches_body(body)); + assert!(!binding.matches_body(b"changed")); + assert!(transmitted.verify_signature(root)); + assert!(parse_reusable_macaroon(&header).is_err()); + assert!(bind_macaroon_to_request(&header, "GetBalances", b"changed").is_err()); + assert!(derive_macaroon(&token, &[binding.caveat().unwrap()]).is_err()); + } + + #[test] + fn offline_restrictions_reserve_space_for_request_binding() { + let token = Macaroon::mint(b"root", b"id").unwrap().to_hex(); + let fits = vec!["permissions = node:read".into(); crate::MAX_CAVEATS - 1]; + let fits = derive_macaroon(&token, &fits).unwrap(); + assert!(bind_macaroon_to_request(&fits, "GetNodeInfo", b"").is_ok()); + assert!(derive_macaroon(&fits, &["method = GetNodeInfo".into()]).is_err()); + assert!(derive_macaroon(&token, &["x".repeat(MAX_MACAROON_BYTES - 100)]).is_err()); + } + + #[test] + fn request_capacity_accepts_exact_limit_and_rejects_one_more_byte() { + let root = b"root"; + let credential = Macaroon::mint(root, b"id").unwrap(); + let method = "X".repeat(128); + let body = [0; 5]; + let proof = RequestBinding::new(&method, &body, u64::MAX).caveat().unwrap(); + // Each long caveat has a tag, two length bytes, and an end marker. + let padding_len = MAX_MACAROON_BYTES - credential.serialize().len() - proof.len() - 8; + let padding = "x".repeat(padding_len); + let token = derive_macaroon(&credential.to_hex(), &[padding]).unwrap(); + parse_reusable_macaroon(&token).unwrap().check_request_capacity().unwrap(); + let bound = bind_macaroon_to_request_at(&token, &method, &body, u64::MAX).unwrap(); + let bound = Macaroon::from_hex(&bound).unwrap(); + assert_eq!(bound.serialize().len(), MAX_MACAROON_BYTES); + assert!(bound.verify_signature(root)); + assert_eq!(bound.caveats().last().unwrap(), proof.as_bytes()); + + let mut too_large = credential.clone(); + let padding = "x".repeat(padding_len + 1); + too_large.attenuate(padding.as_bytes()).unwrap(); + assert!(too_large.check_request_capacity().is_err()); + assert!(parse_reusable_macaroon(&too_large.to_hex()).is_err()); + assert!(derive_macaroon(&credential.to_hex(), &[padding]).is_err()); + assert!(bind_macaroon_to_request_at(&too_large.to_hex(), &method, &body, u64::MAX).is_err()); + } + + #[test] + fn request_binding_rejects_invalid_method_names() { + let token = Macaroon::mint(b"root", b"id").unwrap().to_hex(); + for method in [ + "", + "Get_NodeInfo", + "Get-NodeInfo", + "Get NodeInfo", + "GetNodeInfo\n", + "GétNodeInfo", + "/api.LightningNode/GetNodeInfo", + &"X".repeat(129), + ] { + assert_eq!( + RequestBinding::new(method, b"", 123).caveat(), + Err("Invalid request method") + ); + assert_eq!( + bind_macaroon_to_request(&token, method, b""), + Err("Invalid request method".into()) + ); + } + for method in ["A", "Rpc123", &"X".repeat(128)] { + let bound = bind_macaroon_to_request(&token, method, b"").unwrap(); + let bound = Macaroon::from_hex(&bound).unwrap(); + let proof = RequestBinding::parse(bound.caveats().last().unwrap()).unwrap(); + assert_eq!(proof.method, method); + } + } + + #[test] + fn derivation_matches_reference_implementation() { + let tokens: Vec<_> = include_str!("../tests/data/macaroons-v2.txt") + .lines() + .filter(|line| !line.starts_with('#')) + .map(|line| line.split_whitespace().nth(2).unwrap()) + .collect(); + let first = derive_macaroon(tokens[0], &["permissions = node:read".into()]).unwrap(); + assert_eq!(first, tokens[1]); + assert_eq!(derive_macaroon(&first, &["method = GetNodeInfo".into()]).unwrap(), tokens[2]); + assert!(derive_macaroon("deadbeef", &[]).is_err()); + assert!(derive_macaroon(&"00".repeat(MAX_MACAROON_BYTES + 1), &[]).is_err()); + } +} diff --git a/ldk-server-macaroons/src/lib.rs b/ldk-server-macaroons/src/lib.rs new file mode 100644 index 00000000..f9697e80 --- /dev/null +++ b/ldk-server-macaroons/src/lib.rs @@ -0,0 +1,23 @@ +// 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. + +#![doc = include_str!("../README.md")] +#![deny(rustdoc::broken_intra_doc_links)] +#![deny(missing_docs)] + +mod credential; +mod macaroon; + +pub use credential::{ + bind_macaroon_to_request, bind_macaroon_to_request_at, derive_macaroon, parse_reusable_macaroon, +}; +pub use macaroon::{ + Macaroon, RequestBinding, MAX_CAVEATS, MAX_MACAROON_BYTES, REQUEST_CAVEAT_PREFIX, + REQUEST_TIMESTAMP_TOLERANCE_SECS, +}; diff --git a/ldk-server-macaroons/src/macaroon.rs b/ldk-server-macaroons/src/macaroon.rs new file mode 100644 index 00000000..d0eded67 --- /dev/null +++ b/ldk-server-macaroons/src/macaroon.rs @@ -0,0 +1,480 @@ +// 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. +//! +//! Signatures use HMAC-SHA256 with constant-time verification. +//! Third-party caveats and other formats are rejected. +//! Format: . + +use hex_conservative::{DisplayHex, FromHex}; +use ring::{digest, hmac}; + +/// 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"; + +/// Maximum difference between a request timestamp and server time, in seconds. +pub const REQUEST_TIMESTAMP_TOLERANCE_SECS: u64 = 60; +/// Reserved final caveat for a single RPC invocation. It is not a delegation restriction. +pub const REQUEST_CAVEAT_PREFIX: &str = "request = "; +const MAX_REQUEST_METHOD_BYTES: usize = 128; +// Prefix, u64 timestamp, separators, method, and lowercase SHA-256 digest. +const MAX_REQUEST_CAVEAT_BYTES: usize = + REQUEST_CAVEAT_PREFIX.len() + 20 + 1 + MAX_REQUEST_METHOD_BYTES + 1 + 64; + +/// A request proof carried by the final first-party caveat. +/// The body digest covers the exact gRPC body bytes, including the five-byte frame header. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RequestBinding { + /// Unix time in seconds. + pub timestamp: u64, + /// Short RPC method name, such as `GetNodeInfo`. + pub method: String, + /// SHA-256 hash of the exact gRPC body, including the frame header. + pub body_sha256: [u8; 32], +} + +impl RequestBinding { + /// Bind a method and the exact request body to a Unix timestamp in seconds. + pub fn new(method: &str, body: &[u8], timestamp: u64) -> Self { + Self { + timestamp, + method: method.into(), + body_sha256: digest::digest(&digest::SHA256, body).as_ref().try_into().unwrap(), + } + } + + /// Check the hash of the exact request body, including its gRPC frame header. + pub fn matches_body(&self, body: &[u8]) -> bool { + self.body_sha256.as_slice() == digest::digest(&digest::SHA256, body).as_ref() + } + + /// Encode the canonical request caveat. + pub fn caveat(&self) -> Result { + use std::fmt::Write; + if !valid_request_method(&self.method) { + return Err("Invalid request method"); + } + let mut caveat = format!("{REQUEST_CAVEAT_PREFIX}{} {} ", self.timestamp, self.method); + for byte in self.body_sha256 { + write!(caveat, "{byte:02x}").unwrap(); + } + Ok(caveat) + } + + /// Parse exactly one timestamp, method name, and lowercase SHA-256 digest. + pub fn parse(caveat: &[u8]) -> Result { + let invalid = "Invalid request binding caveat"; + let caveat = std::str::from_utf8(caveat).map_err(|_| invalid)?; + let mut fields = caveat.strip_prefix(REQUEST_CAVEAT_PREFIX).ok_or(invalid)?.split(' '); + let timestamp_text = fields.next().ok_or(invalid)?; + let timestamp: u64 = timestamp_text.parse().map_err(|_| invalid)?; + let method = fields.next().ok_or(invalid)?; + let digest = fields.next().ok_or(invalid)?; + if timestamp_text != timestamp.to_string() + || !valid_request_method(method) + || digest.len() != 64 + || fields.next().is_some() + || !digest.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(invalid); + } + let mut body_sha256 = [0; 32]; + for (index, byte) in body_sha256.iter_mut().enumerate() { + *byte = + u8::from_str_radix(&digest[index * 2..index * 2 + 2], 16).map_err(|_| invalid)?; + } + Ok(Self { timestamp, method: method.to_string(), body_sha256 }) + } +} + +fn valid_request_method(method: &str) -> bool { + !method.is_empty() + && method.len() <= MAX_REQUEST_METHOD_BYTES + && method.bytes().all(|b| b.is_ascii_alphanumeric()) +} + +/// 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 { + /// Parse a hex token. This does not verify its signature or caveats. + pub fn from_hex(token: &str) -> Result { + if token.len() > MAX_MACAROON_BYTES * 2 { + return Err("Macaroon exceeds size limit"); + } + let data = Vec::::from_hex(token).map_err(|_| "Macaroon must be hexadecimal")?; + Self::deserialize(&data) + } + + /// Encode this token as lowercase hex. + pub fn to_hex(&self) -> String { + self.serialize().to_lower_hex_string() + } + + /// Mint a macaroon using the standard root-key derivation and HMAC-SHA256. + pub fn mint(root_key: &[u8], identifier: &[u8]) -> Result { + if identifier.is_empty() || identifier.len() > MAX_MACAROON_BYTES { + return Err("Invalid macaroon identifier size"); + } + let key = sign(KEY_GENERATOR, root_key); + let macaroon = Self { + location: None, + identifier: identifier.to_vec(), + caveats: Vec::new(), + signature: sign(&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 + } + + /// Whether any caveat uses the reserved request-proof prefix. + /// This checks presence only, not the proof's format, position, signature, or freshness. + pub fn has_request_proof(&self) -> bool { + self.caveats.iter().any(|c| c.starts_with(REQUEST_CAVEAT_PREFIX.as_bytes())) + } + + /// Add a restriction without the root key. This cannot remove existing restrictions. + pub fn attenuate(&mut self, caveat: &[u8]) -> 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 = sign(&self.signature, caveat); + Ok(()) + } + + /// Ensure a reusable credential has room for a request proof of any supported length. + pub fn check_request_capacity(&self) -> Result<(), &'static str> { + // One field tag, two length bytes, and one end-of-caveat marker. + if self.caveats.len() >= MAX_CAVEATS + || self.serialize().len() + MAX_REQUEST_CAVEAT_BYTES + 4 > MAX_MACAROON_BYTES + { + return Err("Macaroon has no room for a request binding 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]) -> bool { + let key = sign(KEY_GENERATOR, root_key); + let Some((last, preceding)) = self.caveats.split_last() else { + return verify(&key, &self.identifier, &self.signature); + }; + let mut signature = sign(&key, &self.identifier); + for caveat in preceding { + signature = sign(&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 sign(key: &[u8], data: &[u8]) -> [u8; 32] { + hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, key), data).as_ref().try_into().unwrap() +} + +fn verify(key: &[u8], data: &[u8], signature: &[u8; 32]) -> bool { + hmac::verify(&hmac::Key::new(hmac::HMAC_SHA256, key), data, signature).is_ok() +} + +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 request_binding_encoding_is_canonical_and_bounded() { + let binding = RequestBinding { + timestamp: 123, + method: "GetNodeInfo".into(), + body_sha256: [0xab; 32], + }; + let expected = format!("request = 123 GetNodeInfo {}", "ab".repeat(32)); + assert_eq!(binding.caveat().unwrap(), expected); + assert_eq!(RequestBinding::parse(expected.as_bytes()).unwrap(), binding); + for invalid in [ + expected.replace("123", "0123"), + expected.replace("123", "+123"), + expected.replace("123", "18446744073709551616"), + expected.replace("123", "-1"), + expected.replace("123 ", "123 "), + expected.replace("GetNodeInfo", ""), + expected.replace("GetNodeInfo", "/api.LightningNode/GetNodeInfo"), + expected.replace("GetNodeInfo", &"X".repeat(MAX_REQUEST_METHOD_BYTES + 1)), + expected.replace("ab", "AB"), + expected.replace("ab", "zz"), + format!("{expected} extra"), + format!("{expected}\n"), + expected[..expected.len() - 1].into(), + expected.replace("request = ", "body = "), + ] { + assert!(RequestBinding::parse(invalid.as_bytes()).is_err(), "{invalid}"); + } + assert!(RequestBinding::parse(&[255]).is_err()); + let maximum = RequestBinding { + timestamp: u64::MAX, + method: "X".repeat(MAX_REQUEST_METHOD_BYTES), + body_sha256: [255; 32], + }; + assert_eq!(maximum.caveat().unwrap().len(), MAX_REQUEST_CAVEAT_BYTES); + assert_eq!(RequestBinding::parse(maximum.caveat().unwrap().as_bytes()).unwrap(), maximum); + } + + #[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 mutated_reference_tokens_never_panic_and_accepted_tokens_roundtrip() { + for line in + include_str!("../tests/data/macaroons-v2.txt").lines().filter(|l| !l.starts_with('#')) + { + let bytes = decode(line.split_whitespace().nth(2).unwrap()); + for index in 0..bytes.len() { + for value in 0..=255 { + let mut mutated = bytes.clone(); + mutated[index] = value; + if let Ok(parsed) = Macaroon::deserialize(&mutated) { + assert_eq!(parsed.serialize(), mutated); + } + } + } + } + } + + #[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()); + } + + #[test] + fn standard_signatures_match_reference_implementation() { + for (index, line) in include_str!("../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(); + assert!(m.verify_signature(&root)); + assert!(!m.verify_signature(b"incorrect root")); + let mut issued = Macaroon::mint(&root, &id).unwrap(); + for caveat in m.caveats() { + issued.attenuate(caveat).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)); + } + } + + #[test] + fn attenuation_limits_leave_token_unchanged() { + let mut token = Macaroon::mint(b"key", b"id").unwrap(); + for _ in 0..MAX_CAVEATS { + token.attenuate(b"permissions = admin").unwrap(); + } + let original = token.serialize(); + assert!(token.attenuate(b"permissions = admin").is_err()); + assert_eq!(token.serialize(), original); + let mut token = Macaroon::mint(b"key", b"id").unwrap(); + let original = token.serialize(); + assert!(token.attenuate(&vec![b'x'; MAX_MACAROON_BYTES]).is_err()); + assert_eq!(token.serialize(), original); + } +} diff --git a/ldk-server-macaroons/tests/data/macaroons-v2.txt b/ldk-server-macaroons/tests/data/macaroons-v2.txt new file mode 100644 index 00000000..83171f99 --- /dev/null +++ b/ldk-server-macaroons/tests/data/macaroons-v2.txt @@ -0,0 +1,9 @@ +# 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 +# Request proof for GetNodeInfo with the five-byte empty gRPC frame. +746573742d6b6579 746573742d6964 0201000207746573742d69640002177065726d697373696f6e73203d206e6f64653a7265616400026172657175657374203d2031383030303030303030204765744e6f6465496e666f2038383535353038616164653136656335373364323165366134383564666430613736323430383563316131346235656364643634383564653063363833396134000006206553a4b4f42f654b01b195823008752c41d0cff8247b77fbb77e4860debcc7e2 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..69388cef 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" } } @@ -97,6 +97,9 @@ All unary LDK Server RPCs are exposed as MCP tools. Use `tools/list` to discover Streaming RPCs such as `subscribe_events` and non-RPC HTTP endpoints such as `metrics` are not exposed as tools. +The `create_macaroon` tool returns a private token that may be saved in chat history or tool logs. +To keep it out of that history, create it with the CLI and supply it through `LDK_MACAROON`. + ## MCP Protocol - **Protocol version**: `2025-11-25` 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..60e67e37 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", + "Show the current macaroon's permissions and restrictions", + 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 IDs, names, permissions, and restrictions", + schema::list_macaroons_schema, + |client, args| Box::pin(handlers::handle_list_macaroons(client, args)), + ), + tool_spec( + "create_macaroon", + "Create a macaroon with chosen permissions and return its private 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..486a8eab 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": "Permissions 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/Cargo.toml b/ldk-server/Cargo.toml index 5590d97f..7f7c3b13 100644 --- a/ldk-server/Cargo.toml +++ b/ldk-server/Cargo.toml @@ -24,6 +24,7 @@ ring = { version = "0.17", default-features = false } getrandom = { version = "0.2", default-features = false } prost = { version = "0.11.6", default-features = false, features = ["std"] } ldk-server-grpc = { path = "../ldk-server-grpc" } +ldk-server-macaroons = { path = "../ldk-server-macaroons" } bytes = { version = "1.4.0", default-features = false } hex = { package = "hex-conservative", version = "0.2.1", default-features = false } rusqlite = { version = "0.31.0", features = ["bundled"] } diff --git a/ldk-server/src/api/error.rs b/ldk-server/src/api/error.rs index b28c22a8..a1216fd8 100644 --- a/ldk-server/src/api/error.rs +++ b/ldk-server/src/api/error.rs @@ -47,6 +47,9 @@ pub(crate) enum LdkServerErrorCode { /// Please refer to [`protos::error::ErrorCode::AuthError`]. AuthError, + /// The request was authenticated, but the key does not have the required permission. + AuthorizationError, + /// Please refer to [`protos::error::ErrorCode::LightningError`]. LightningError, @@ -59,6 +62,7 @@ impl fmt::Display for LdkServerErrorCode { match self { LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"), LdkServerErrorCode::AuthError => write!(f, "AuthError"), + LdkServerErrorCode::AuthorizationError => write!(f, "AuthorizationError"), LdkServerErrorCode::LightningError => write!(f, "LightningError"), LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"), } diff --git a/ldk-server/src/api/macaroons.rs b/ldk-server/src/api/macaroons.rs new file mode 100644 index 00000000..1f2dce89 --- /dev/null +++ b/ldk-server/src/api/macaroons.rs @@ -0,0 +1,68 @@ +// 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::sync::Arc; + +use ldk_server_grpc::api::{ + CreateMacaroonRequest, CreateMacaroonResponse, GetPermissionsRequest, GetPermissionsResponse, + ListMacaroonsRequest, ListMacaroonsResponse, MacaroonInfo as ProtoMacaroonInfo, + RevokeMacaroonRequest, RevokeMacaroonResponse, +}; + +use super::error::{LdkServerError, LdkServerErrorCode}; +use crate::macaroons::{MacaroonInfo, MacaroonStore}; + +pub(crate) async fn handle_create_macaroon_request( + store: Arc, issuer: Arc, request: CreateMacaroonRequest, +) -> Result { + let created = + run_blocking(move || store.create_root(&request.name, request.permissions, &issuer)) + .await?; + Ok(CreateMacaroonResponse { + macaroon: Some(macaroon_to_proto(created.info)), + token: created.token, + }) +} + +pub(crate) async fn handle_list_macaroons_request( + store: Arc, _request: ListMacaroonsRequest, +) -> Result { + let macaroons = store.list_roots()?.into_iter().map(macaroon_to_proto).collect(); + Ok(ListMacaroonsResponse { macaroons }) +} + +pub(crate) async fn handle_revoke_macaroon_request( + store: Arc, issuer: Arc, request: RevokeMacaroonRequest, +) -> Result { + run_blocking(move || store.revoke_root(&request.id, &issuer)).await?; + Ok(RevokeMacaroonResponse {}) +} + +pub(crate) async fn handle_get_permissions_request( + issuer: Arc, _request: GetPermissionsRequest, +) -> Result { + Ok(GetPermissionsResponse { macaroon: Some(macaroon_to_proto((*issuer).clone())) }) +} + +fn macaroon_to_proto(info: MacaroonInfo) -> ProtoMacaroonInfo { + ProtoMacaroonInfo { + id: info.id, + name: info.name, + permissions: info.permissions.into_iter().collect(), + caveats: info.caveats, + } +} + +async fn run_blocking( + f: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(f).await.map_err(|error| { + LdkServerError::new(LdkServerErrorCode::InternalServerError, error.to_string()) + })? +} diff --git a/ldk-server/src/api/mod.rs b/ldk-server/src/api/mod.rs index 65c8985f..e00c3860 100644 --- a/ldk-server/src/api/mod.rs +++ b/ldk-server/src/api/mod.rs @@ -46,6 +46,7 @@ pub(crate) mod list_channels; pub(crate) mod list_forwarded_payments; pub(crate) mod list_payments; pub(crate) mod list_peers; +pub(crate) mod macaroons; pub(crate) mod onchain_receive; pub(crate) mod onchain_send; pub(crate) mod open_channel; diff --git a/ldk-server/src/macaroons/authorization.rs b/ldk-server/src/macaroons/authorization.rs new file mode 100644 index 00000000..3ffe817e --- /dev/null +++ b/ldk-server/src/macaroons/authorization.rs @@ -0,0 +1,219 @@ +// 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. + +//! RPC permission requirements. + +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::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, +}; + +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, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use ldk_server_grpc::permissions::ALL_PERMISSIONS; + + use super::*; + use crate::macaroons::MacaroonInfo; + #[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 info = MacaroonInfo { + id: "test".to_string(), + name: "test".to_string(), + permissions: BTreeSet::new(), + caveats: Vec::new(), + }; + assert!( + !info.allows(required), + "Identity without permissions must not access {method}" + ); + for permission in ALL_PERMISSIONS { + info.permissions = BTreeSet::from([permission.to_string()]); + assert_eq!( + info.allows(required), + permission == "admin" || permission == required, + "Unexpected access to {method} with {permission}" + ); + } + } + } + + #[test] + fn permissionless_and_unknown_methods_have_explicit_classification() { + assert!(matches!( + method_authorization(GET_PERMISSIONS_PATH), + MethodAuthorization::AuthenticatedOnly + )); + assert!(matches!( + method_authorization("FutureUnclassifiedRpc"), + MethodAuthorization::Unknown + )); + } +} diff --git a/ldk-server/src/macaroons/mod.rs b/ldk-server/src/macaroons/mod.rs new file mode 100644 index 00000000..af7101de --- /dev/null +++ b/ldk-server/src/macaroons/mod.rs @@ -0,0 +1,49 @@ +// 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. + +//! Server-side macaroon policy, root storage, and RPC authorization. + +mod authorization; +mod persistence; +mod policy; +mod store; + +use std::io; + +pub(crate) use authorization::{method_authorization, MethodAuthorization}; +pub(crate) use policy::MacaroonInfo; +#[cfg(test)] +pub(crate) use store::test_util; +pub(crate) use store::MacaroonStore; + +use crate::api::error::{LdkServerError, LdkServerErrorCode}; + +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 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()) +} + +fn auth_error(message: impl Into) -> LdkServerError { + LdkServerError::new(LdkServerErrorCode::AuthError, message) +} diff --git a/ldk-server/src/macaroons/persistence.rs b/ldk-server/src/macaroons/persistence.rs new file mode 100644 index 00000000..5989ca88 --- /dev/null +++ b/ldk-server/src/macaroons/persistence.rs @@ -0,0 +1,130 @@ +// 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. + +//! Root-file representation and filesystem operations. + +use std::fs::{self, File}; +use std::io; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use hex::DisplayHex; +use ldk_node::bitcoin::hashes::{sha256, Hash}; +use serde::Deserialize; + +use super::policy::{validate_name_value, validate_permissions}; +use super::{invalid_data, MacaroonInfo}; +use crate::util::write_new; + +pub(super) const MACAROON_FILE_SIZE_LIMIT: usize = 16384; +pub(super) const MACAROONS_DIR: &str = "macaroons"; +pub(super) const ADMIN_ROOT_FILE: &str = "admin.toml"; +pub(super) const ADMIN_MACAROON_FILE: &str = "admin.macaroon"; + +#[derive(Debug)] +pub(super) struct RootRecord { + pub(super) info: Arc, + pub(super) secret: String, + pub(super) path: PathBuf, +} + +#[derive(Deserialize)] +pub(super) struct StoredRoot { + pub(super) id: String, + pub(super) name: String, + #[serde(rename = "key")] + pub(super) secret: String, + pub(super) permissions: Vec, + #[serde(default)] + pub(super) caveats: Vec, +} + +pub(super) fn compute_root_id(secret: &str) -> String { + let hash = sha256::Hash::hash(secret.as_bytes()); + hash[..16].to_lower_hex_string() +} + +pub(super) fn record_from_stored(stored: StoredRoot, path: PathBuf) -> io::Result { + if !is_hex(&stored.secret, 64) { + return Err(invalid_data(format!("Invalid macaroon in {}", path.display()))); + } + if !is_hex(&stored.id, 32) || stored.id != compute_root_id(&stored.secret) { + 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_macaroons::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(RootRecord { + info: Arc::new(MacaroonInfo { + id: stored.id, + name: stored.name, + permissions, + caveats: stored.caveats, + }), + secret: stored.secret, + path, + }) +} + +pub(super) 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()) +} + +pub(super) fn write_root_file(path: &Path, info: &MacaroonInfo, secret: &str) -> io::Result<()> { + // Rust Debug string escaping is valid TOML for printable ASCII, including quotes + // and backslashes. Reject controls and Unicode, whose Debug escapes differ from TOML. + if info.caveats.iter().any(|c| !c.is_ascii() || c.bytes().any(|b| b < 32 || b == 127)) { + return Err(invalid_data("Invalid stored macaroon caveats")); + } + 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()) +} + +pub(super) 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 +} + +pub(super) fn is_hex(value: &str, expected_length: usize) -> bool { + value.len() == expected_length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} diff --git a/ldk-server/src/macaroons/policy.rs b/ldk-server/src/macaroons/policy.rs new file mode 100644 index 00000000..e9fa7aba --- /dev/null +++ b/ldk-server/src/macaroons/policy.rs @@ -0,0 +1,182 @@ +// 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. + +//! Permission and caveat evaluation, independent of root storage. + +use std::collections::BTreeSet; + +use hex::FromHex; +use ldk_server_grpc::permissions::{ + ADMIN_PERMISSION, ALL_PERMISSIONS, MACAROONS_MANAGE_PERMISSION, +}; +use ldk_server_macaroons::{Macaroon, REQUEST_TIMESTAMP_TOLERANCE_SECS}; + +use super::{auth_error, authorization_error, internal_error, invalid_request}; +use crate::api::error::LdkServerError; + +#[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) + } +} + +pub(super) 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())?; + let permissions = info.permissions.iter().cloned().collect::>().join(","); + macaroon.attenuate(format!("permissions = {permissions}").as_bytes())?; + for caveat in &info.caveats { + macaroon.attenuate(caveat.as_bytes())?; + } + macaroon.check_request_capacity()?; + Ok(macaroon.to_hex()) +} + +pub(super) fn unix_time() -> Result { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|time| time.as_secs()) + .map_err(internal_error) +} + +pub(super) fn check_request_timestamp(timestamp: u64, now: u64) -> Result<(), LdkServerError> { + if now.abs_diff(timestamp) > REQUEST_TIMESTAMP_TOLERANCE_SECS { + return Err(auth_error("Macaroon request timestamp expired")); + } + Ok(()) +} + +pub(super) fn check_caveat( + caveat: &str, method: &str, permissions: &mut BTreeSet, +) -> Result<(), LdkServerError> { + check_caveat_at(caveat, method, permissions, unix_time()?) +} + +pub(super) fn check_caveat_at( + caveat: &str, method: &str, permissions: &mut BTreeSet, now: u64, +) -> 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")); + } + 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(()) +} + +pub(super) fn is_unrestricted_admin(info: &MacaroonInfo) -> bool { + info.is_admin() && info.caveats.iter().all(|c| c == "permissions = admin") +} + +pub(super) fn validate_name(name: &str) -> Result<(), LdkServerError> { + validate_name_value(name).map_err(invalid_request) +} + +pub(super) 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(()) +} + +pub(super) 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) +} + +pub(super) fn management_permissions( + issuer: &MacaroonInfo, method: &str, +) -> Result, LdkServerError> { + 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, method, &mut issuer_permissions)?; + } + if !issuer_permissions.contains(ADMIN_PERMISSION) + && !issuer_permissions.contains(MACAROONS_MANAGE_PERMISSION) + { + return Err(authorization_error("Macaroon management permission required")); + } + Ok(issuer_permissions) +} + +#[cfg(test)] +mod tests { + use ldk_server_grpc::permissions::NODE_READ_PERMISSION; + + use super::*; + #[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()); + } + + #[test] + fn request_timestamp_tolerance_has_exact_bounds() { + for timestamp in [940, 1000, 1060] { + assert!(check_request_timestamp(timestamp, 1000).is_ok()); + } + for timestamp in [0, 939, 1061, u64::MAX] { + assert!(check_request_timestamp(timestamp, 1000).is_err()); + } + } +} diff --git a/ldk-server/src/macaroons/store.rs b/ldk-server/src/macaroons/store.rs new file mode 100644 index 00000000..8111059c --- /dev/null +++ b/ldk-server/src/macaroons/store.rs @@ -0,0 +1,387 @@ +// 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. + +//! Root lifecycle and request authentication. + +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::FromHex; +use ldk_server_grpc::endpoints::{CREATE_MACAROON_PATH, REVOKE_MACAROON_PATH}; +use ldk_server_grpc::permissions::ADMIN_PERMISSION; +use ldk_server_macaroons::{Macaroon, RequestBinding, MAX_MACAROON_BYTES}; + +use super::persistence::{ + compute_root_id, generate_secret, is_hex, record_from_stored, write_private_file, + write_root_file, RootRecord, StoredRoot, ADMIN_MACAROON_FILE, ADMIN_ROOT_FILE, MACAROONS_DIR, + MACAROON_FILE_SIZE_LIMIT, +}; +use super::policy::{ + check_caveat, check_caveat_at, check_request_timestamp, is_unrestricted_admin, + management_permissions, mint_token, unix_time, validate_name, validate_permissions, +}; +use super::{ + auth_error, authorization_error, internal_error, invalid_data, invalid_request, + store_lock_error, MacaroonInfo, +}; +use crate::api::error::LdkServerError; +use crate::util::{create_dir_all_private, read_to_string_with_limit}; + +#[derive(Debug)] +pub(crate) struct CreatedMacaroon { + pub(crate) info: MacaroonInfo, + pub(crate) token: String, +} + +/// The signature and header conditions passed, but the body has not been checked yet. +/// This must be completed with `finish_request` before executing the RPC. +#[derive(Debug)] +pub(crate) struct PendingMacaroonRequest { + pub(crate) info: Arc, + binding: RequestBinding, +} + +pub(crate) struct MacaroonStore { + roots: 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 { roots: RwLock::new(HashMap::new()), management: Mutex::new(()), directory }; + store.load_root_files()?; + if store.roots_mut()?.is_empty() { + store.create_initial_admin()?; + } + let admin_path = macaroon_dir.join(ADMIN_MACAROON_FILE); + if !store.default_admin_token_is_valid(&admin_path)? { + store.write_default_admin_token(&admin_path)?; + } + Ok(store) + } + + fn roots_mut(&mut self) -> io::Result<&mut HashMap>> { + self.roots.get_mut().map_err(|_| io::Error::other("macaroon store lock is poisoned")) + } + + fn default_admin_token_is_valid(&self, path: &Path) -> io::Result { + if !path.try_exists()? { + return Ok(false); + } + let token = match read_to_string_with_limit(path, MAX_MACAROON_BYTES * 2 + 2) { + Ok(token) => token, + Err(error) if error.kind() == io::ErrorKind::InvalidData => return Ok(false), + Err(error) => { + return Err(io::Error::new( + error.kind(), + format!("Failed to read default macaroon {}: {error}", path.display()), + )) + }, + }; + // Check the signature only. Preserve valid restricted or expired credentials. + let Ok((macaroon, _)) = self.verify_token(token.trim()) else { + return Ok(false); + }; + if macaroon.has_request_proof() { + log::warn!( + "Default macaroon file {} contains a request proof; restore a reusable credential", + path.display() + ); + } + Ok(true) + } + + fn write_default_admin_token(&mut self, path: &Path) -> io::Result<()> { + let roots = self.roots_mut()?; + if let Some(admin) = roots + .values() + .find(|record| record.path.file_name().is_some_and(|name| name == ADMIN_ROOT_FILE)) + { + let token = mint_token(&admin.info, &admin.secret).map_err(invalid_data)?; + write_private_file(path, token.as_bytes())?; + log::info!("Wrote default macaroon: id={}", admin.info.id); + } else { + log::warn!( + "Default admin.macaroon is missing or invalid and roots/admin.toml is absent; \ + use an existing admin credential to create a replacement and save its token \ + as admin.macaroon" + ); + } + Ok(()) + } + + fn load_root_files(&mut self) -> io::Result<()> { + let entries = fs::read_dir(&self.directory)?; + let roots = self.roots_mut()?; + for entry in entries { + 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: StoredRoot = 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 roots.values().any(|existing| existing.info.name == record.info.name) { + return Err(invalid_data(format!("Duplicate macaroon name: {}", record.info.name))); + } + if roots.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_root_id(&secret), + name: "admin".to_string(), + permissions: BTreeSet::from([ADMIN_PERMISSION.to_string()]), + caveats: Vec::new(), + }; + let path = self.directory.join(ADMIN_ROOT_FILE); + write_root_file(&path, &info, &secret)?; + self.roots_mut()? + .insert(info.id.clone(), Arc::new(RootRecord { info: Arc::new(info), secret, path })); + + Ok(()) + } + + fn verify_token(&self, token: &str) -> Result<(Macaroon, Arc), LdkServerError> { + let invalid = || auth_error("Invalid macaroon credentials"); + let macaroon = Macaroon::from_hex(token).map_err(|_| invalid())?; + let id = std::str::from_utf8(macaroon.identifier()).map_err(|_| invalid())?; + let record = self + .roots + .read() + .map_err(|_| 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) { + return Err(invalid()); + } + Ok((macaroon, record)) + } + + pub(crate) fn authenticate_request( + &self, method: &str, auth_header: Option<&str>, + ) -> Result { + let token = auth_header.ok_or_else(|| auth_error("Missing macaroon credentials"))?; + let (macaroon, record) = self.verify_token(token)?; + let (request_caveat, restrictions) = macaroon + .caveats() + .split_last() + .ok_or_else(|| auth_error("Missing request binding caveat"))?; + let binding = RequestBinding::parse(request_caveat).map_err(auth_error)?; + if binding.method != method { + return Err(auth_error("Macaroon request method does not match")); + } + check_request_timestamp(binding.timestamp, unix_time()?)?; + // Only the final request proof is excluded from delegation. A request proof in + // any preceding position or a stored root is an unknown condition and is denied. + let info = Self::authenticate_caveats(&record, restrictions, method)?; + Ok(PendingMacaroonRequest { info, binding }) + } + + pub(crate) fn finish_request( + &self, request: PendingMacaroonRequest, method: &str, body: &[u8], + ) -> Result, LdkServerError> { + self.finish_request_at(request, method, body, unix_time()?) + } + + fn finish_request_at( + &self, request: PendingMacaroonRequest, method: &str, body: &[u8], now: u64, + ) -> Result, LdkServerError> { + check_request_timestamp(request.binding.timestamp, now)?; + if request.binding.method != method || !request.binding.matches_body(body) { + return Err(auth_error("Macaroon request body or method does not match")); + } + // Reading the body may take time. Recheck revocation and caveat expiry before + // admitting the request, including before opening an event subscription. + if !self.roots.read().map_err(|_| store_lock_error())?.contains_key(&request.info.id) { + return Err(auth_error("Invalid macaroon credentials")); + } + let mut permissions = request.info.permissions.clone(); + for caveat in &request.info.caveats { + check_caveat_at(caveat, method, &mut permissions, now)?; + } + Ok(request.info) + } + + fn authenticate_caveats( + record: &RootRecord, caveats: &[Vec], method: &str, + ) -> Result, LdkServerError> { + let mut info = (*record.info).clone(); + // Report and inherit every effective limit, including limits added to root files. + // All supported conditions are idempotent and their order does not affect access. + // Thus we can deduplicate repeats without dropping restrictions during delegation. + let mut seen = BTreeSet::new(); + info.caveats.retain(|caveat| seen.insert(caveat.clone())); + for caveat in caveats { + let caveat = String::from_utf8(caveat.clone()) + .map_err(|_| authorization_error("Invalid macaroon caveat"))?; + if seen.insert(caveat.clone()) { + info.caveats.push(caveat); + } + } + for caveat in &info.caveats { + 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_root( + &self, name: &str, permissions: Vec, issuer: &MacaroonInfo, + ) -> Result { + self.create_root_with_writer(name, permissions, issuer, write_root_file) + } + + fn create_root_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(|_| store_lock_error())?; + let issuer_permissions = management_permissions(issuer, CREATE_MACAROON_PATH)?; + let secret = generate_secret().map_err(internal_error)?; + let info = MacaroonInfo { + id: compute_root_id(&secret), + name: name.to_string(), + permissions, + caveats: issuer.caveats.clone(), + }; + let token = mint_token(&info, &secret).map_err(invalid_request)?; + { + let roots = self.roots.read().map_err(|_| store_lock_error())?; + if !roots.contains_key(&issuer.id) { + return Err(auth_error("Invalid credentials")); + } + if roots.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 root does not have", + )); + } + if roots.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(RootRecord { info: Arc::new(info.clone()), secret: secret.clone(), path }); + self.roots.write().map_err(|_| store_lock_error())?.insert(info.id.clone(), record); + log::info!( + "Created macaroon: issuer={} id={} name={} permissions={:?}", + issuer.id, + info.id, + info.name, + info.permissions + ); + Ok(CreatedMacaroon { info, token }) + } + + pub(crate) fn list_roots(&self) -> Result, LdkServerError> { + let records = self.roots.read().map_err(|_| store_lock_error())?; + let mut roots: Vec<_> = records.values().map(|record| (*record.info).clone()).collect(); + roots.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + Ok(roots) + } + + pub(crate) fn revoke_root( + &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 id = id.to_ascii_lowercase(); + let _management = self.management.lock().map_err(|_| store_lock_error())?; + let issuer_permissions = management_permissions(issuer, REVOKE_MACAROON_PATH)?; + let roots = self.roots.read().map_err(|_| store_lock_error())?; + if !roots.contains_key(&issuer.id) { + return Err(auth_error("Invalid credentials")); + } + let record = + roots.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 root with permissions that the calling root does not have", + )); + } + if is_unrestricted_admin(&record.info) + && roots.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(); + let info = Arc::clone(&record.info); + drop(roots); + match fs::remove_file(path) { + Ok(()) => {}, + // The file may have been deleted manually; still revoke the root from memory. + Err(error) if error.kind() == io::ErrorKind::NotFound => {}, + Err(error) => return Err(internal_error(error)), + } + self.roots.write().map_err(|_| store_lock_error())?.remove(&id); + File::open(&self.directory) + .and_then(|directory| directory.sync_all()) + .map_err(internal_error)?; + log::info!( + "Revoked macaroon: issuer={} id={} name={} permissions={:?}", + issuer.id, + info.id, + info.name, + info.permissions + ); + Ok(()) + } +} + +#[cfg(test)] +pub(crate) mod test_util; + +#[cfg(test)] +mod tests; diff --git a/ldk-server/src/macaroons/store/test_util.rs b/ldk-server/src/macaroons/store/test_util.rs new file mode 100644 index 00000000..b75449c5 --- /dev/null +++ b/ldk-server/src/macaroons/store/test_util.rs @@ -0,0 +1,91 @@ +// 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. + +//! Fixtures for server macaroon and HTTP admission tests. + +use std::ops::Deref; +use std::sync::atomic::{AtomicU32, Ordering}; + +use super::*; + +static TEST_COUNTER: AtomicU32 = AtomicU32::new(0); + +pub(crate) struct TestDir(PathBuf); + +impl TestDir { + pub(crate) fn new(name: &str) -> Self { + loop { + let count = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir() + .join(format!("ldk-macaroon-test-{name}-{}-{count}", std::process::id())); + match fs::create_dir(&path) { + Ok(()) => return Self(path), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("Cannot create test directory: {error}"), + } + } + } +} +impl AsRef for TestDir { + fn as_ref(&self) -> &Path { + &self.0 + } +} +impl Deref for TestDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.0 + } +} +impl Drop for TestDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +pub(crate) fn test_store(name: &str) -> (TestDir, MacaroonStore) { + let directory = TestDir::new(name); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + (directory, store) +} + +pub(crate) fn now() -> u64 { + unix_time().unwrap() +} + +pub(crate) 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()).unwrap(); + } + m.to_hex() +} + +pub(crate) fn admin_token(store: &MacaroonStore) -> String { + let roots = store.roots.read().unwrap(); + let record = roots.values().find(|r| r.info.name == "admin").unwrap(); + mint_token(&record.info, &record.secret).unwrap() +} + +impl MacaroonStore { + // Unit tests can inspect reusable credentials without creating an HTTP request. + // Production authorization always requires authenticate_request + finish_request. + pub(crate) fn authenticate( + &self, method: &str, auth_header: Option<&str>, + ) -> Result, LdkServerError> { + let token = auth_header.ok_or_else(|| auth_error("Missing macaroon credentials"))?; + let (macaroon, record) = self.verify_token(token)?; + Self::authenticate_caveats(&record, macaroon.caveats(), method) + } +} + +pub(crate) fn bind_request(token: &str, method: &str, body: &[u8], timestamp: u64) -> String { + ldk_server_macaroons::bind_macaroon_to_request_at(token, method, body, timestamp).unwrap() +} diff --git a/ldk-server/src/macaroons/store/tests.rs b/ldk-server/src/macaroons/store/tests.rs new file mode 100644 index 00000000..dd881066 --- /dev/null +++ b/ldk-server/src/macaroons/store/tests.rs @@ -0,0 +1,20 @@ +// 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 hex::DisplayHex; +use ldk_server_grpc::endpoints::*; +use ldk_server_grpc::permissions::*; + +use super::test_util::*; +use super::*; +use crate::api::error::LdkServerErrorCode; + +mod management; +mod persistence; +mod requests; diff --git a/ldk-server/src/macaroons/store/tests/management.rs b/ldk-server/src/macaroons/store/tests/management.rs new file mode 100644 index 00000000..a85d6022 --- /dev/null +++ b/ldk-server/src/macaroons/store/tests/management.rs @@ -0,0 +1,378 @@ +// 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 super::*; + +#[test] +fn creates_lists_revokes_and_reloads_root() { + let (directory, store) = test_store("lifecycle"); + let admin = store.list_roots().unwrap().remove(0); + let created = + store.create_root("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + + assert_eq!(store.list_roots().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_roots().unwrap().len(), 2); + reloaded.revoke_root(&created.info.id, &admin).unwrap(); + assert_eq!(reloaded.list_roots().unwrap(), vec![admin]); +} + +#[test] +fn revokes_root_when_its_file_is_missing() { + let (directory, store) = test_store("revoke-missing-file"); + let admin = store.list_roots().unwrap().remove(0); + let reader = + store.create_root("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_root(&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_roots().unwrap(), vec![admin.clone()]); + assert_eq!( + MacaroonStore::load_or_create(&directory).unwrap().list_roots().unwrap(), + vec![admin] + ); +} + +#[test] +fn revoke_rejects_malformed_ids_without_echoing_them() { + let (_directory, store) = test_store("revoke-invalid-id"); + let admin = store.list_roots().unwrap().remove(0); + for id in [String::new(), "a".repeat(31), "a".repeat(33), "z".repeat(32), "a".repeat(8192)] { + let error = store.revoke_root(&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_roots().unwrap(), vec![admin]); +} + +#[tokio::test] +async fn authentication_continues_during_root_file_write() { + use std::time::Duration; + let directory = TestDir::new("slow-root-write"); + let store = Arc::new(MacaroonStore::load_or_create(&directory).unwrap()); + let admin = store.list_roots().unwrap().remove(0); + let reader = + store.create_root("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_root_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_root_file(path, info, secret) + }, + ) + }); + started_rx.await.unwrap(); + let auth_store = Arc::clone(&store); + let auth = tokio::task::spawn_blocking(move || { + let info = auth_store.authenticate(GET_NODE_INFO_PATH, Some(&header)).unwrap(); + assert!(!auth_store.list_roots().unwrap().iter().any(|info| info.name == "pending")); + info + }); + 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_roots().unwrap().iter().any(|info| info.name == "pending")); +} + +#[test] +fn failed_root_write_does_not_publish_root() { + let (_directory, store) = test_store("failed-root-write"); + let admin = store.list_roots().unwrap().remove(0); + let error = store + .create_root_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_roots().unwrap(), vec![admin.clone()]); + store.create_root("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); +} + +#[test] +fn revoked_issuer_cannot_manage_roots_with_an_old_snapshot() { + let (_directory, store) = test_store("revoked-issuer"); + let admin = store.list_roots().unwrap().remove(0); + let manager = store + .create_root( + "manager", + vec![MACAROONS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + let reader = + store.create_root("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap(); + store.revoke_root(&manager.id, &admin).unwrap(); + assert_eq!( + store + .create_root("late", vec![NODE_READ_PERMISSION.to_string()], &manager) + .unwrap_err() + .error_code, + LdkServerErrorCode::AuthError + ); + assert_eq!( + store.revoke_root(&reader.info.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + assert!(store.list_roots().unwrap().contains(&reader.info)); +} + +#[test] +fn concurrent_creates_keep_root_names_unique() { + let (directory, store) = test_store("concurrent-create"); + let admin = store.list_roots().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_root("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_roots().unwrap(), reloaded.list_roots().unwrap()); + assert_eq!(reloaded.list_roots().unwrap().len(), 2); +} + +#[test] +fn scoped_manager_cannot_escalate_or_revoke_admin() { + let (_directory, store) = test_store("delegation"); + let admin = store.list_roots().unwrap().remove(0); + let manager = store + .create_root( + "manager", + vec![MACAROONS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + + let delegated = + store.create_root("delegated", vec![NODE_READ_PERMISSION.to_string()], &manager).unwrap(); + assert!(delegated.info.allows(NODE_READ_PERMISSION)); + assert_eq!( + store + .create_root("escalated", vec![ADMIN_PERMISSION.to_string()], &manager) + .unwrap_err() + .error_code, + LdkServerErrorCode::AuthorizationError + ); + assert_eq!( + store.revoke_root(&admin.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); +} + +#[test] +fn scoped_manager_revokes_only_roots_within_its_permissions() { + let (directory, store) = test_store("scoped-revocation"); + let admin = store.list_roots().unwrap().remove(0); + let manager = store + .create_root( + "manager", + vec![MACAROONS_MANAGE_PERMISSION.to_string(), NODE_READ_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + let reader = + store.create_root("reader", vec![NODE_READ_PERMISSION.to_string()], &admin).unwrap().info; + let peer = store + .create_root( + "peer", + vec![NODE_READ_PERMISSION.to_string(), PAYMENTS_SEND_PERMISSION.to_string()], + &admin, + ) + .unwrap() + .info; + + assert_eq!( + store.revoke_root(&peer.id, &manager).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); + store.revoke_root(&reader.id, &manager).unwrap(); + let roots = store.list_roots().unwrap(); + assert!(roots.contains(&peer)); + assert!(!roots.contains(&reader)); + assert_eq!(MacaroonStore::load_or_create(&directory).unwrap().list_roots().unwrap(), roots); +} + +#[test] +fn concurrent_revocations_preserve_the_final_admin() { + let (directory, store) = test_store("concurrent-revoke"); + let first = store.list_roots().unwrap().remove(0); + let second = + store.create_root("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_root(&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 roots = store.list_roots().unwrap(); + assert_eq!(roots.len(), 1); + assert!(roots[0].is_admin()); + assert_eq!(MacaroonStore::load_or_create(&directory).unwrap().list_roots().unwrap(), roots); +} + +#[test] +fn refuses_to_revoke_final_admin() { + let (_directory, store) = test_store("final-admin"); + let admin = store.list_roots().unwrap().remove(0); + + let error = store.revoke_root(&admin.id, &admin).unwrap_err(); + assert_eq!(error.error_code, LdkServerErrorCode::InvalidRequestError); + assert!(store.roots.read().unwrap().contains_key(&admin.id)); +} + +#[test] +fn issued_children_inherit_expiry_and_method_restrictions() { + let (directory, store) = test_store("inherited-caveats"); + 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_root("child", vec![ADMIN_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 child_issuer = store.authenticate(CREATE_MACAROON_PATH, Some(&created.token)).unwrap(); + let grandchild = + store.create_root("grandchild", vec![NODE_READ_PERMISSION.into()], &child_issuer).unwrap(); + assert!(grandchild.info.caveats.contains(&expiry)); + assert!(grandchild.info.caveats.contains(&"method = CreateMacaroon".to_string())); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&grandchild.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_root("expired", vec![NODE_READ_PERMISSION.into()], &stale).is_err()); +} + +#[test] +fn restricted_admin_does_not_replace_the_last_unrestricted_admin() { + let (_directory, store) = test_store("restricted-admin"); + let token = restrict(&admin_token(&store), &["method = CreateMacaroon"]); + let issuer = store.authenticate(CREATE_MACAROON_PATH, Some(&token)).unwrap(); + store.create_root("restricted-admin", vec![ADMIN_PERMISSION.into()], &issuer).unwrap(); + let admin = store.list_roots().unwrap().into_iter().find(|i| i.name == "admin").unwrap(); + assert!(store.revoke_root(&admin.id, &admin).is_err()); +} + +#[test] +fn stored_caveats_are_reported_and_inherited_through_grandchildren() { + let (directory, store) = test_store("stored-inheritance"); + let token = admin_token(&store); + let expiry = format!("time-before = {}", now() + 3600); + { + let roots = store.roots.read().unwrap(); + let record = roots.values().next().unwrap(); + let mut info = (*record.info).clone(); + info.caveats = vec![expiry.clone(), expiry.clone()]; + write_root_file(&record.path, &info, &record.secret).unwrap(); + } + drop(store); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let issuer = store.authenticate(GET_PERMISSIONS_PATH, Some(&token)).unwrap(); + assert_eq!(issuer.caveats, vec![expiry.clone(), "permissions = admin".into()]); + let child = store.create_root("child", vec![ADMIN_PERMISSION.into()], &issuer).unwrap(); + let child_issuer = store.authenticate(CREATE_MACAROON_PATH, Some(&child.token)).unwrap(); + assert_eq!(child_issuer.caveats, issuer.caveats); + let grandchild = + store.create_root("grandchild", vec![NODE_READ_PERMISSION.into()], &child_issuer).unwrap(); + let grandchild_info = + store.authenticate(GET_PERMISSIONS_PATH, Some(&grandchild.token)).unwrap(); + assert_eq!(grandchild_info.caveats.iter().filter(|c| **c == expiry).count(), 1); + assert_eq!(grandchild_info.permissions, BTreeSet::from([NODE_READ_PERMISSION.into()])); + assert!(grandchild.info.caveats.contains(&expiry)); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!( + reloaded.authenticate(GET_PERMISSIONS_PATH, Some(&grandchild.token)).unwrap(), + grandchild_info + ); +} + +#[test] +fn uppercase_credentials_revoke_and_names_can_be_reused() { + let (directory, store) = test_store("uppercase"); + let token = admin_token(&store).to_ascii_uppercase(); + let admin = store.authenticate(CREATE_MACAROON_PATH, Some(&token)).unwrap(); + let child = store.create_root("reusable", vec![NODE_READ_PERMISSION.into()], &admin).unwrap(); + assert!(store + .authenticate(GET_NODE_INFO_PATH, Some(&child.token.to_ascii_uppercase())) + .is_ok()); + store.revoke_root(&child.info.id.to_ascii_uppercase(), &admin).unwrap(); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&child.token)).is_err()); + let replacement = + store.create_root("reusable", vec![NODE_READ_PERMISSION.into()], &admin).unwrap(); + assert_ne!(replacement.info.id, child.info.id); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert!(reloaded.authenticate(GET_NODE_INFO_PATH, Some(&child.token)).is_err()); + assert!(reloaded.authenticate(GET_NODE_INFO_PATH, Some(&replacement.token)).is_ok()); +} + +#[test] +fn issued_tokens_reserve_capacity_for_request_proofs() { + let (_directory, store) = test_store("request-proof-capacity"); + let mut admin = store.list_roots().unwrap().remove(0); + admin.caveats = vec!["permissions = admin".into(); ldk_server_macaroons::MAX_CAVEATS - 2]; + let child = store.create_root("fits", vec![ADMIN_PERMISSION.into()], &admin).unwrap(); + let bound = bind_request(&child.token, GET_NODE_INFO_PATH, b"", now()); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&bound)).is_ok()); + admin.caveats.push("permissions = admin".into()); + assert!(store.create_root("too-many", vec![ADMIN_PERMISSION.into()], &admin).is_err()); + assert!(!store.list_roots().unwrap().iter().any(|info| info.name == "too-many")); +} diff --git a/ldk-server/src/macaroons/store/tests/persistence.rs b/ldk-server/src/macaroons/store/tests/persistence.rs new file mode 100644 index 00000000..069071f1 --- /dev/null +++ b/ldk-server/src/macaroons/store/tests/persistence.rs @@ -0,0 +1,180 @@ +// 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 super::*; + +#[test] +fn creates_initial_admin_root() { + let (directory, store) = test_store("initial-admin"); + let roots = store.list_roots().unwrap(); + + assert_eq!(roots.len(), 1); + assert_eq!(roots[0].name, "admin"); + assert!(roots[0].is_admin()); + let admin_path = directory.join(MACAROONS_DIR).join("roots").join(ADMIN_ROOT_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 + ); +} + +#[test] +fn load_macaroon_rejects_oversized_toml() { + let (directory, store) = test_store("oversized-root-toml"); + 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")); +} + +#[test] +fn load_rejects_duplicate_root_names_and_ids() { + for duplicate in ["name", "ID"] { + let (directory, store) = test_store("duplicate-root"); + let (mut info, mut secret) = { + let roots = store.roots.read().unwrap(); + let admin = roots.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_root_id(&secret); + } else { + // Same secret and ID, but a different valid name. + info.name = "another-admin".to_string(); + } + write_root_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}"))); + } +} + +#[test] +fn bootstrap_token_is_private_and_recovers_with_the_same_root() { + let (directory, store) = test_store("bootstrap-token"); + 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_roots().unwrap(); + fs::remove_file(&path).unwrap(); + drop(store); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(reloaded.list_roots().unwrap(), roots); + assert_eq!(fs::read_to_string(path).unwrap(), original); +} + +#[test] +fn bootstrap_recovers_after_roots_reset_and_invalid_token() { + let (directory, store) = test_store("bootstrap-reset"); + let path = directory.join(MACAROONS_DIR).join(ADMIN_MACAROON_FILE); + let original = fs::read_to_string(&path).unwrap(); + drop(store); + fs::remove_dir_all(directory.join(MACAROONS_DIR).join("roots")).unwrap(); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + let replacement = fs::read_to_string(&path).unwrap(); + assert_ne!(replacement, original); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&original)).is_err()); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&replacement)).unwrap().is_admin()); + drop(store); + let mut forged = Vec::::from_hex(&replacement).unwrap(); + *forged.last_mut().unwrap() ^= 1; + for invalid in [ + original, + forged.to_lower_hex_string(), + "malformed".into(), + "a".repeat(MAX_MACAROON_BYTES * 2 + 3), + ] { + write_private_file(&path, invalid.as_bytes()).unwrap(); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), replacement); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&replacement)).is_ok()); + } +} + +#[test] +fn bootstrap_read_errors_identify_the_default_token_path() { + let directory = TestDir::new("bootstrap-read-error"); + MacaroonStore::load_or_create(&directory).unwrap(); + let path = directory.join(MACAROONS_DIR).join(ADMIN_MACAROON_FILE); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + let expected_kind = fs::read_to_string(&path).unwrap_err().kind(); + let error = MacaroonStore::load_or_create(&directory).err().unwrap(); + assert_eq!(error.kind(), expected_kind); + assert!(error.to_string().contains(path.to_str().unwrap())); +} + +#[test] +fn bootstrap_preserves_valid_restrictions_and_does_not_recreate_revoked_root() { + let (directory, store) = test_store("bootstrap-preserve"); + let path = directory.join(MACAROONS_DIR).join(ADMIN_MACAROON_FILE); + let original = fs::read_to_string(&path).unwrap(); + for caveat in ["permissions = node:read", "time-before = 0", "method = CreateMacaroon"] { + let restricted = restrict(&original, &[caveat]); + write_private_file(&path, restricted.as_bytes()).unwrap(); + MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), restricted); + } + let bound = bind_request(&original, GET_NODE_INFO_PATH, b"", now()); + write_private_file(&path, bound.as_bytes()).unwrap(); + MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), bound); + let admin = store.authenticate(CREATE_MACAROON_PATH, Some(&original)).unwrap(); + let replacement = + store.create_root("replacement", vec![ADMIN_PERMISSION.into()], &admin).unwrap(); + store.revoke_root(&admin.id, &admin).unwrap(); + drop(store); + let store = MacaroonStore::load_or_create(&directory).unwrap(); + assert_eq!(store.list_roots().unwrap(), vec![replacement.info.clone()]); + assert!(store.authenticate(GET_NODE_INFO_PATH, Some(&replacement.token)).is_ok()); + assert!(!directory.join(MACAROONS_DIR).join("roots/admin.toml").exists()); + fs::remove_file(&path).unwrap(); + MacaroonStore::load_or_create(&directory).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn leftover_temporary_root_files_are_ignored() { + let (directory, store) = test_store("temporary-roots"); + let original = store.list_roots().unwrap(); + for name in ["partial.tmp", ".admin.toml.123.tmp"] { + fs::write(store.directory.join(name), "incomplete TOML").unwrap(); + } + assert_eq!(MacaroonStore::load_or_create(&directory).unwrap().list_roots().unwrap(), original); +} + +#[test] +fn stored_caveat_serialization_roundtrips_printable_ascii() { + let (_directory, store) = test_store("caveat-escaping"); + let roots = store.roots.read().unwrap(); + let record = roots.values().next().unwrap(); + let mut info = (*record.info).clone(); + info.caveats = vec![(32u8..127).map(char::from).collect()]; + write_root_file(&record.path, &info, &record.secret).unwrap(); + let stored: StoredRoot = toml::from_str(&fs::read_to_string(&record.path).unwrap()).unwrap(); + assert_eq!(stored.caveats, info.caveats); + for invalid in ["control\0", "newline\n", "unicode é"] { + info.caveats = vec![invalid.into()]; + assert!(write_root_file(&record.path, &info, &record.secret).is_err()); + } +} diff --git a/ldk-server/src/macaroons/store/tests/requests.rs b/ldk-server/src/macaroons/store/tests/requests.rs new file mode 100644 index 00000000..63c225f2 --- /dev/null +++ b/ldk-server/src/macaroons/store/tests/requests.rs @@ -0,0 +1,248 @@ +// 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 super::*; +use crate::macaroons::{method_authorization, MethodAuthorization}; + +#[test] +fn splicing_requires_its_own_permission() { + let (directory, store) = test_store("splice-permission"); + let admin = store.list_roots().unwrap().remove(0); + let manager = store + .create_root("manager", vec![CHANNELS_MANAGE_PERMISSION.to_string()], &admin) + .unwrap() + .info; + let splicer = store + .create_root("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_root("delegated-splicer", vec![CHANNELS_SPLICE_PERMISSION.to_string()], &manager,) + .is_err()); + let reloaded = MacaroonStore::load_or_create(&directory).unwrap(); + assert!(reloaded.list_roots().unwrap().contains(&splicer)); +} + +#[test] +fn attenuation_intersects_permissions_and_enforces_all_conditions() { + let (_directory, store) = test_store("attenuation"); + 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_root("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()); +} + +#[test] +fn rejects_forgery_and_revokes_all_attenuated_copies() { + let (directory, store) = test_store("revocation"); + let admin = store.list_roots().unwrap().remove(0); + let reader = store.create_root("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("not-a-macaroon"), Some("deadbeef")] { + assert_eq!( + store.authenticate(GET_NODE_INFO_PATH, header).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + } + let root = { store.roots.read().unwrap().get(&reader.info.id).unwrap().secret.clone() }; + assert_ne!(reader.token, root); + store.revoke_root(&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()); +} + +#[test] +fn request_proofs_bind_method_body_identifier_and_timestamp() { + let (_directory, store) = test_store("request-proof"); + let credential = admin_token(&store); + let body = b"\x00\x00\x00\x00\x03abc"; + let timestamp = now(); + let header = bind_request(&credential, GET_NODE_INFO_PATH, body, timestamp); + for _ in 0..2 { + // Timestamp freshness deliberately permits identical replays within its window. + let pending = store.authenticate_request(GET_NODE_INFO_PATH, Some(&header)).unwrap(); + assert!(store.finish_request(pending, GET_NODE_INFO_PATH, body).unwrap().is_admin()); + } + assert!(store.authenticate_request(GET_BALANCES_PATH, Some(&header)).is_err()); + for changed in [b"\x00\x00\x00\x00\x03abd".as_slice(), b"abc", b""] { + let pending = store.authenticate_request(GET_NODE_INFO_PATH, Some(&header)).unwrap(); + assert_eq!( + store.finish_request(pending, GET_NODE_INFO_PATH, changed).unwrap_err().error_code, + LdkServerErrorCode::AuthError + ); + } + for stale in [timestamp - 61, timestamp + 600] { + let header = bind_request(&credential, GET_NODE_INFO_PATH, body, stale); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&header)).is_err()); + } + let bytes = Vec::::from_hex(&header).unwrap(); + let parsed = Macaroon::deserialize(&bytes).unwrap(); + let identifier_offset = + bytes.windows(parsed.identifier().len()).position(|w| w == parsed.identifier()).unwrap(); + let timestamp_bytes = timestamp.to_string(); + let timestamp_offset = + bytes.windows(timestamp_bytes.len()).position(|w| w == timestamp_bytes.as_bytes()).unwrap(); + for offset in [identifier_offset, timestamp_offset, bytes.len() - 1] { + let mut forged = bytes.clone(); + forged[offset] ^= 1; + assert!(store + .authenticate_request(GET_NODE_INFO_PATH, Some(&forged.to_lower_hex_string())) + .is_err()); + } +} + +#[test] +fn request_proof_is_required_once_and_cannot_be_extended_or_rebound() { + let (_directory, store) = test_store("request-proof-required"); + let credential = admin_token(&store); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&credential)).is_err()); + let bound = bind_request(&credential, GET_NODE_INFO_PATH, b"body", now()); + let rebound = append_request_proof(&bound, GET_BALANCES_PATH, b"different", now()); + assert!(store.authenticate_request(GET_BALANCES_PATH, Some(&rebound)).is_err()); + let duplicate = append_request_proof(&bound, GET_NODE_INFO_PATH, b"body", now()); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&duplicate)).is_err()); + let extended = restrict(&bound, &["permissions = node:read"]); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&extended)).is_err()); + for malformed in [ + "request = ", + "request = 00 GetNodeInfo deadbeef", + "request = 18446744073709551616 GetNodeInfo deadbeef", + ] { + let header = restrict(&credential, &[malformed]); + assert!(store.authenticate_request(GET_NODE_INFO_PATH, Some(&header)).is_err()); + } + // A body hash and fresh timestamp do not remove the holder's policy restrictions. + let restricted = restrict(&credential, &["method = GetNodeInfo"]); + let bound = bind_request(&restricted, GET_BALANCES_PATH, b"", now()); + assert_eq!( + store.authenticate_request(GET_BALANCES_PATH, Some(&bound)).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); +} + +#[test] +fn request_proofs_do_not_become_child_restrictions() { + let (_directory, store) = test_store("request-proof-delegation"); + let expiry = format!("time-before = {}", now() + 3600); + let credential = restrict(&admin_token(&store), &[&expiry]); + let bound = bind_request(&credential, CREATE_MACAROON_PATH, b"create-body", now()); + let pending = store.authenticate_request(CREATE_MACAROON_PATH, Some(&bound)).unwrap(); + let issuer = store.finish_request(pending, CREATE_MACAROON_PATH, b"create-body").unwrap(); + assert!(issuer.caveats.contains(&expiry)); + assert!(!issuer.caveats.iter().any(|c| c.starts_with("request = "))); + let child = store.create_root("child", vec![ADMIN_PERMISSION.into()], &issuer).unwrap(); + let bound = bind_request(&child.token, CREATE_MACAROON_PATH, b"grandchild-body", now()); + let pending = store.authenticate_request(CREATE_MACAROON_PATH, Some(&bound)).unwrap(); + let child_issuer = + store.finish_request(pending, CREATE_MACAROON_PATH, b"grandchild-body").unwrap(); + let grandchild = + store.create_root("grandchild", vec![NODE_READ_PERMISSION.into()], &child_issuer).unwrap(); + assert!(grandchild.info.caveats.contains(&expiry)); + let bound = bind_request(&grandchild.token, GET_PERMISSIONS_PATH, b"", now()); + let pending = store.authenticate_request(GET_PERMISSIONS_PATH, Some(&bound)).unwrap(); + let info = store.finish_request(pending, GET_PERMISSIONS_PATH, b"").unwrap(); + assert!(!info.caveats.iter().any(|c| c.starts_with("request = "))); + assert!(!info.caveats.iter().any(|c| c == "method = CreateMacaroon")); +} + +#[test] +fn revocation_and_freshness_are_rechecked_after_reading_the_body() { + let (_directory, store) = test_store("request-proof-finish"); + let admin = store.authenticate(CREATE_MACAROON_PATH, Some(&admin_token(&store))).unwrap(); + let child = store.create_root("child", vec![NODE_READ_PERMISSION.into()], &admin).unwrap(); + let bound = bind_request(&child.token, GET_NODE_INFO_PATH, b"", now()); + let pending = store.authenticate_request(GET_NODE_INFO_PATH, Some(&bound)).unwrap(); + store.revoke_root(&child.info.id, &admin).unwrap(); + assert!(store.finish_request(pending, GET_NODE_INFO_PATH, b"").is_err()); + let bound = bind_request(&admin_token(&store), GET_NODE_INFO_PATH, b"", now()); + let pending = store.authenticate_request(GET_NODE_INFO_PATH, Some(&bound)).unwrap(); + let later = pending.binding.timestamp + 61; + assert!(store.finish_request_at(pending, GET_NODE_INFO_PATH, b"", later).is_err()); + let timestamp = now(); + let expiry = timestamp + 10; + let credential = restrict(&admin_token(&store), &[&format!("time-before = {expiry}")]); + let bound = bind_request(&credential, GET_NODE_INFO_PATH, b"", timestamp); + let pending = store.authenticate_request(GET_NODE_INFO_PATH, Some(&bound)).unwrap(); + assert_eq!( + store.finish_request_at(pending, GET_NODE_INFO_PATH, b"", expiry).unwrap_err().error_code, + LdkServerErrorCode::AuthorizationError + ); +} + +// Deliberately bypass reusable-token checks to test rejection of multiple proofs. +fn append_request_proof(token: &str, method: &str, body: &[u8], timestamp: u64) -> String { + let proof = RequestBinding::new(method, body, timestamp); + restrict(token, &[&proof.caveat().unwrap()]) +} 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..17e7f95f 100644 --- a/ldk-server/src/service.rs +++ b/ldk-server/src/service.rs @@ -15,8 +15,6 @@ 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::endpoints::{ BOLT11_CLAIM_FOR_ID_PATH, BOLT11_FAIL_FOR_ID_PATH, BOLT11_RECEIVE_FOR_HASH_PATH, @@ -24,21 +22,22 @@ 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, 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}; @@ -76,6 +75,10 @@ use crate::api::list_channels::handle_list_channels_request; use crate::api::list_forwarded_payments::handle_list_forwarded_payments_request; use crate::api::list_payments::handle_list_payments_request; use crate::api::list_peers::handle_list_peers_request; +use crate::api::macaroons::{ + handle_create_macaroon_request, handle_get_permissions_request, handle_list_macaroons_request, + handle_revoke_macaroon_request, +}; use crate::api::onchain_receive::handle_onchain_receive_request; use crate::api::onchain_send::handle_onchain_send_request; use crate::api::open_channel::handle_open_channel; @@ -86,6 +89,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 +101,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,65 +110,14 @@ pub(crate) struct NodeService { impl NodeService { pub(crate) fn new( - node: Arc, paginated_kv_store: Arc, api_key: String, - metrics: Option>, metrics_auth_header: Option, - event_sender: broadcast::Sender, + node: Arc, paginated_kv_store: Arc, + 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 } - } -} - -// Maximum allowed time difference between client timestamp and server time (1 minute) -const AUTH_TIMESTAMP_TOLERANCE_SECS: u64 = 60; - -fn compute_auth_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> Hmac { - let mut hmac_engine: HmacEngine = HmacEngine::new(api_key.as_bytes()); - hmac_engine.input(×tamp.to_be_bytes()); - hmac_engine.input(body); - Hmac::::from_engine(hmac_engine) -} - -/// Validates HMAC authentication from request headers. -/// The signature covers the timestamp and raw gRPC request body bytes. -fn validate_auth(req: &Request, api_key: &str, body: &[u8]) -> Result<(), LdkServerError> { - let auth_err = |msg: &str| LdkServerError::new(LdkServerErrorCode::AuthError, msg.to_string()); - - let auth_header = req - .headers() - .get("x-auth") - .and_then(|v| v.to_str().ok()) - .ok_or_else(|| auth_err("Missing x-auth metadata"))?; - - let auth_data = - auth_header.strip_prefix("HMAC ").ok_or_else(|| auth_err("Invalid x-auth format"))?; - - let (timestamp_str, provided_hmac_hex) = - auth_data.split_once(':').ok_or_else(|| auth_err("Invalid x-auth format"))?; - - let timestamp = timestamp_str.parse::().map_err(|_| auth_err("Invalid timestamp"))?; - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_err(|_| auth_err("System time error"))? - .as_secs(); - - if now.abs_diff(timestamp) > AUTH_TIMESTAMP_TOLERANCE_SECS { - return Err(auth_err("Request timestamp expired")); - } - - let expected_hmac = compute_auth_hmac(api_key, timestamp, body); - - let provided_hmac = provided_hmac_hex - .parse::>() - .map_err(|_| auth_err("Invalid HMAC in x-auth"))?; - - if expected_hmac != provided_hmac { - return Err(auth_err("Invalid credentials")); + Self { context, macaroon_store, metrics, metrics_auth_header, event_sender, shutdown_rx } } - - Ok(()) } pub(crate) struct Context { @@ -257,26 +210,23 @@ 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(); let future: Self::Future = Box::pin(async move { - let content_length = match request_content_length(&request_parts.headers) { - Ok(content_length) => content_length, - Err(status) => return Ok(grpc_error_response(status)), - }; - let body_bytes = match read_request_body(request_body, content_length).await { - Ok(bytes) => bytes, + let (issuer, body_bytes) = match read_authorized_request( + &macaroon_store, + &method, + &request_parts.headers, + request_body, + ) + .await + { + Ok(request) => request, 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)); - } - match method.as_str() { GET_NODE_INFO_PATH => { handle_grpc_unary(context, body_bytes, handle_get_node_info_request).await @@ -419,6 +369,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 +413,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, issuer, 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, issuer, request) + }) + .await + }, + GET_PERMISSIONS_PATH => { + handle_grpc_unary(context, body_bytes, move |_context, request| { + handle_get_permissions_request(issuer, request) + }) + .await + }, _ => { let status = GrpcStatus::new( GRPC_STATUS_UNIMPLEMENTED, @@ -491,7 +469,7 @@ 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> { @@ -552,9 +530,32 @@ fn validate_request_body_len( Ok(()) } -async fn read_request_body( - body: Incoming, content_length: Option, -) -> Result { +async fn read_authorized_request( + store: &MacaroonStore, method: &str, headers: &HeaderMap, body: B, +) -> Result<(Arc, bytes::Bytes), GrpcStatus> +where + B: hyper::body::Body, + B::Error: Into>, +{ + let auth_header = headers.get("macaroon").and_then(|value| value.to_str().ok()); + let request = + store.authenticate_request(method, auth_header).map_err(ldk_error_to_grpc_status)?; + match method_authorization(method) { + MethodAuthorization::Permission(permission) if !request.info.allows(permission) => { + return Err(GrpcStatus::new( + GRPC_STATUS_PERMISSION_DENIED, + format!("macaroon requires permission: {permission}"), + )); + }, + MethodAuthorization::Unknown => { + return Err(GrpcStatus::new( + GRPC_STATUS_UNIMPLEMENTED, + format!("Unknown method: {method}"), + )); + }, + _ => {}, + } + let content_length = request_content_length(headers)?; let limited_body = Limited::new(body, MAX_BODY_SIZE); let bytes = match limited_body.collect().await { Ok(collected) => collected.to_bytes(), @@ -566,7 +567,8 @@ async fn read_request_body( }, }; validate_request_body_len(content_length, bytes.len())?; - Ok(bytes) + let info = store.finish_request(request, method, &bytes).map_err(ldk_error_to_grpc_status)?; + Ok((info, bytes)) } /// Map an `LdkServerError` to a `GrpcStatus`. @@ -574,6 +576,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, }; @@ -583,84 +586,214 @@ pub(crate) fn ldk_error_to_grpc_status(e: LdkServerError) -> GrpcStatus { #[cfg(test)] mod tests { use super::*; - - fn compute_hmac(api_key: &str, timestamp: u64, body: &[u8]) -> String { - compute_auth_hmac(api_key, timestamp, body).to_string() + use crate::macaroons::test_util::{admin_token, bind_request, test_store}; + + struct UnreadBody; + impl hyper::body::Body for UnreadBody { + type Data = bytes::Bytes; + type Error = std::convert::Infallible; + fn poll_frame( + self: std::pin::Pin<&mut Self>, _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll, Self::Error>>> { + panic!("Rejected request body must not be read"); + } } - 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); + #[tokio::test] + async fn macaroon_request_clock_skew() { + use ldk_server_grpc::grpc::GRPC_STATUS_OK; + + let (_directory, store) = test_store("http-clock-skew"); + let token = admin_token(&store); + let bytes = [0; 5]; // Empty protobuf message with its gRPC frame header. + + // Exact 60-second boundaries are tested with a fixed clock in policy tests. + for (offset, expected) in [ + (0i64, GRPC_STATUS_OK), + (-30, GRPC_STATUS_OK), + (30, GRPC_STATUS_OK), + (-120, GRPC_STATUS_UNAUTHENTICATED), + (120, GRPC_STATUS_UNAUTHENTICATED), + ] { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let bound = bind_request( + &token, + GET_NODE_INFO_PATH, + &bytes, + now.checked_add_signed(offset).unwrap(), + ); + let mut headers = HeaderMap::new(); + headers.insert("macaroon", bound.parse().unwrap()); + let body = http_body_util::Full::new(bytes::Bytes::copy_from_slice(&bytes)); + let status = + match read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, body).await { + Ok((_, received)) => { + assert_eq!(received.as_ref(), &bytes); + GRPC_STATUS_OK + }, + Err(error) => error.code, + }; + assert_eq!(status, expected, "timestamp offset: {offset}"); } - builder.body(()).unwrap() } - #[test] - fn test_validate_auth_success() { - let api_key = "test_api_key"; - let body = b"test body"; + #[tokio::test] + async fn rejected_requests_do_not_poll_the_body() { + let (_directory, store) = test_store("http-admission"); + let token = admin_token(&store); + let admin = store.authenticate(CREATE_MACAROON_PATH, Some(&token)).unwrap(); + let reader = store.create_root("reader", vec!["node:read".into()], &admin).unwrap(); 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); + let denied = bind_request(&reader.token, ONCHAIN_SEND_PATH, b"", timestamp); + let unknown = bind_request(&token, "UnmappedMethod", b"", timestamp); + let stale = bind_request(&token, GET_NODE_INFO_PATH, b"", timestamp - 61); + let wrong_method = bind_request(&token, GET_BALANCES_PATH, b"", timestamp); + for (credential, method, expected) in [ + (None, GET_NODE_INFO_PATH, GRPC_STATUS_UNAUTHENTICATED), + (None, GET_PERMISSIONS_PATH, GRPC_STATUS_UNAUTHENTICATED), + (Some("invalid"), GET_NODE_INFO_PATH, GRPC_STATUS_UNAUTHENTICATED), + (Some(reader.token.as_str()), GET_NODE_INFO_PATH, GRPC_STATUS_UNAUTHENTICATED), + (Some(denied.as_str()), ONCHAIN_SEND_PATH, GRPC_STATUS_PERMISSION_DENIED), + (Some(unknown.as_str()), "UnmappedMethod", GRPC_STATUS_UNIMPLEMENTED), + (Some(stale.as_str()), GET_NODE_INFO_PATH, GRPC_STATUS_UNAUTHENTICATED), + (Some(wrong_method.as_str()), GET_NODE_INFO_PATH, GRPC_STATUS_UNAUTHENTICATED), + ] { + let mut headers = HeaderMap::new(); + if let Some(token) = credential { + headers.insert("macaroon", token.parse().unwrap()); + } + let error = + read_authorized_request(&store, method, &headers, UnreadBody).await.unwrap_err(); + assert_eq!(error.code, expected); + } + let mut headers = HeaderMap::new(); + let bound = bind_request(&reader.token, GET_NODE_INFO_PATH, b"request", timestamp); + headers.insert("macaroon", bound.parse().unwrap()); + let body = http_body_util::Full::new(bytes::Bytes::from_static(b"request")); + let (_, bytes) = + read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, body).await.unwrap(); + assert_eq!(bytes.as_ref(), b"request"); + let changed_body = http_body_util::Full::new(bytes::Bytes::from_static(b"changed")); + assert_eq!( + read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, changed_body) + .await + .unwrap_err() + .code, + GRPC_STATUS_UNAUTHENTICATED + ); + // Authorized requests still have both declared and actual body-size limits. + headers.insert("content-length", (MAX_BODY_SIZE + 1).to_string().parse().unwrap()); + assert_eq!( + read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, UnreadBody) + .await + .unwrap_err() + .code, + GRPC_STATUS_INVALID_ARGUMENT + ); + headers.remove("content-length"); + let oversized = http_body_util::Full::new(bytes::Bytes::from(vec![0; MAX_BODY_SIZE + 1])); + assert_eq!( + read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, oversized) + .await + .unwrap_err() + .code, + GRPC_STATUS_INVALID_ARGUMENT + ); } - #[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); - } + #[tokio::test] + async fn malformed_macaroon_headers_are_rejected_before_reading_the_body() { + use hyper::header::HeaderValue; + use ldk_server_macaroons::MAX_MACAROON_BYTES; - #[test] - fn test_validate_auth_wrong_body() { + let (_directory, store) = test_store("http-admission"); + let token = admin_token(&store); 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); + let body = b"\x00\x00\x00\x00\x00"; + let bound = bind_request(&token, GET_NODE_INFO_PATH, body, timestamp); + let mut headers = HeaderMap::new(); + headers.insert("macaroon", bound.parse().unwrap()); + let (_, received) = read_authorized_request( + &store, + GET_NODE_INFO_PATH, + &headers, + http_body_util::Full::new(bytes::Bytes::from_static(body)), + ) + .await + .unwrap(); + assert_eq!(received.as_ref(), body); + + for (case, header) in [ + ("missing", None), + ("empty", Some(Vec::new())), + ("non-ASCII", Some(vec![0xff])), + ("non-hex", Some(b"zz".to_vec())), + ("odd hex length", Some(bound.as_bytes()[..bound.len() - 1].to_vec())), + ("truncated token", Some(bound.as_bytes()[..bound.len() - 2].to_vec())), + ("trailing bytes", Some(format!("{bound}00").into_bytes())), + ("extra prefix", Some(format!("Bearer {bound}").into_bytes())), + ("unbound token", Some(token.into_bytes())), + ("oversized token", Some("00".repeat(MAX_MACAROON_BYTES + 1).into_bytes())), + ] { + let mut headers = HeaderMap::new(); + if let Some(header) = header { + headers.insert("macaroon", HeaderValue::from_bytes(&header).unwrap()); + } + let error = read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, UnreadBody) + .await + .unwrap_err(); + assert_eq!(error.code, GRPC_STATUS_UNAUTHENTICATED, "header case: {case}"); + } } - #[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); + #[tokio::test] + async fn policy_expiry_during_body_read_is_rejected() { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + let (_directory, store) = test_store("http-expiry"); + let token = admin_token(&store); + let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs(); + let expiry = timestamp + 3; + let token = + ldk_server_macaroons::derive_macaroon(&token, &[format!("time-before = {expiry}")]) + .unwrap(); + let bytes = bytes::Bytes::from_static(&[0; 5]); + let bound = bind_request(&token, GET_NODE_INFO_PATH, &bytes, timestamp); + let mut headers = HeaderMap::new(); + headers.insert("macaroon", bound.parse().unwrap()); + // The same credential and body must pass before the policy expires. + read_authorized_request( + &store, + GET_NODE_INFO_PATH, + &headers, + http_body_util::Full::new(bytes.clone()), + ) + .await + .unwrap(); + + let body_started = std::cell::Cell::new(false); + let body = http_body_util::StreamBody::new(futures_util::stream::once(async { + body_started.set(true); + while SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() < expiry { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Ok::<_, std::convert::Infallible>(hyper::body::Frame::data(bytes)) + })); + let error = tokio::time::timeout( + Duration::from_secs(10), + read_authorized_request(&store, GET_NODE_INFO_PATH, &headers, body), + ) + .await + .unwrap() + .unwrap_err(); + assert!(body_started.get(), "Request must pass authentication before its body is read"); + assert_eq!(error.code, GRPC_STATUS_PERMISSION_DENIED); + assert_eq!(error.message, "Macaroon expired"); } #[test] 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; diff --git a/ldk-server/src/util/mod.rs b/ldk-server/src/util/mod.rs index 010ffc68..ef2b0ad1 100644 --- a/ldk-server/src/util/mod.rs +++ b/ldk-server/src/util/mod.rs @@ -41,7 +41,7 @@ pub(crate) fn read_to_string_with_limit(path: &Path, limit: usize) -> io::Result pub(crate) fn write_new(path: &Path, contents: &[u8], mode: u32) -> io::Result<()> { let mut file = OpenOptions::new().create_new(true).write(true).mode(mode).open(path)?; file.write_all(contents)?; - fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + file.set_permissions(fs::Permissions::from_mode(mode))?; file.sync_all()?; Ok(()) } @@ -79,6 +79,30 @@ mod tests { fs::remove_dir_all(dir).unwrap(); } + #[test] + fn write_new_rejects_symlinks() { + let dir = test_dir("symlinks"); + let target = dir.join("target"); + fs::write(&target, b"original").unwrap(); + fs::set_permissions(&target, fs::Permissions::from_mode(0o600)).unwrap(); + + for (name, destination) in [("existing", target.clone()), ("dangling", dir.join("missing"))] + { + let path = dir.join(name); + std::os::unix::fs::symlink(&destination, &path).unwrap(); + + let err = write_new(&path, b"replacement", 0o400).unwrap_err(); + + assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(fs::read_link(&path).unwrap(), destination); + } + assert_eq!(fs::read(&target).unwrap(), b"original"); + assert_eq!(fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600); + assert!(!dir.join("missing").exists()); + + fs::remove_dir_all(dir).unwrap(); + } + fn test_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir() .join(format!("ldk-server-secure-file-test-{name}-{}", std::process::id()));