radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit e923d4cc5ba7857848c91563cf73fd327aa529f7
parent 6b66c168cb617b4184fa81ad1aeb8432be7c9659
Author: triesap <tyson@radroots.org>
Date:   Fri, 27 Mar 2026 22:50:11 +0000

api: gate public nip46 and require bridge auth

- add bridge bearer-token config validation and default public nip46 gating
- attach transport-level auth state in json-rpc middleware and enforce it in bridge methods
- keep bridge as the default public rpc surface while leaving relay-side nip46 flows intact
- update docs and tests for authenticated bridge ingress behavior

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Mconfig.toml | 2++
Msrc/app/config.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/runtime.rs | 16++++++++++++++++
Msrc/core/bridge/publish.rs | 1+
Asrc/transport/jsonrpc/auth.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/jsonrpc/error.rs | 5+++++
Msrc/transport/jsonrpc/methods/bridge/job_status.rs | 4+++-
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 19++++++++++++-------
Msrc/transport/jsonrpc/methods/bridge/status.rs | 6+++++-
Msrc/transport/jsonrpc/methods/mod.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/transport/jsonrpc/mod.rs | 6++++--
Msrc/transport/jsonrpc/server.rs | 36+++++++++++++++++++++++++++++++-----
14 files changed, 334 insertions(+), 18 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1873,6 +1873,7 @@ dependencies = [ "serde_qs", "thiserror 2.0.18", "tokio", + "tower", "tracing", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml @@ -43,6 +43,7 @@ serde_json = { version = "1", default-features = false } serde_qs = { version = "1.0" } tokio = { version = "1", features = ["full"] } thiserror = { version = "2" } +tower = { version = "0.5.3", features = ["util"] } tracing = { version = "0.1" } uuid = { version = "1.22.0", features = ["v4"] } url = { version = "2.5.8" } diff --git a/config.toml b/config.toml @@ -20,9 +20,11 @@ addr = "127.0.0.1:7070" [config.bridge] enabled = true +bearer_token = "change-me" delivery_policy = "any" publish_max_attempts = 2 [config.nip46] +public_jsonrpc_enabled = false session_ttl_secs = 900 perms = [] diff --git a/src/app/config.rs b/src/app/config.rs @@ -1,3 +1,4 @@ +use anyhow::{Result, bail}; use radroots_nostr::prelude::RadrootsNostrMetadata; use radroots_runtime::RadrootsNostrServiceConfig; use serde::{Deserialize, Serialize}; @@ -34,6 +35,10 @@ fn default_nip46_perms() -> Vec<String> { Vec::new() } +fn default_nip46_public_jsonrpc_enabled() -> bool { + false +} + fn default_bridge_enabled() -> bool { false } @@ -68,6 +73,8 @@ pub struct Nip46Config { pub session_ttl_secs: u64, #[serde(default = "default_nip46_perms")] pub perms: Vec<String>, + #[serde(default = "default_nip46_public_jsonrpc_enabled")] + pub public_jsonrpc_enabled: bool, #[serde(default)] pub nostrconnect_url: Option<String>, } @@ -77,6 +84,7 @@ impl Default for Nip46Config { Self { session_ttl_secs: default_nip46_session_ttl_secs(), perms: default_nip46_perms(), + public_jsonrpc_enabled: default_nip46_public_jsonrpc_enabled(), nostrconnect_url: None, } } @@ -104,6 +112,8 @@ impl BridgeDeliveryPolicy { pub struct BridgeConfig { #[serde(default = "default_bridge_enabled")] pub enabled: bool, + #[serde(default)] + pub bearer_token: Option<String>, #[serde(default = "default_bridge_connect_timeout_secs")] pub connect_timeout_secs: u64, #[serde(default = "default_bridge_delivery_policy")] @@ -124,6 +134,7 @@ impl Default for BridgeConfig { fn default() -> Self { Self { enabled: default_bridge_enabled(), + bearer_token: None, connect_timeout_secs: default_bridge_connect_timeout_secs(), delivery_policy: default_bridge_delivery_policy(), delivery_quorum: None, @@ -135,6 +146,22 @@ impl Default for BridgeConfig { } } +impl BridgeConfig { + pub fn bearer_token(&self) -> Option<&str> { + self.bearer_token + .as_deref() + .map(str::trim) + .filter(|token| !token.is_empty()) + } + + pub fn validate(&self) -> Result<()> { + if self.enabled && self.bearer_token().is_none() { + bail!("bridge bearer_token is required when bridge ingress is enabled"); + } + Ok(()) + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RpcConfig { #[serde(default = "default_rpc_addr")] @@ -185,6 +212,11 @@ impl Configuration { pub fn rpc_addr(&self) -> &str { self.rpc_addr.as_deref().unwrap_or(self.rpc.addr.as_str()) } + + pub fn validate(&self) -> Result<()> { + self.bridge.validate()?; + Ok(()) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -212,6 +244,7 @@ mod tests { let cfg = Nip46Config::default(); assert_eq!(cfg.session_ttl_secs, 900); assert!(cfg.perms.is_empty()); + assert!(!cfg.public_jsonrpc_enabled); assert!(cfg.nostrconnect_url.is_none()); } @@ -231,6 +264,7 @@ mod tests { fn bridge_defaults_are_expected() { let cfg = BridgeConfig::default(); assert!(!cfg.enabled); + assert!(cfg.bearer_token.is_none()); assert_eq!(cfg.connect_timeout_secs, 10); assert_eq!(cfg.delivery_policy, BridgeDeliveryPolicy::Any); assert_eq!(cfg.delivery_quorum, None); @@ -256,4 +290,26 @@ mod tests { cfg.rpc_addr = Some("127.0.0.1:2222".to_string()); assert_eq!(cfg.rpc_addr(), "127.0.0.1:2222"); } + + #[test] + fn bridge_validation_requires_bearer_token_when_enabled() { + let err = BridgeConfig { + enabled: true, + ..BridgeConfig::default() + } + .validate() + .expect_err("missing token should fail"); + assert!(err.to_string().contains("bearer_token")); + } + + #[test] + fn bridge_validation_accepts_enabled_bridge_with_bearer_token() { + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + } + .validate() + .expect("valid bridge config"); + } } diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -262,6 +262,7 @@ async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { pub async fn run() -> Result<()> { let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; + settings.config.validate()?; info!("Starting radrootsd"); @@ -439,6 +440,21 @@ mod tests { } #[tokio::test] + async fn run_returns_error_when_bridge_is_enabled_without_bearer_token() { + let _guard = test_guard(); + let path = unique_identity_path("bridge-auth"); + let args = args_for_identity(path, true); + let mut settings = settings_with_relays(Vec::new()); + settings.config.bridge.enabled = true; + settings.config.bridge.bearer_token = None; + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + let err = run().await.expect_err("invalid bridge config should error"); + assert!(err.to_string().contains("bearer_token")); + } + + #[tokio::test] async fn run_covers_shutdown_path_and_presence_success() { let _guard = test_guard(); let path = unique_identity_path("shutdown"); diff --git a/src/core/bridge/publish.rs b/src/core/bridge/publish.rs @@ -410,6 +410,7 @@ mod tests { fn publish_settings_from_config_copies_values() { let config = BridgeConfig { enabled: true, + bearer_token: Some("secret".to_string()), connect_timeout_secs: 15, delivery_policy: BridgeDeliveryPolicy::Quorum, delivery_quorum: Some(2), diff --git a/src/transport/jsonrpc/auth.rs b/src/transport/jsonrpc/auth.rs @@ -0,0 +1,108 @@ +#![forbid(unsafe_code)] + +use jsonrpsee::core::server::Extensions; + +use super::RpcError; + +pub(crate) const BRIDGE_AUTH_MODE: &str = "bearer_token"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum BridgeAuthorization { + Disabled, + Authorized, + Missing, + Invalid, +} + +pub(crate) fn authorize_bridge_request( + authorization_header: Option<&str>, + expected_token: Option<&str>, +) -> BridgeAuthorization { + let Some(expected_token) = expected_token else { + return BridgeAuthorization::Disabled; + }; + let Some(authorization_header) = authorization_header else { + return BridgeAuthorization::Missing; + }; + + let mut parts = authorization_header.split_whitespace(); + let scheme = parts.next().unwrap_or_default(); + let token = parts.next().unwrap_or_default(); + + if !scheme.eq_ignore_ascii_case("bearer") || token.is_empty() || parts.next().is_some() { + return BridgeAuthorization::Invalid; + } + + if token == expected_token { + BridgeAuthorization::Authorized + } else { + BridgeAuthorization::Invalid + } +} + +pub(crate) fn require_bridge_auth(extensions: &Extensions) -> Result<(), RpcError> { + match extensions + .get::<BridgeAuthorization>() + .copied() + .unwrap_or(BridgeAuthorization::Missing) + { + BridgeAuthorization::Authorized => Ok(()), + BridgeAuthorization::Disabled | BridgeAuthorization::Missing => Err( + RpcError::Unauthorized("bridge bearer token required".to_string()), + ), + BridgeAuthorization::Invalid => Err(RpcError::Unauthorized( + "invalid bridge bearer token".to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use jsonrpsee::core::server::Extensions; + + use super::{ + BRIDGE_AUTH_MODE, BridgeAuthorization, authorize_bridge_request, require_bridge_auth, + }; + + #[test] + fn authorize_bridge_request_returns_disabled_without_configured_token() { + let auth = authorize_bridge_request(None, None); + assert_eq!(auth, BridgeAuthorization::Disabled); + } + + #[test] + fn authorize_bridge_request_accepts_matching_bearer_token() { + let auth = authorize_bridge_request(Some("Bearer secret"), Some("secret")); + assert_eq!(auth, BridgeAuthorization::Authorized); + assert_eq!(BRIDGE_AUTH_MODE, "bearer_token"); + } + + #[test] + fn authorize_bridge_request_rejects_invalid_headers() { + assert_eq!( + authorize_bridge_request(Some("Basic secret"), Some("secret")), + BridgeAuthorization::Invalid + ); + assert_eq!( + authorize_bridge_request(Some("Bearer wrong"), Some("secret")), + BridgeAuthorization::Invalid + ); + assert_eq!( + authorize_bridge_request(None, Some("secret")), + BridgeAuthorization::Missing + ); + } + + #[test] + fn require_bridge_auth_accepts_authorized_extensions() { + let mut extensions = Extensions::new(); + extensions.insert(BridgeAuthorization::Authorized); + require_bridge_auth(&extensions).expect("authorized"); + } + + #[test] + fn require_bridge_auth_rejects_missing_extensions() { + let err = require_bridge_auth(&Extensions::new()).expect_err("missing auth should fail"); + assert!(err.to_string().contains("required")); + } +} diff --git a/src/transport/jsonrpc/error.rs b/src/transport/jsonrpc/error.rs @@ -13,6 +13,8 @@ pub enum RpcError { InvalidParams(String), #[error("method not found: {0}")] MethodNotFound(String), + #[error("unauthorized: {0}")] + Unauthorized(String), #[error("{0}")] Other(String), } @@ -24,6 +26,9 @@ impl From<RpcError> for ErrorObjectOwned { RpcError::MethodNotFound(name) => { ErrorObject::owned(-32601, format!("method not found: {name}"), None::<()>) } + RpcError::Unauthorized(msg) => { + ErrorObject::owned(-32001, format!("unauthorized: {msg}"), None::<()>) + } other => ErrorObject::owned(-32000, other.to_string(), None::<()>), } } diff --git a/src/transport/jsonrpc/methods/bridge/job_status.rs b/src/transport/jsonrpc/methods/bridge/job_status.rs @@ -3,6 +3,7 @@ use jsonrpsee::server::RpcModule; use serde::Deserialize; use crate::core::bridge::store::BridgeJobRecord; +use crate::transport::jsonrpc::auth::require_bridge_auth; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; #[derive(Debug, Deserialize)] @@ -12,7 +13,8 @@ struct BridgeJobStatusParams { pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("bridge.job.status"); - m.register_async_method("bridge.job.status", |params, ctx, _| async move { + m.register_async_method("bridge.job.status", |params, ctx, extensions| async move { + require_bridge_auth(&extensions)?; let params: BridgeJobStatusParams = params .parse() .map_err(|e| RpcError::InvalidParams(e.to_string()))?; diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::core::bridge::publish::{BridgePublishSettings, connect_and_publish_event}; use crate::core::bridge::store::{BridgeJobRecord, new_listing_publish_job}; +use crate::transport::jsonrpc::auth::require_bridge_auth; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; #[derive(Debug, Deserialize)] @@ -27,13 +28,17 @@ struct BridgeListingPublishResponse { pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("bridge.listing.publish"); - m.register_async_method("bridge.listing.publish", |params, ctx, _| async move { - let params: BridgeListingPublishParams = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let response = publish_listing(ctx.as_ref().clone(), params).await?; - Ok::<BridgeListingPublishResponse, RpcError>(response) - })?; + m.register_async_method( + "bridge.listing.publish", + |params, ctx, extensions| async move { + require_bridge_auth(&extensions)?; + let params: BridgeListingPublishParams = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let response = publish_listing(ctx.as_ref().clone(), params).await?; + Ok::<BridgeListingPublishResponse, RpcError>(response) + }, + )?; Ok(()) } diff --git a/src/transport/jsonrpc/methods/bridge/status.rs b/src/transport/jsonrpc/methods/bridge/status.rs @@ -3,12 +3,14 @@ use jsonrpsee::server::RpcModule; use serde::Serialize; use crate::app::config::BridgeDeliveryPolicy; +use crate::transport::jsonrpc::auth::{BRIDGE_AUTH_MODE, require_bridge_auth}; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; #[derive(Clone, Debug, Serialize)] struct BridgeStatusResponse { enabled: bool, ready: bool, + auth_mode: String, signer_mode: String, relay_count: usize, delivery_policy: BridgeDeliveryPolicy, @@ -25,12 +27,14 @@ struct BridgeStatusResponse { pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("bridge.status"); - m.register_async_method("bridge.status", |_params, ctx, _| async move { + m.register_async_method("bridge.status", |_params, ctx, extensions| async move { + require_bridge_auth(&extensions)?; let relay_count = ctx.state.client.relays().await.len(); let snapshot = ctx.state.bridge_jobs.snapshot(); Ok::<BridgeStatusResponse, RpcError>(BridgeStatusResponse { enabled: ctx.state.bridge_config.enabled, ready: ctx.state.bridge_config.enabled && relay_count > 0, + auth_mode: BRIDGE_AUTH_MODE.to_string(), signer_mode: "embedded_service_identity".to_string(), relay_count, delivery_policy: ctx.state.bridge_config.delivery_policy, diff --git a/src/transport/jsonrpc/methods/mod.rs b/src/transport/jsonrpc/methods/mod.rs @@ -13,7 +13,94 @@ pub fn register_all( ctx: RpcContext, registry: MethodRegistry, ) -> Result<()> { - root.merge(bridge::module(ctx.clone(), registry.clone())?)?; - root.merge(nip46::module(ctx, registry)?)?; + if ctx.state.bridge_config.enabled { + root.merge(bridge::module(ctx.clone(), registry.clone())?)?; + } + if ctx.state.nip46_config.public_jsonrpc_enabled { + root.merge(nip46::module(ctx, registry)?)?; + } Ok(()) } + +#[cfg(test)] +mod tests { + use jsonrpsee::server::RpcModule; + use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::RadrootsNostrMetadata; + + use super::register_all; + use crate::app::config::{BridgeConfig, Nip46Config}; + use crate::core::Radrootsd; + use crate::transport::jsonrpc::auth::BridgeAuthorization; + use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; + + fn state(bridge_enabled: bool, nip46_public_jsonrpc_enabled: bool) -> Radrootsd { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let bridge = BridgeConfig { + enabled: bridge_enabled, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }; + let nip46 = Nip46Config { + public_jsonrpc_enabled: nip46_public_jsonrpc_enabled, + ..Nip46Config::default() + }; + Radrootsd::new(identity, metadata, bridge, nip46).expect("state") + } + + #[test] + fn register_all_exposes_bridge_methods_by_default() { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state(true, false), registry.clone()); + let mut root = RpcModule::new(ctx.clone()); + register_all(&mut root, ctx, registry).expect("register"); + + assert!(root.method("bridge.status").is_some()); + assert!(root.method("bridge.job.status").is_some()); + assert!(root.method("bridge.listing.publish").is_some()); + assert!(root.method("nip46.connect").is_none()); + } + + #[test] + fn register_all_exposes_nip46_when_public_jsonrpc_is_enabled() { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state(true, true), registry.clone()); + let mut root = RpcModule::new(ctx.clone()); + register_all(&mut root, ctx, registry).expect("register"); + + assert!(root.method("bridge.status").is_some()); + assert!(root.method("nip46.connect").is_some()); + } + + #[tokio::test] + async fn bridge_status_rejects_unauthenticated_requests() { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state(true, false), registry.clone()); + let mut root = RpcModule::new(ctx.clone()); + register_all(&mut root, ctx, registry).expect("register"); + + let (response, _stream) = root + .raw_json_request(r#"{"jsonrpc":"2.0","method":"bridge.status","id":1}"#, 1) + .await + .expect("request"); + assert!(response.get().contains("unauthorized")); + } + + #[tokio::test] + async fn bridge_status_accepts_authenticated_requests() { + let registry = MethodRegistry::default(); + let ctx = RpcContext::new(state(true, false), registry.clone()); + let mut root = RpcModule::new(ctx.clone()); + root.extensions_mut() + .insert(BridgeAuthorization::Authorized); + register_all(&mut root, ctx, registry).expect("register"); + + let (response, _stream) = root + .raw_json_request(r#"{"jsonrpc":"2.0","method":"bridge.status","id":1}"#, 1) + .await + .expect("request"); + assert!(response.get().contains("\"auth_mode\":\"bearer_token\"")); + } +} diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs @@ -8,6 +8,7 @@ use jsonrpsee::server::{RpcModule, ServerHandle}; use crate::app::config::RpcConfig; use crate::core::Radrootsd; +mod auth; mod context; mod error; mod params; @@ -26,13 +27,14 @@ pub async fn start_rpc( addr: SocketAddr, rpc_cfg: &RpcConfig, ) -> Result<ServerHandle> { + state.bridge_config.validate()?; let registry = MethodRegistry::default(); let ctx = RpcContext::new(state, registry.clone()); - let server = server::build_server(addr, rpc_cfg).await?; + let bridge_config = ctx.state.bridge_config.clone(); let mut root = RpcModule::new(ctx.clone()); methods::register_all(&mut root, ctx, registry)?; - let handle = server.start(root); + let handle = server::start_server(addr, rpc_cfg, &bridge_config, root).await?; Ok(handle) } diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -3,11 +3,21 @@ use std::net::SocketAddr; use anyhow::Result; -use jsonrpsee::server::{BatchRequestConfig, Server, ServerBuilder, ServerConfigBuilder}; +use jsonrpsee::server::{ + BatchRequestConfig, HttpBody, HttpRequest, RpcModule, ServerBuilder, ServerConfigBuilder, + ServerHandle, +}; -use crate::app::config::RpcConfig; +use crate::app::config::{BridgeConfig, RpcConfig}; +use crate::transport::jsonrpc::RpcContext; +use crate::transport::jsonrpc::auth; -pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Server> { +pub async fn start_server( + addr: SocketAddr, + rpc_cfg: &RpcConfig, + bridge_cfg: &BridgeConfig, + root: RpcModule<RpcContext>, +) -> Result<ServerHandle> { let mut builder = ServerConfigBuilder::new() .max_request_body_size(rpc_cfg.max_request_body_size) .max_response_body_size(rpc_cfg.max_response_body_size) @@ -25,6 +35,22 @@ pub async fn build_server(addr: SocketAddr, rpc_cfg: &RpcConfig) -> Result<Serve } let server_cfg = builder.build(); - let server = ServerBuilder::with_config(server_cfg).build(addr).await?; - Ok(server) + let bridge_bearer_token = bridge_cfg.bearer_token().map(str::to_owned); + let server = ServerBuilder::with_config(server_cfg) + .set_http_middleware(tower::ServiceBuilder::new().map_request( + move |mut request: HttpRequest<HttpBody>| { + let bridge_auth = auth::authorize_bridge_request( + request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()), + bridge_bearer_token.as_deref(), + ); + request.extensions_mut().insert(bridge_auth); + request + }, + )) + .build(addr) + .await?; + Ok(server.start(root)) }