Skip to content

Commit 34c314c

Browse files
benthecarmanclaude
andcommitted
Add HMAC-based authentication for RPC/CLI
Implements time-based HMAC-SHA256 authentication using a shared API key. Each request includes a timestamp and HMAC in the X-Auth header, preventing replay attacks with a 60-second tolerance window. Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent f3eaacd commit 34c314c

File tree

12 files changed

+409
-68
lines changed

12 files changed

+409
-68
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,6 @@ cargo run --bin ldk-server ./ldk-server/ldk-server-config.toml
5151

5252
Interact with the node using CLI:
5353
```
54-
./target/debug/ldk-server-cli -b localhost:3002 onchain-receive # To generate onchain-receive address.
55-
./target/debug/ldk-server-cli -b localhost:3002 help # To print help/available commands.
54+
./target/debug/ldk-server-cli -b localhost:3002 --api-key your-secret-api-key onchain-receive # To generate onchain-receive address.
55+
./target/debug/ldk-server-cli -b localhost:3002 --api-key your-secret-api-key help # To print help/available commands.
5656
```

ldk-server-cli/src/main.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ struct Cli {
4646
#[arg(short, long, default_value = "localhost:3000")]
4747
base_url: String,
4848

49+
#[arg(short, long, required(true))]
50+
api_key: String,
51+
4952
#[command(subcommand)]
5053
command: Commands,
5154
}
@@ -214,7 +217,7 @@ enum Commands {
214217
#[tokio::main]
215218
async fn main() {
216219
let cli = Cli::parse();
217-
let client = LdkServerClient::new(cli.base_url);
220+
let client = LdkServerClient::new(cli.base_url, cli.api_key);
218221

219222
match cli.command {
220223
Commands::GetNodeInfo => {

ldk-server-client/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ serde = ["ldk-server-protos/serde"]
1111
ldk-server-protos = { path = "../ldk-server-protos" }
1212
reqwest = { version = "0.11.13", default-features = false, features = ["rustls-tls"] }
1313
prost = { version = "0.11.6", default-features = false, features = ["std", "prost-derive"] }
14+
bitcoin_hashes = "0.14"

ldk-server-client/src/client.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ use crate::error::LdkServerError;
1313
use crate::error::LdkServerErrorCode::{
1414
AuthError, InternalError, InternalServerError, InvalidRequestError, LightningError,
1515
};
16+
use bitcoin_hashes::hmac::{Hmac, HmacEngine};
17+
use bitcoin_hashes::{sha256, Hash, HashEngine};
1618
use ldk_server_protos::api::{
1719
Bolt11ReceiveRequest, Bolt11ReceiveResponse, Bolt11SendRequest, Bolt11SendResponse,
1820
Bolt12ReceiveRequest, Bolt12ReceiveResponse, Bolt12SendRequest, Bolt12SendResponse,
@@ -32,6 +34,7 @@ use ldk_server_protos::endpoints::{
3234
use ldk_server_protos::error::{ErrorCode, ErrorResponse};
3335
use reqwest::header::CONTENT_TYPE;
3436
use reqwest::Client;
37+
use std::time::{SystemTime, UNIX_EPOCH};
3538

3639
const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
3740

@@ -40,12 +43,31 @@ const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
4043
pub struct LdkServerClient {
4144
base_url: String,
4245
client: Client,
46+
api_key: String,
4347
}
4448

4549
impl LdkServerClient {
4650
/// Constructs a [`LdkServerClient`] using `base_url` as the ldk-server endpoint.
47-
pub fn new(base_url: String) -> Self {
48-
Self { base_url, client: Client::new() }
51+
/// `api_key` is used for HMAC-based authentication.
52+
pub fn new(base_url: String, api_key: String) -> Self {
53+
Self { base_url, client: Client::new(), api_key }
54+
}
55+
56+
/// Computes the HMAC-SHA256 authentication header value.
57+
/// Format: "HMAC <timestamp>:<hmac_hex>"
58+
fn compute_auth_header(&self, body: &[u8]) -> String {
59+
let timestamp = SystemTime::now()
60+
.duration_since(UNIX_EPOCH)
61+
.expect("System time should be after Unix epoch")
62+
.as_secs();
63+
64+
// Compute HMAC-SHA256(api_key, timestamp_bytes || body)
65+
let mut hmac_engine: HmacEngine<sha256::Hash> = HmacEngine::new(self.api_key.as_bytes());
66+
hmac_engine.input(&timestamp.to_be_bytes());
67+
hmac_engine.input(body);
68+
let hmac_result = Hmac::<sha256::Hash>::from_engine(hmac_engine);
69+
70+
format!("HMAC {}:{}", timestamp, hmac_result)
4971
}
5072

5173
/// Retrieve the latest node info like `node_id`, `current_best_block` etc.
@@ -196,10 +218,12 @@ impl LdkServerClient {
196218
&self, request: &Rq, url: &str,
197219
) -> Result<Rs, LdkServerError> {
198220
let request_body = request.encode_to_vec();
221+
let auth_header = self.compute_auth_header(&request_body);
199222
let response_raw = self
200223
.client
201224
.post(url)
202225
.header(CONTENT_TYPE, APPLICATION_OCTET_STREAM)
226+
.header("X-Auth", auth_header)
203227
.body(request_body)
204228
.send()
205229
.await

ldk-server/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ async-trait = { version = "0.1.85", default-features = false }
2020
toml = { version = "0.8.9", default-features = false, features = ["parse"] }
2121
chrono = { version = "0.4", default-features = false, features = ["clock"] }
2222
log = "0.4.28"
23+
base64 = { version = "0.21", default-features = false, features = ["std"] }
2324

2425
# Required for RabittMQ based EventPublisher. Only enabled for `events-rabbitmq` feature.
2526
lapin = { version = "2.4.0", features = ["rustls"], default-features = false, optional = true }

ldk-server/ldk-server-config.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
network = "regtest" # Bitcoin network to use
44
listening_address = "localhost:3001" # Lightning node listening address
55
rest_service_address = "127.0.0.1:3002" # LDK Server REST address
6+
api_key = "your-secret-api-key" # API key for authenticating REST requests
67

78
# Storage settings
89
[storage.disk]

ldk-server/src/api/error.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ pub(crate) enum LdkServerErrorCode {
4343
/// Please refer to [`protos::error::ErrorCode::InvalidRequestError`].
4444
InvalidRequestError,
4545

46+
/// Please refer to [`protos::error::ErrorCode::AuthError`].
47+
AuthError,
48+
4649
/// Please refer to [`protos::error::ErrorCode::LightningError`].
4750
LightningError,
4851

@@ -54,6 +57,7 @@ impl fmt::Display for LdkServerErrorCode {
5457
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
5558
match self {
5659
LdkServerErrorCode::InvalidRequestError => write!(f, "InvalidRequestError"),
60+
LdkServerErrorCode::AuthError => write!(f, "AuthError"),
5761
LdkServerErrorCode::LightningError => write!(f, "LightningError"),
5862
LdkServerErrorCode::InternalServerError => write!(f, "InternalServerError"),
5963
}

ldk-server/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ fn main() {
356356
match res {
357357
Ok((stream, _)) => {
358358
let io_stream = TokioIo::new(stream);
359-
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store));
359+
let node_service = NodeService::new(Arc::clone(&node), Arc::clone(&paginated_store), config_file.api_key.clone());
360360
runtime.spawn(async move {
361361
if let Err(err) = http1::Builder::new().serve_connection(io_stream, node_service).await {
362362
error!("Failed to serve connection: {}", err);

0 commit comments

Comments
 (0)