From 03b47f742b388e3711ce1fdc8d5f624f617485da Mon Sep 17 00:00:00 2001 From: Tori Date: Mon, 17 Aug 2026 12:31:09 -0500 Subject: [PATCH] feat(rpc): require bearer-token auth on admin gRPC service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin gRPC service (cancel_order, settle_order, add_solver, take_dispute, get_version, validate_db_password) had zero code-level authentication — anyone reaching listen_address:port had full admin control. validate_db_password's rate limiter is anti-brute-force, not auth; a comment in call_admin_cancel falsely claimed "gRPC transport authenticates the operator." Adds a shared bearer token (`[rpc].auth_token` / MOSTRO_RPC_AUTH_TOKEN, mirroring the nsec_privkey secret-handling pattern) checked by TokenAuthInterceptor via constant-time comparison, wired onto the server with tonic-build's with_interceptor so it runs before any handler. The daemon now refuses to start if [rpc].enabled = true without a token configured — deployments that never enabled [rpc] are unaffected. validate_db_password's existing RateLimiter is kept as defense-in-depth on top of the new transport-level auth. Resolves #807. BREAKING CHANGE: deployments with [rpc].enabled = true must now set [rpc].auth_token or MOSTRO_RPC_AUTH_TOKEN; the daemon refuses to start otherwise. Deployments that never enabled [rpc] are unaffected. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 10 +-- docs/ADMIN_RPC_AND_DISPUTES.md | 2 +- docs/RPC.md | 18 ++++-- docs/RPC_RATE_LIMITING.md | 2 +- settings.tpl.toml | 5 ++ src/config/constants.rs | 4 ++ src/config/secret.rs | 20 +++++- src/config/types.rs | 11 +++- src/config/util.rs | 113 ++++++++++++++++++++++++++++++++- src/rpc/auth.rs | 111 ++++++++++++++++++++++++++++++++ src/rpc/mod.rs | 1 + src/rpc/server.rs | 70 +++++++++++++++++++- src/rpc/service.rs | 7 +- 15 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 src/rpc/auth.rs diff --git a/Cargo.lock b/Cargo.lock index ee27affe..473a4350 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2372,6 +2372,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "subtle", "tokio", "toml", "tonic 0.14.6", diff --git a/Cargo.toml b/Cargo.toml index b2889c3e..0ae07280 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ cdk = { version = "0.17.2", default-features = false, features = ["wallet"] } secp256k1 = { version = "0.30", features = ["serde"] } secrecy = { version = "0.10", features = ["serde"] } zeroize = "1.8" +subtle = "2.6" [dev-dependencies] tokio = { version = "1.47.1", features = ["full", "test-util", "macros"] } diff --git a/README.md b/README.md index fb70f370..15b3d50f 100644 --- a/README.md +++ b/README.md @@ -794,17 +794,19 @@ For client documentation, see the respective client repositories. ### Admin Operations (RPC Interface) -If RPC is enabled, use admin tools for dispute resolution: +If RPC is enabled, use admin tools for dispute resolution. Every call requires the `authorization: Bearer ` header set to your `[rpc].auth_token` / `MOSTRO_RPC_AUTH_TOKEN` value: ```bash +export MOSTRO_RPC_AUTH_TOKEN="your-configured-token" + # Cancel an order (admin override) -grpcurl -plaintext -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/CancelOrder +grpcurl -plaintext -H "authorization: Bearer $MOSTRO_RPC_AUTH_TOKEN" -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/CancelOrder # Settle disputed order -grpcurl -plaintext -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/SettleOrder +grpcurl -plaintext -H "authorization: Bearer $MOSTRO_RPC_AUTH_TOKEN" -d '{"order_id": "abc123"}' localhost:50051 mostro.admin.v1.AdminService/SettleOrder # Add dispute solver -grpcurl -plaintext -d '{"solver_pubkey": "npub1..."}' localhost:50051 mostro.admin.v1.AdminService/AddSolver +grpcurl -plaintext -H "authorization: Bearer $MOSTRO_RPC_AUTH_TOKEN" -d '{"solver_pubkey": "npub1..."}' localhost:50051 mostro.admin.v1.AdminService/AddSolver ``` --- diff --git a/docs/ADMIN_RPC_AND_DISPUTES.md b/docs/ADMIN_RPC_AND_DISPUTES.md index 83e292bb..e62feb15 100644 --- a/docs/ADMIN_RPC_AND_DISPUTES.md +++ b/docs/ADMIN_RPC_AND_DISPUTES.md @@ -41,7 +41,7 @@ sequenceDiagram ``` ## Audit and Safety -- Require admin authentication/authorization at message level. +- Admin RPC transport requires a bearer token (`[rpc].auth_token` / `MOSTRO_RPC_AUTH_TOKEN`), validated by `TokenAuthInterceptor` (`src/rpc/auth.rs`) before any request reaches a handler — see `docs/RPC.md#security-considerations`. The daemon refuses to start if `[rpc].enabled = true` without a token configured. - Enforce solver permission levels in the daemon: `read` solvers can assist but cannot execute `admin-settle` or `admin-cancel`. - Record solver, timestamps, and decisions in DB for traceability. - Avoid leaking sensitive data in logs; scrub invoices and keys. diff --git a/docs/RPC.md b/docs/RPC.md index d18ce530..d073e040 100644 --- a/docs/RPC.md +++ b/docs/RPC.md @@ -22,6 +22,10 @@ enabled = true listen_address = "127.0.0.1" # RPC server port (required key; default=50051) port = 50051 +# Bearer token required on every admin RPC call. Required when enabled = true +# (the daemon refuses to start otherwise). Prefer the MOSTRO_RPC_AUTH_TOKEN +# environment variable over storing this in plaintext TOML. +auth_token = "change-me-to-a-long-random-value" ``` ## Available Admin Operations @@ -86,7 +90,7 @@ Take a dispute for resolution. ### 5. Validate Database Password -Kept for backward compatibility with older clients. The SQLite database is **not** encrypted and this RPC does **not** validate any password; it always succeeds. +Kept for backward compatibility with older clients. The SQLite database is **not** encrypted and this RPC does **not** validate any password; it always succeeds. Like every other admin RPC, it is only reached after the bearer-token check below passes. **Request:** @@ -140,10 +144,14 @@ async fn main() -> Result<(), Box> { let mut client = AdminServiceClient::new(channel); - let request = tonic::Request::new(CancelOrderRequest { + let mut request = tonic::Request::new(CancelOrderRequest { order_id: "550e8400-e29b-41d4-a716-446655440000".to_string(), request_id: Some("12345".to_string()), }); + request.metadata_mut().insert( + "authorization", + "Bearer ".parse().unwrap(), + ); let response = client.cancel_order(request).await?; @@ -159,10 +167,12 @@ async fn main() -> Result<(), Box> { ## Security Considerations +- Every admin RPC call requires a bearer token: `authorization: Bearer ` metadata, checked by `TokenAuthInterceptor` (`src/rpc/auth.rs`) before the request reaches any handler. The comparison is constant-time (`subtle::ConstantTimeEq`) so a timing side channel can't be used to guess the token. +- The token is set via `[rpc].auth_token` in `settings.toml`, or the `MOSTRO_RPC_AUTH_TOKEN` environment variable (or `/.env`) — the environment variable takes precedence, following the same pattern as `MOSTRO_NSEC_PRIVKEY`. +- **Fail-closed at startup**: if `[rpc].enabled = true` and no token is configured (TOML or environment), the daemon refuses to start rather than silently running an authless admin surface. - The RPC server listens on localhost by default for security -- Consider implementing authentication/authorization for production use - The RPC interface provides the same admin capabilities as Nostr-based commands -- Only enable the RPC server in trusted environments +- Only enable the RPC server in trusted environments, and treat the token like any other credential (rotate it, don't commit it, prefer the environment variable over plaintext TOML) ## Debugging diff --git a/docs/RPC_RATE_LIMITING.md b/docs/RPC_RATE_LIMITING.md index d0519d28..33815856 100644 --- a/docs/RPC_RATE_LIMITING.md +++ b/docs/RPC_RATE_LIMITING.md @@ -69,7 +69,7 @@ How the original issue’s ideas map to the codebase today: | Exponential backoff / lockout | Implemented inside **`RateLimiter`**; **not** triggered by **`ValidateDbPassword`** (no **`record_failure`**). | | Audit logging | **tracing** in service + limiter. | | Localhost-only | Default RPC bind **`127.0.0.1`** (see `settings.toml` / `docs/RPC.md`). | -| Strong auth | Out of scope for this stub; would need API keys or similar. | +| Strong auth | Implemented (issue #807): a bearer token (`[rpc].auth_token` / `MOSTRO_RPC_AUTH_TOKEN`) is required on every admin RPC call, enforced transport-wide by **`TokenAuthInterceptor`** (`src/rpc/auth.rs`) before any request — including `ValidateDbPassword` — reaches a handler. See `docs/RPC.md#security-considerations`. This module's `RateLimiter` remains in place as defense-in-depth specifically on `ValidateDbPassword`, since token auth already blocks unauthenticated callers entirely and the limiter is cheap, already-reviewed code that adds a second layer against a leaked/brute-forced token. | ## Testing diff --git a/settings.tpl.toml b/settings.tpl.toml index ce21a5df..340c24f8 100644 --- a/settings.tpl.toml +++ b/settings.tpl.toml @@ -138,6 +138,11 @@ listen_address = "127.0.0.1" port = 50051 # Duration in seconds after which inactive rate-limiter entries are evicted # rate_limiter_stale_duration = 3600 +# Bearer token required on every admin RPC call (authorization: Bearer ). +# Required when enabled = true - the daemon refuses to start otherwise. +# Prefer the MOSTRO_RPC_AUTH_TOKEN environment variable (or /.env) +# over storing this in plaintext TOML. +# auth_token = "change-me-to-a-long-random-value" # Multi-source price providers (see docs/PRICE_PROVIDERS.md). # Absent section ≡ legacy single-source behaviour synthesised from diff --git a/src/config/constants.rs b/src/config/constants.rs index ce5d3b5f..d0b2ccfe 100644 --- a/src/config/constants.rs +++ b/src/config/constants.rs @@ -30,3 +30,7 @@ pub const ENV_FILENAME: &str = ".env"; /// Environment variable name used to override the Nostr private key from the /// process environment. Shared between the wizard and the loader. pub const NSEC_ENV_VAR: &str = "MOSTRO_NSEC_PRIVKEY"; + +/// Environment variable name used to override the admin RPC bearer token +/// from the process environment. Shared between the wizard and the loader. +pub const RPC_TOKEN_ENV_VAR: &str = "MOSTRO_RPC_AUTH_TOKEN"; diff --git a/src/config/secret.rs b/src/config/secret.rs index c54e4537..533b7952 100644 --- a/src/config/secret.rs +++ b/src/config/secret.rs @@ -1,7 +1,7 @@ //! Helpers for loading and parsing the Mostro Nostr private key with //! zeroization of transient buffers. -use crate::config::constants::NSEC_ENV_VAR; +use crate::config::constants::{NSEC_ENV_VAR, RPC_TOKEN_ENV_VAR}; use crate::config::types::NostrSettings; use mostro_core::error::MostroError::{self, *}; use mostro_core::error::ServiceError; @@ -11,7 +11,8 @@ use serde::Serializer; use zeroize::Zeroize; /// Serialize a [`SecretString`] for config files (wizard / TOML export only). -pub fn serialize_nsec(secret: &SecretString, serializer: S) -> Result +/// Shared by every `SecretString`-typed setting (`nsec_privkey`, `auth_token`). +pub fn serialize_secret(secret: &SecretString, serializer: S) -> Result where S: Serializer, { @@ -32,6 +33,21 @@ pub fn read_nsec_env_var() -> Option { Some(secret) } +/// Read `MOSTRO_RPC_AUTH_TOKEN` from the process environment, trim +/// whitespace, and wrap in a [`SecretString`]. Returns `None` when unset or +/// blank. +pub fn read_rpc_token_env_var() -> Option { + let mut token_from_env = std::env::var(RPC_TOKEN_ENV_VAR).ok()?; + let trimmed = token_from_env.trim(); + if trimmed.is_empty() { + token_from_env.zeroize(); + return None; + } + let secret = SecretString::from(trimmed.to_owned()); + token_from_env.zeroize(); + Some(secret) +} + /// Parse a bech32 nsec into [`Keys`], exposing the secret only in this scope. pub fn parse_mostro_keys(secret: &SecretString) -> Result { let nsec = secret.expose_secret(); diff --git a/src/config/types.rs b/src/config/types.rs index 8e7cb0e6..236bbef6 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -505,7 +505,7 @@ impl Default for LightningSettings { pub struct NostrSettings { /// Nostr private key. Optional when `MOSTRO_NSEC_PRIVKEY` is provided via /// environment variable or `/.env`. - #[serde(default, serialize_with = "crate::config::secret::serialize_nsec")] + #[serde(default, serialize_with = "crate::config::secret::serialize_secret")] pub nsec_privkey: secrecy::SecretString, /// Nostr relays list pub relays: Vec, @@ -522,6 +522,14 @@ pub struct RpcSettings { /// Duration in seconds after which inactive rate-limiter entries are evicted #[serde(default = "default_rate_limiter_stale_duration")] pub rate_limiter_stale_duration: u64, + /// Bearer token required on every admin gRPC call (`authorization: Bearer + /// ` metadata). Optional in TOML when `MOSTRO_RPC_AUTH_TOKEN` is + /// provided via environment variable or `/.env` — mirrors + /// `NostrSettings::nsec_privkey`. Unlike the Nostr key, this stays live + /// in `MOSTRO_CONFIG` for the process lifetime (the auth interceptor + /// reads it on every request) rather than being cleared after startup. + #[serde(default, serialize_with = "crate::config::secret::serialize_secret")] + pub auth_token: secrecy::SecretString, } fn default_rate_limiter_stale_duration() -> u64 { @@ -535,6 +543,7 @@ impl Default for RpcSettings { listen_address: "127.0.0.1".to_string(), port: 50051, rate_limiter_stale_duration: default_rate_limiter_stale_duration(), + auth_token: secrecy::SecretString::default(), } } } diff --git a/src/config/util.rs b/src/config/util.rs index 635a5ce3..03342230 100644 --- a/src/config/util.rs +++ b/src/config/util.rs @@ -3,11 +3,12 @@ /// It includes functions to initialize the default settings directory and create a settings file from the template if it doesn't exist. /// It also includes functions to add a trailing slash to a path if it doesn't already have one. use crate::config::constants::{ENV_FILENAME, MAX_DEV_FEE_PERCENTAGE, MIN_DEV_FEE_PERCENTAGE}; -use crate::config::secret::read_nsec_env_var; +use crate::config::secret::{read_nsec_env_var, read_rpc_token_env_var}; use crate::config::wizard; use crate::config::{init_mostro_settings, Settings}; use mostro_core::error::MostroError::{self, *}; use mostro_core::error::ServiceError; +use secrecy::ExposeSecret; use std::fs; use std::io::IsTerminal; use std::path::PathBuf; @@ -46,6 +47,16 @@ fn apply_nsec_env_override(settings: &mut Settings) { } } +/// If the `MOSTRO_RPC_AUTH_TOKEN` environment variable is set to a non-empty +/// value, override the RPC auth token loaded from `settings.toml`. +/// Whitespace is trimmed; blank values are ignored so the TOML stays the +/// fallback. +fn apply_rpc_token_env_override(settings: &mut Settings) { + if let Some(token) = read_rpc_token_env_var() { + settings.rpc.auth_token = token; + } +} + /// Validates Mostro settings on startup fn validate_mostro_settings(settings: &Settings) -> Result<(), MostroError> { let dev_fee = settings.mostro.dev_fee_percentage; @@ -65,6 +76,16 @@ fn validate_mostro_settings(settings: &Settings) -> Result<(), MostroError> { )))); } + // An admin RPC surface with no credential is the exact vulnerability + // issue #807 fixes — refuse to boot rather than silently run it open. + if settings.rpc.enabled && settings.rpc.auth_token.expose_secret().is_empty() { + return Err(MostroInternalErr(ServiceError::IOError( + "[rpc].enabled = true requires an auth token: set [rpc].auth_token in \ + settings.toml or the MOSTRO_RPC_AUTH_TOKEN environment variable." + .to_string(), + ))); + } + validate_cashu_settings( settings.cashu.as_ref(), settings @@ -173,6 +194,7 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro }; apply_nsec_env_override(&mut settings); + apply_rpc_token_env_override(&mut settings); validate_mostro_settings(&settings)?; init_mostro_settings(settings)?; tracing::info!("Settings correctly loaded!"); @@ -190,9 +212,10 @@ pub fn init_configuration_file(config_path: Option) -> Result<(), Mostro let mut settings: Settings = toml::from_str(&contents) .map_err(|e| MostroInternalErr(ServiceError::IOError(e.to_string())))?; - // Apply MOSTRO_NSEC_PRIVKEY override before validation so an empty TOML - // value is fine when the env var is set. + // Apply MOSTRO_NSEC_PRIVKEY / MOSTRO_RPC_AUTH_TOKEN overrides before + // validation so an empty TOML value is fine when the env var is set. apply_nsec_env_override(&mut settings); + apply_rpc_token_env_override(&mut settings); // Validate settings before initializing validate_mostro_settings(&settings)?; @@ -368,6 +391,90 @@ mod tests { assert!(nostr.nsec_privkey.expose_secret().is_empty()); assert_eq!(nostr.relays, vec!["wss://relay.test"]); } + + // ── RPC auth token env override (issue #807) ─────────────────────────── + // Mirrors the MOSTRO_NSEC_PRIVKEY suite above, sharing the same + // EnvVarGuard/ENV_LOCK machinery (ENV_LOCK guards the whole module's env + // access, not just NSEC_ENV_VAR, so these are safe to run concurrently + // with the nsec tests). + + use crate::config::constants::RPC_TOKEN_ENV_VAR; + + #[test] + fn env_var_overrides_toml_rpc_token() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(RPC_TOKEN_ENV_VAR); + guard.set("token_from_env"); + + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.auth_token = SecretString::from("token_from_toml".to_string()); + apply_rpc_token_env_override(&mut settings); + + assert_eq!(settings.rpc.auth_token.expose_secret(), "token_from_env"); + } + + #[test] + fn empty_env_var_falls_back_to_toml_rpc_token() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(RPC_TOKEN_ENV_VAR); + guard.set(""); + + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.auth_token = SecretString::from("token_from_toml".to_string()); + apply_rpc_token_env_override(&mut settings); + + assert_eq!(settings.rpc.auth_token.expose_secret(), "token_from_toml"); + } + + #[test] + fn no_env_var_keeps_toml_rpc_token() { + let _lock = ENV_LOCK.lock().unwrap(); + let _guard = EnvVarGuard::new(RPC_TOKEN_ENV_VAR); + + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.auth_token = SecretString::from("token_from_toml".to_string()); + apply_rpc_token_env_override(&mut settings); + + assert_eq!(settings.rpc.auth_token.expose_secret(), "token_from_toml"); + } + + #[test] + fn whitespace_only_env_is_ignored_rpc_token() { + let _lock = ENV_LOCK.lock().unwrap(); + let guard = EnvVarGuard::new(RPC_TOKEN_ENV_VAR); + guard.set(" \t "); + + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.auth_token = SecretString::from("token_from_toml".to_string()); + apply_rpc_token_env_override(&mut settings); + + assert_eq!(settings.rpc.auth_token.expose_secret(), "token_from_toml"); + } + + // ── fail-closed validation (issue #807) ──────────────────────────────── + + #[test] + fn validate_mostro_settings_rejects_enabled_rpc_without_token() { + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.enabled = true; + assert!(validate_mostro_settings(&settings).is_err()); + } + + #[test] + fn validate_mostro_settings_accepts_enabled_rpc_with_token() { + let mut settings = make_settings("nsec_from_toml"); + settings.rpc.enabled = true; + settings.rpc.auth_token = SecretString::from("a-real-token".to_string()); + assert!(validate_mostro_settings(&settings).is_ok()); + } + + #[test] + fn validate_mostro_settings_accepts_disabled_rpc_without_token() { + // Default RpcSettings (enabled = false) must stay valid — untouched + // deployments that never opted into [rpc] are unaffected. + let settings = make_settings("nsec_from_toml"); + assert!(validate_mostro_settings(&settings).is_ok()); + } } #[cfg(test)] diff --git a/src/rpc/auth.rs b/src/rpc/auth.rs new file mode 100644 index 00000000..eb598e2a --- /dev/null +++ b/src/rpc/auth.rs @@ -0,0 +1,111 @@ +//! Bearer-token authentication for the admin gRPC service (issue #807). + +use secrecy::{ExposeSecret, SecretString}; +use subtle::ConstantTimeEq; +use tonic::service::Interceptor; +use tonic::{Request, Status}; + +const AUTH_HEADER: &str = "authorization"; +const BEARER_PREFIX: &str = "Bearer "; + +/// Rejects any admin RPC call whose `authorization: Bearer ` metadata +/// does not match the configured token. The byte comparison is constant-time +/// (`subtle::ConstantTimeEq`) so a timing side channel can't be used to guess +/// the token; the length check that gates it is not constant-time, but the +/// length of a caller-supplied token isn't secret information. +#[derive(Clone)] +pub struct TokenAuthInterceptor { + token: SecretString, +} + +impl TokenAuthInterceptor { + pub fn new(token: SecretString) -> Self { + Self { token } + } +} + +impl Interceptor for TokenAuthInterceptor { + fn call(&mut self, request: Request<()>) -> Result, Status> { + let provided = request + .metadata() + .get(AUTH_HEADER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix(BEARER_PREFIX)); + + let Some(provided) = provided else { + return Err(Status::unauthenticated( + "missing or malformed authorization header", + )); + }; + + let matches: bool = provided + .as_bytes() + .ct_eq(self.token.expose_secret().as_bytes()) + .into(); + if matches { + Ok(request) + } else { + Err(Status::unauthenticated("invalid admin RPC token")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn interceptor(token: &str) -> TokenAuthInterceptor { + TokenAuthInterceptor::new(SecretString::from(token.to_string())) + } + + fn request_with_auth(value: Option<&str>) -> Request<()> { + let mut request = Request::new(()); + if let Some(value) = value { + request + .metadata_mut() + .insert(AUTH_HEADER, value.parse().unwrap()); + } + request + } + + #[test] + fn accepts_valid_token() { + let mut interceptor = interceptor("s3cr3t"); + let result = interceptor.call(request_with_auth(Some("Bearer s3cr3t"))); + assert!(result.is_ok()); + } + + #[test] + fn rejects_missing_header() { + let mut interceptor = interceptor("s3cr3t"); + let err = interceptor.call(request_with_auth(None)).unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn rejects_malformed_header() { + let mut interceptor = interceptor("s3cr3t"); + let err = interceptor + .call(request_with_auth(Some("s3cr3t"))) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn rejects_wrong_token() { + let mut interceptor = interceptor("s3cr3t"); + let err = interceptor + .call(request_with_auth(Some("Bearer wrong"))) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } + + #[test] + fn rejects_wrong_length_token() { + let mut interceptor = interceptor("s3cr3t"); + let err = interceptor + .call(request_with_auth(Some("Bearer s3cr3t-but-longer"))) + .unwrap_err(); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + } +} diff --git a/src/rpc/mod.rs b/src/rpc/mod.rs index 60f80d72..7cfc7c59 100644 --- a/src/rpc/mod.rs +++ b/src/rpc/mod.rs @@ -4,6 +4,7 @@ //! for admin operations without going through the Nostr protocol. This is useful //! for local development and admin applications that need low-latency access. +pub mod auth; pub mod rate_limiter; pub mod server; pub mod service; diff --git a/src/rpc/server.rs b/src/rpc/server.rs index 46ff79f7..180996f9 100644 --- a/src/rpc/server.rs +++ b/src/rpc/server.rs @@ -2,8 +2,10 @@ use crate::config::settings::Settings; use crate::lightning::LndConnector; +use crate::rpc::auth::TokenAuthInterceptor; use crate::rpc::service::AdminServiceImpl; use nostr_sdk::prelude::Keys; +use secrecy::SecretString; use sqlx::{Pool, Sqlite}; use std::sync::Arc; use tonic::transport::Server; @@ -15,6 +17,7 @@ use super::admin::admin_service_server::AdminServiceServer; pub struct RpcServer { listen_address: String, port: u16, + auth_token: SecretString, } impl RpcServer { @@ -24,6 +27,7 @@ impl RpcServer { Self { listen_address: rpc_config.listen_address.clone(), port: rpc_config.port, + auth_token: rpc_config.auth_token.clone(), } } @@ -39,11 +43,15 @@ impl RpcServer { .map_err(|e| format!("Invalid address: {}", e))?; let admin_service = AdminServiceImpl::new(my_keys, pool, ln_client); + let interceptor = TokenAuthInterceptor::new(self.auth_token.clone()); info!("Starting RPC server on {}", addr); let server = Server::builder() - .add_service(AdminServiceServer::new(admin_service)) + .add_service(AdminServiceServer::with_interceptor( + admin_service, + interceptor, + )) .serve(addr); if let Err(e) = server.await { @@ -85,6 +93,7 @@ mod tests { let server = RpcServer { listen_address: "localhost".to_string(), port: 8080, + auth_token: SecretString::default(), }; assert_eq!(server.listen_address, "localhost"); @@ -96,6 +105,7 @@ mod tests { let server = RpcServer { listen_address: "127.0.0.1".to_string(), port: 50051, + auth_token: SecretString::default(), }; let expected_addr = format!("{}:{}", server.listen_address, server.port); @@ -151,6 +161,7 @@ mod tests { let server = RpcServer { listen_address: "not an address".to_string(), port: 50051, + auth_token: SecretString::default(), }; let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let result = server @@ -167,6 +178,7 @@ mod tests { let server = RpcServer { listen_address: "8.8.8.8".to_string(), port: 1, + auth_token: SecretString::default(), }; let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let result = server @@ -175,6 +187,62 @@ mod tests { assert!(result.is_err()); } + /// End-to-end proof that `start()`'s `with_interceptor` wiring — not + /// just the `TokenAuthInterceptor` struct in isolation (covered in + /// `rpc::auth`'s own tests) — actually gates the bound server: a real + /// client, over a real connection, without the header, gets rejected. + #[tokio::test] + async fn start_rejects_calls_without_auth_token() { + init_test_settings(); + + // Bind an OS-assigned ephemeral port to avoid CI port collisions, + // then hand that exact address to the server. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + let server = RpcServer { + listen_address: addr.ip().to_string(), + port: addr.port(), + auth_token: SecretString::from("test-token".to_string()), + }; + let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + let ln_client = offline_ln_client().await; + + let handle = tokio::spawn(async move { + let _ = server + .start(Keys::generate(), Arc::new(pool), ln_client) + .await; + }); + + // Give the server a moment to bind before connecting. + let mut attempts = 0; + let channel = loop { + match tonic::transport::Channel::from_shared(format!("http://{addr}")) + .unwrap() + .connect() + .await + { + Ok(channel) => break channel, + Err(_) if attempts < 50 => { + attempts += 1; + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + Err(e) => panic!("failed to connect to test server: {e}"), + } + }; + + let mut client = crate::rpc::admin::admin_service_client::AdminServiceClient::new(channel); + let result = client + .get_version(crate::rpc::admin::GetVersionRequest {}) + .await; + + let err = result.expect_err("call without an authorization header must be rejected"); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + + handle.abort(); + } + #[test] fn test_default_rpc_settings() { let default_settings = RpcSettings::default(); diff --git a/src/rpc/service.rs b/src/rpc/service.rs index 854230a0..16a7fb49 100644 --- a/src/rpc/service.rs +++ b/src/rpc/service.rs @@ -66,7 +66,12 @@ impl AdminServiceImpl { // enforced downstream: the caller must be the assigned solver // (`is_assigned_solver`), with `ensure_dispute_finalize_permission` // bypassing solver category checks for the daemon key (same as - // `admin_take_dispute`). gRPC transport authenticates the operator. + // `admin_take_dispute`). The gRPC transport authenticates the *caller* + // via `TokenAuthInterceptor` (src/rpc/auth.rs), which rejects the + // request before it reaches this handler — this synthesized event + // still uses the daemon's own keys as both sender and identity + // because there is no per-operator Nostr identity on this surface, + // only the shared bearer token. let event = UnwrappedMessage { message: msg.clone(), signature: None,