Skip to content

Commit d730480

Browse files
committed
feat(security): add API key authentication for gRPC server
1 parent ea9f60a commit d730480

6 files changed

Lines changed: 108 additions & 13 deletions

File tree

ddk-node/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,16 @@ Options:
2222
-n, --network <NETWORK> Set the Bitcoin network for DDK [default: regtest]
2323
-s, --storage-dir <STORAGE_DIR> The path where DlcDevKit will store data.
2424
-p, --port <LISTENING_PORT> Listening port for network transport. [default: 1776]
25-
--grpc <GRPC_HOST> Host and port the gRPC server will run on. [default: 0.0.0.0:3030]
25+
--grpc <GRPC_HOST> Host and port the gRPC server will run on. [default: 127.0.0.1:3030]
26+
--api-key <API_KEY> API key for gRPC authentication. Required for non-localhost bindings.
2627
--esplora <ESPLORA_HOST> Host to connect to an esplora server. [default: http://127.0.0.1:30000]
2728
--oracle <ORACLE_HOST> Host to connect to an oracle server. [default: http://127.0.0.1:8082]
2829
--seed <SEED> Seed config strategy ('bytes' OR 'file') [default: file]
2930
-h, --help Print help
3031
```
3132

33+
When binding to non-localhost addresses (e.g., `--grpc 0.0.0.0:3030`), an API key is required via `--api-key`.
34+
3235
```
3336
$ ddk-cli --help
3437

ddk-node/src/bin/cli.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use clap::Parser;
22
use ddk_node::cli_opts::CliCommand;
33
use ddk_node::ddkrpc::ddk_rpc_client::DdkRpcClient;
4+
use tonic::metadata::MetadataValue;
5+
use tonic::transport::Channel;
46

57
#[derive(Debug, Clone, Parser)]
68
#[clap(name = "ddk-cli")]
@@ -14,6 +16,9 @@ struct DdkCliArgs {
1416
#[arg(help = "ddk-node gRPC server to connect to.")]
1517
#[arg(default_value = "http://127.0.0.1:3030")]
1618
pub server: String,
19+
#[arg(long)]
20+
#[arg(help = "API key for authentication")]
21+
pub api_key: Option<String>,
1722
#[clap(subcommand)]
1823
pub command: CliCommand,
1924
}
@@ -22,9 +27,21 @@ struct DdkCliArgs {
2227
async fn main() -> anyhow::Result<()> {
2328
let opts = DdkCliArgs::parse();
2429

25-
let mut client = DdkRpcClient::connect(opts.server).await?;
30+
if let Some(api_key) = opts.api_key {
31+
let channel = Channel::from_shared(opts.server)?.connect().await?;
2632

27-
ddk_node::command::cli_command(opts.command, &mut client).await?;
33+
let token: MetadataValue<_> = api_key.parse()?;
34+
let mut client =
35+
DdkRpcClient::with_interceptor(channel, move |mut req: tonic::Request<()>| {
36+
req.metadata_mut().insert("x-api-key", token.clone());
37+
Ok(req)
38+
});
39+
40+
ddk_node::command::cli_command(opts.command, &mut client).await?;
41+
} else {
42+
let mut client = DdkRpcClient::connect(opts.server).await?;
43+
ddk_node::command::cli_command(opts.command, &mut client).await?;
44+
}
2845

2946
Ok(())
3047
}

ddk-node/src/command.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ use ddk_messages::oracle_msgs::{EventDescriptor, OracleAnnouncement};
2727
use ddk_messages::{AcceptDlc, OfferDlc};
2828
use inquire::{Select, Text};
2929
use serde_json::Value;
30-
use tonic::transport::Channel;
3130

32-
pub async fn cli_command(
33-
arg: CliCommand,
34-
client: &mut DdkRpcClient<Channel>,
35-
) -> anyhow::Result<()> {
31+
pub async fn cli_command<T>(arg: CliCommand, client: &mut DdkRpcClient<T>) -> anyhow::Result<()>
32+
where
33+
T: tonic::client::GrpcService<tonic::body::BoxBody> + Send + 'static,
34+
T::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
35+
T::ResponseBody: tonic::codegen::Body<Data = tonic::codegen::Bytes> + Send + 'static,
36+
<T::ResponseBody as tonic::codegen::Body>::Error:
37+
Into<Box<dyn std::error::Error + Send + Sync>> + Send,
38+
{
3639
match arg {
3740
CliCommand::Info => {
3841
let info = client.info(InfoRequest::default()).await?.into_inner();
@@ -306,9 +309,16 @@ async fn generate_contract_input() -> anyhow::Result<ContractInput> {
306309
})
307310
}
308311

309-
async fn interactive_contract_input(
310-
client: &mut DdkRpcClient<Channel>,
311-
) -> anyhow::Result<ContractInput> {
312+
async fn interactive_contract_input<T>(
313+
client: &mut DdkRpcClient<T>,
314+
) -> anyhow::Result<ContractInput>
315+
where
316+
T: tonic::client::GrpcService<tonic::body::BoxBody> + Send + 'static,
317+
T::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
318+
T::ResponseBody: tonic::codegen::Body<Data = tonic::codegen::Bytes> + Send + 'static,
319+
<T::ResponseBody as tonic::codegen::Body>::Error:
320+
Into<Box<dyn std::error::Error + Send + Sync>> + Send,
321+
{
312322
let contract_type =
313323
Select::new("Select type of contract.", vec!["enum", "numerical"]).prompt()?;
314324

ddk-node/src/ddkrpc.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ pub struct SignRequest {
255255
#[prost(oneof = "sign_request::Outcome", tags = "2, 3")]
256256
pub outcome: ::core::option::Option<sign_request::Outcome>,
257257
}
258+
/// Nested message and enum types in `SignRequest`.
258259
pub mod sign_request {
259260
#[derive(serde::Serialize, serde::Deserialize)]
260261
#[allow(clippy::derive_partial_eq_without_eq)]

ddk-node/src/lib.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use ddkrpc::{InfoRequest, InfoResponse};
3232
use opts::NodeOpts;
3333
use std::str::FromStr;
3434
use std::sync::Arc;
35+
use tonic::service::Interceptor;
3536
use tonic::transport::Server;
3637
use tonic::Request;
3738
use tonic::Response;
@@ -40,6 +41,44 @@ use tonic::{async_trait, Code};
4041

4142
type Ddk = DlcDevKit<NostrDlc, PostgresStore, KormirOracleClient>;
4243

44+
/// API key authentication interceptor for gRPC requests.
45+
///
46+
/// This interceptor checks for an API key in the `x-api-key` metadata header.
47+
/// If an API key is configured, all requests must include a matching key.
48+
/// If no API key is configured, all requests are allowed (for localhost-only deployments).
49+
#[derive(Clone)]
50+
pub struct ApiKeyInterceptor {
51+
api_key: Option<String>,
52+
}
53+
54+
impl ApiKeyInterceptor {
55+
pub fn new(api_key: Option<String>) -> Self {
56+
Self { api_key }
57+
}
58+
}
59+
60+
impl Interceptor for ApiKeyInterceptor {
61+
fn call(&mut self, req: Request<()>) -> Result<Request<()>, Status> {
62+
// If no API key is configured, allow all requests
63+
let Some(expected_key) = &self.api_key else {
64+
return Ok(req);
65+
};
66+
67+
// Check for API key in metadata
68+
let metadata = req.metadata();
69+
match metadata.get("x-api-key") {
70+
Some(key) => {
71+
if key.to_str().unwrap_or("") == expected_key {
72+
Ok(req)
73+
} else {
74+
Err(Status::unauthenticated("Invalid API key"))
75+
}
76+
}
77+
None => Err(Status::unauthenticated("Missing API key")),
78+
}
79+
}
80+
}
81+
4382
#[derive(Clone)]
4483
pub struct DdkNode {
4584
pub node: Arc<Ddk>,
@@ -53,6 +92,18 @@ impl DdkNode {
5392
}
5493

5594
pub async fn serve(opts: NodeOpts) -> anyhow::Result<()> {
95+
// Validate security configuration
96+
let is_localhost =
97+
opts.grpc_host.starts_with("127.0.0.1") || opts.grpc_host.starts_with("localhost");
98+
99+
if !is_localhost && opts.api_key.is_none() {
100+
anyhow::bail!(
101+
"API key is required when binding to non-localhost address ({}). \
102+
Use --api-key to set an API key or bind to 127.0.0.1 for local-only access.",
103+
opts.grpc_host
104+
);
105+
}
106+
56107
let logger = Arc::new(Logger::console(
57108
"console_logger".to_string(),
58109
LogLevel::Info,
@@ -102,15 +153,25 @@ impl DdkNode {
102153
ddk.start()?;
103154
let node = DdkNode::new(ddk);
104155
let node_stop = node.node.clone();
156+
157+
// Create API key interceptor
158+
let interceptor = ApiKeyInterceptor::new(opts.api_key.clone());
159+
105160
let server = Server::builder()
106-
.add_service(DdkRpcServer::new(node))
161+
.add_service(DdkRpcServer::with_interceptor(node, interceptor))
107162
.serve_with_shutdown(opts.grpc_host.parse()?, async {
108163
tokio::signal::ctrl_c()
109164
.await
110165
.expect("Failed to install Ctrl+C signal handler");
111166
let _ = node_stop.stop();
112167
});
113168

169+
if opts.api_key.is_some() {
170+
tracing::info!("gRPC server starting with API key authentication enabled");
171+
} else {
172+
tracing::info!("gRPC server starting without authentication (localhost only)");
173+
}
174+
114175
server.await?;
115176

116177
Ok(())

ddk-node/src/opts.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ pub struct NodeOpts {
3030
#[arg(help = "Listening port for the lightning network transport.")]
3131
pub listening_port: u16,
3232
#[arg(long = "grpc")]
33-
#[arg(default_value = "0.0.0.0:3030")]
33+
#[arg(default_value = "127.0.0.1:3030")]
3434
#[arg(help = "Host and port the gRPC server will run on.")]
3535
pub grpc_host: String,
36+
#[arg(long = "api-key")]
37+
#[arg(help = "API key for gRPC authentication. Required for non-localhost bindings.")]
38+
pub api_key: Option<String>,
3639
#[arg(long = "esplora")]
3740
#[arg(default_value = "https://mutinynet.com/api")]
3841
#[arg(help = "Esplora server to connect to.")]

0 commit comments

Comments
 (0)