From 6512a5054f3126e8e480967a66265041f0f878d1 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 8 Sep 2026 21:44:06 -0500 Subject: [PATCH] Replace HMAC Auth with scoped macaroons We initially implemented HMAC auth but it doesn't allow for scoped permissions without creating a new token for each use case. Macaroons support this natively and allows for users to do more complex things without us having to build out tons of support. This implements macaroons from scratch and replaces our existing HMAC Auth. AI assistance: OpenAI Codex; reviews by GPT-6 Astra and Claude Fable 5.1. --- CONTRIBUTING.md | 20 +- Cargo.lock | 12 +- Cargo.toml | 2 +- README.md | 1 + docs/api-guide.md | 122 ++++- docs/configuration.md | 6 +- docs/getting-started.md | 24 +- docs/operations.md | 42 +- docs/request-binding.md | 73 +++ e2e-tests/Cargo.lock | 11 +- e2e-tests/build.rs | 2 + e2e-tests/src/lib.rs | 31 +- e2e-tests/tests/e2e.rs | 40 +- e2e-tests/tests/macaroons.rs | 296 +++++++++++ e2e-tests/tests/mcp.rs | 74 ++- ldk-server-cli/README.md | 2 +- ldk-server-cli/src/main.rs | 160 +++++- ldk-server-client/Cargo.toml | 3 +- ldk-server-client/README.md | 25 +- ldk-server-client/src/client.rs | 117 +++-- ldk-server-client/src/config.rs | 134 +++-- ldk-server-client/src/error.rs | 4 + ldk-server-client/src/lib.rs | 3 + ldk-server-grpc/src/api.rs | 94 ++++ ldk-server-grpc/src/endpoints.rs | 4 + ldk-server-grpc/src/grpc.rs | 1 + ldk-server-grpc/src/lib.rs | 1 + ldk-server-grpc/src/permissions.rs | 121 +++++ ldk-server-grpc/src/proto/api.proto | 59 +++ ldk-server-macaroons/Cargo.toml | 15 + ldk-server-macaroons/README.md | 17 + ldk-server-macaroons/src/credential.rs | 191 +++++++ ldk-server-macaroons/src/lib.rs | 23 + ldk-server-macaroons/src/macaroon.rs | 480 ++++++++++++++++++ .../tests/data/macaroons-v2.txt | 9 + ldk-server-mcp/CLAUDE.md | 4 +- ldk-server-mcp/README.md | 13 +- ldk-server-mcp/src/config.rs | 43 +- ldk-server-mcp/src/main.rs | 2 +- ldk-server-mcp/src/protocol.rs | 28 +- ldk-server-mcp/src/tools/handlers.rs | 61 ++- ldk-server-mcp/src/tools/mod.rs | 24 + ldk-server-mcp/src/tools/schema.rs | 29 ++ ldk-server-mcp/tests/integration.rs | 28 +- ldk-server/Cargo.toml | 1 + ldk-server/src/api/error.rs | 4 + ldk-server/src/api/macaroons.rs | 68 +++ ldk-server/src/api/mod.rs | 1 + ldk-server/src/macaroons/authorization.rs | 219 ++++++++ ldk-server/src/macaroons/mod.rs | 49 ++ ldk-server/src/macaroons/persistence.rs | 130 +++++ ldk-server/src/macaroons/policy.rs | 182 +++++++ ldk-server/src/macaroons/store.rs | 387 ++++++++++++++ ldk-server/src/macaroons/store/test_util.rs | 91 ++++ ldk-server/src/macaroons/store/tests.rs | 20 + .../src/macaroons/store/tests/management.rs | 378 ++++++++++++++ .../src/macaroons/store/tests/persistence.rs | 180 +++++++ .../src/macaroons/store/tests/requests.rs | 248 +++++++++ ldk-server/src/main.rs | 74 +-- ldk-server/src/service.rs | 431 ++++++++++------ ldk-server/src/util/config.rs | 3 +- ldk-server/src/util/mod.rs | 26 +- 62 files changed, 4463 insertions(+), 480 deletions(-) create mode 100644 docs/request-binding.md create mode 100644 e2e-tests/tests/macaroons.rs create mode 100644 ldk-server-grpc/src/permissions.rs create mode 100644 ldk-server-macaroons/Cargo.toml create mode 100644 ldk-server-macaroons/README.md create mode 100644 ldk-server-macaroons/src/credential.rs create mode 100644 ldk-server-macaroons/src/lib.rs create mode 100644 ldk-server-macaroons/src/macaroon.rs create mode 100644 ldk-server-macaroons/tests/data/macaroons-v2.txt create mode 100644 ldk-server/src/api/macaroons.rs create mode 100644 ldk-server/src/macaroons/authorization.rs create mode 100644 ldk-server/src/macaroons/mod.rs create mode 100644 ldk-server/src/macaroons/persistence.rs create mode 100644 ldk-server/src/macaroons/policy.rs create mode 100644 ldk-server/src/macaroons/store.rs create mode 100644 ldk-server/src/macaroons/store/test_util.rs create mode 100644 ldk-server/src/macaroons/store/tests.rs create mode 100644 ldk-server/src/macaroons/store/tests/management.rs create mode 100644 ldk-server/src/macaroons/store/tests/persistence.rs create mode 100644 ldk-server/src/macaroons/store/tests/requests.rs 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()));