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:
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))
}