radrootsd

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

commit 8c98d750c8f11a9b8230a09ce51c38b66bd4a952
parent 795c7fc148ef8d2fb41b0efa386f7ad48054b220
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 21:00:59 +0000

bridge: enforce signer authority continuity

Diffstat:
Msrc/core/nip46/session.rs | 44+++++++++++++++++++++++++++++++++++++++++++-
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 15+++++++++++++--
Msrc/transport/jsonrpc/methods/bridge/order_request.rs | 15+++++++++++++--
Msrc/transport/jsonrpc/methods/bridge/shared.rs | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 35+++++++++++++++++++++++++----------
Msrc/transport/nostr/listener.rs | 1+
6 files changed, 208 insertions(+), 20 deletions(-)

diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use nostr::nips::nip46::NostrConnectRequest; @@ -50,6 +50,16 @@ pub struct Nip46SessionView { pub authorized: bool, pub auth_url: Option<String>, pub expires_in_secs: Option<u64>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signer_authority: Option<Nip46SessionAuthority>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Nip46SessionAuthority { + pub provider_runtime_id: String, + pub account_identity_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_signer_session_id: Option<String>, } #[derive(Clone)] @@ -70,6 +80,7 @@ pub struct Nip46Session { pub authorized: bool, pub auth_url: Option<String>, pub pending_request: Option<PendingNostrRequest>, + pub signer_authority: Option<Nip46SessionAuthority>, } impl Nip46SessionStore { @@ -191,6 +202,14 @@ impl Nip46SessionStore { } impl Nip46Session { + pub fn normalize_authority( + authority: Option<Nip46SessionAuthority>, + ) -> Result<Option<Nip46SessionAuthority>, String> { + authority + .map(|authority| authority.normalized()) + .transpose() + } + pub fn is_expired(&self) -> bool { self.expires_at .map(|expires_at| expires_at <= Instant::now()) @@ -221,7 +240,28 @@ impl Nip46Session { authorized: self.authorized, auth_url: self.auth_url.clone(), expires_in_secs: self.expires_at.map(remaining_secs), + signer_authority: self.signer_authority.clone(), + } + } +} + +impl Nip46SessionAuthority { + pub fn normalized(mut self) -> Result<Self, String> { + self.provider_runtime_id = self.provider_runtime_id.trim().to_owned(); + self.account_identity_id = self.account_identity_id.trim().to_owned(); + self.provider_signer_session_id = self + .provider_signer_session_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + if self.provider_runtime_id.is_empty() { + return Err("signer_authority.provider_runtime_id cannot be empty".to_owned()); + } + if self.account_identity_id.is_empty() { + return Err("signer_authority.account_identity_id cannot be empty".to_owned()); } + Ok(self) } } @@ -296,6 +336,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, } } @@ -344,6 +385,7 @@ mod tests { authorized: false, auth_url: Some("https://signer.example.com/auth".to_string()), pending_request: None, + signer_authority: None, }; let view = session.public_view(); diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -15,9 +15,11 @@ use crate::core::bridge::publish::{ }; use crate::core::bridge::store::new_listing_publish_job; use crate::transport::jsonrpc::auth::require_bridge_auth; +use crate::core::nip46::session::Nip46SessionAuthority; use crate::transport::jsonrpc::methods::bridge::shared::{ - BridgePublishResponse, ensure_bridge_enabled, fingerprint_bridge_request, normalize_idempotency_key, - reserve_bridge_job, resolve_actor_bridge_signer, sign_bridge_event_builder, + BridgePublishResponse, ensure_bridge_enabled, fingerprint_bridge_request, + normalize_idempotency_key, reserve_bridge_job, resolve_actor_bridge_signer, + sign_bridge_event_builder, }; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; @@ -29,6 +31,8 @@ struct BridgeListingPublishParams { #[serde(default)] signer_session_id: Option<String>, #[serde(default)] + signer_authority: Option<Nip46SessionAuthority>, + #[serde(default)] idempotency_key: Option<String>, } @@ -64,6 +68,7 @@ async fn publish_listing( let signer = resolve_actor_bridge_signer( &ctx, params.signer_session_id.as_deref(), + params.signer_authority.as_ref(), kind, "bridge.listing.publish", ) @@ -248,6 +253,7 @@ mod tests { listing: base_listing(), kind: None, signer_session_id: Some(session_id.clone()), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }; @@ -262,6 +268,7 @@ mod tests { listing: base_listing(), kind: None, signer_session_id: Some(session_id), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }, ) @@ -298,6 +305,7 @@ mod tests { listing, kind: None, signer_session_id: Some(session_id), + signer_authority: None, idempotency_key: Some("bad-listing".to_string()), }, ) @@ -332,6 +340,7 @@ mod tests { listing: base_listing(), kind: Some(KIND_LISTING_DRAFT), signer_session_id: Some(session_id), + signer_authority: None, idempotency_key: Some("draft-kind".to_string()), }, ) @@ -372,6 +381,7 @@ mod tests { listing: base_listing(), kind: None, signer_session_id: None, + signer_authority: None, idempotency_key: Some("missing-session".to_string()), }, ) @@ -405,6 +415,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }).await; session_id.to_string() } diff --git a/src/transport/jsonrpc/methods/bridge/order_request.rs b/src/transport/jsonrpc/methods/bridge/order_request.rs @@ -23,10 +23,12 @@ use crate::core::bridge::publish::{ BridgePublishSettings, connect_and_publish_event, failed_prepublish_execution, }; use crate::core::bridge::store::new_order_request_job; +use crate::core::nip46::session::Nip46SessionAuthority; use crate::transport::jsonrpc::auth::require_bridge_auth; use crate::transport::jsonrpc::methods::bridge::shared::{ - BridgePublishResponse, ensure_bridge_enabled, fingerprint_bridge_request, normalize_idempotency_key, - reserve_bridge_job, resolve_actor_bridge_signer, sign_bridge_event_builder, + BridgePublishResponse, ensure_bridge_enabled, fingerprint_bridge_request, + normalize_idempotency_key, reserve_bridge_job, resolve_actor_bridge_signer, + sign_bridge_event_builder, }; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; @@ -36,6 +38,8 @@ struct BridgeOrderRequestParams { #[serde(default)] signer_session_id: Option<String>, #[serde(default)] + signer_authority: Option<Nip46SessionAuthority>, + #[serde(default)] idempotency_key: Option<String>, } @@ -65,6 +69,7 @@ async fn publish_order_request( let signer = resolve_actor_bridge_signer( &ctx, params.signer_session_id.as_deref(), + params.signer_authority.as_ref(), u32::from(TradeListingMessageType::OrderRequest.kind()), "bridge.order.request", ) @@ -370,6 +375,7 @@ mod tests { let params = BridgeOrderRequestParams { order: base_order("", ""), signer_session_id: Some(session_id.clone()), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }; @@ -385,6 +391,7 @@ mod tests { BridgeOrderRequestParams { order: base_order("", ""), signer_session_id: Some(session_id), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }, ) @@ -417,6 +424,7 @@ mod tests { BridgeOrderRequestParams { order: base_order("", ""), signer_session_id: Some(session_id.clone()), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }, ) @@ -430,6 +438,7 @@ mod tests { BridgeOrderRequestParams { order: conflicting, signer_session_id: Some(session_id), + signer_authority: None, idempotency_key: Some("same-key".to_string()), }, ) @@ -461,6 +470,7 @@ mod tests { BridgeOrderRequestParams { order: base_order("", ""), signer_session_id: None, + signer_authority: None, idempotency_key: Some("missing-session".to_string()), }, ) @@ -494,6 +504,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }).await; session_id.to_string() } diff --git a/src/transport/jsonrpc/methods/bridge/shared.rs b/src/transport/jsonrpc/methods/bridge/shared.rs @@ -9,7 +9,7 @@ use crate::core::bridge::publish::BridgeRelayPublishResult; use crate::core::bridge::store::{ BridgeJobRecord, BridgeJobReservation, BridgeJobStatus, BridgeJobStoreError, }; -use crate::core::nip46::session::Nip46SessionRole; +use crate::core::nip46::session::{Nip46SessionAuthority, Nip46SessionRole}; use crate::transport::jsonrpc::nip46::{client as nip46_client, session as nip46_session}; use crate::transport::jsonrpc::{RpcContext, RpcError}; @@ -162,6 +162,7 @@ pub(super) async fn resolve_bridge_signer( pub(super) async fn resolve_actor_bridge_signer( ctx: &RpcContext, signer_session_id: Option<&str>, + signer_authority: Option<&Nip46SessionAuthority>, event_kind: u32, command: &str, ) -> Result<BridgeSignerSelection, RpcError> { @@ -184,12 +185,53 @@ pub(super) async fn resolve_actor_bridge_signer( error )) })?; + require_signer_authority(&session, signer_authority).map_err(|reason| { + RpcError::Unauthorized(format!("{command} signer_session_id `{session_id}` {reason}")) + })?; Ok(BridgeSignerSelection::Nip46Session { session_id: session_id.to_string(), session, }) } +fn require_signer_authority( + session: &crate::core::nip46::session::Nip46Session, + signer_authority: Option<&Nip46SessionAuthority>, +) -> Result<(), String> { + let Some(expected) = signer_authority else { + return Ok(()); + }; + let Some(actual) = session.signer_authority.as_ref() else { + return Err("is missing signer authority continuity metadata".to_owned()); + }; + if actual.provider_runtime_id != expected.provider_runtime_id { + return Err(format!( + "provider `{}` does not match required provider `{}`", + actual.provider_runtime_id, expected.provider_runtime_id + )); + } + if actual.account_identity_id != expected.account_identity_id { + return Err(format!( + "account identity `{}` does not match required account `{}`", + actual.account_identity_id, expected.account_identity_id + )); + } + if actual.provider_signer_session_id != expected.provider_signer_session_id { + return Err(format!( + "provider signer session `{}` does not match required provider session `{}`", + actual + .provider_signer_session_id + .as_deref() + .unwrap_or("<none>"), + expected + .provider_signer_session_id + .as_deref() + .unwrap_or("<none>") + )); + } + Ok(()) +} + pub(super) async fn sign_bridge_event_builder( ctx: &RpcContext, signer: &BridgeSignerSelection, @@ -275,7 +317,7 @@ mod tests { use crate::core::bridge::store::{ BRIDGE_PENDING_RECOVERY_SUMMARY, BridgeJobStatus, new_listing_publish_job, }; - use crate::core::nip46::session::Nip46Session; + use crate::core::nip46::session::{Nip46Session, Nip46SessionAuthority}; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use super::{ @@ -322,6 +364,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }) .await; let ctx = RpcContext::new(state, MethodRegistry::default()); @@ -350,9 +393,10 @@ mod tests { .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); - let err = match resolve_actor_bridge_signer(&ctx, None, 30402, "bridge.listing.publish") - .await - { + let err = + match resolve_actor_bridge_signer(&ctx, None, None, 30402, "bridge.listing.publish") + .await + { Ok(_) => panic!("expected missing session to fail"), Err(err) => err, }; @@ -391,6 +435,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }) .await; let ctx = RpcContext::new(state, MethodRegistry::default()); @@ -398,6 +443,7 @@ mod tests { let err = match resolve_actor_bridge_signer( &ctx, Some("session-1"), + None, 30402, "bridge.listing.publish", ) @@ -410,6 +456,67 @@ mod tests { assert!(err.to_string().contains("sign_event:30402")); } + #[tokio::test] + async fn resolve_actor_bridge_signer_rejects_mismatched_authority() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity.clone(), + metadata, + BridgeConfig::default(), + Nip46Config::default(), + ) + .expect("state"); + let session_keys = RadrootsNostrKeys::generate(); + state + .nip46_sessions + .insert(Nip46Session { + id: "session-1".to_string(), + client: RadrootsNostrClient::new(session_keys.clone()), + client_keys: session_keys.clone(), + client_pubkey: session_keys.public_key(), + remote_signer_pubkey: session_keys.public_key(), + user_pubkey: None, + relays: vec!["wss://relay.example.com".to_string()], + perms: vec!["sign_event".to_string()], + name: None, + url: None, + image: None, + expires_at: Some(Instant::now() + std::time::Duration::from_secs(60)), + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, + signer_authority: Some(Nip46SessionAuthority { + provider_runtime_id: "myc".to_owned(), + account_identity_id: "acct-authorized".to_owned(), + provider_signer_session_id: Some("conn-authorized".to_owned()), + }), + }) + .await; + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let err = match resolve_actor_bridge_signer( + &ctx, + Some("session-1"), + Some(&Nip46SessionAuthority { + provider_runtime_id: "myc".to_owned(), + account_identity_id: "acct-other".to_owned(), + provider_signer_session_id: Some("conn-authorized".to_owned()), + }), + 30402, + "bridge.listing.publish", + ) + .await + { + Ok(_) => panic!("expected authority mismatch to fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("account identity")); + assert!(err.to_string().contains("acct-other")); + } + #[test] fn fingerprint_bridge_request_changes_when_request_changes() { let signer = super::BridgeSignerSelection::EmbeddedServiceIdentity { @@ -451,6 +558,7 @@ mod tests { authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }; let renewed_session = Nip46Session { id: "session-2".to_string(), diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -7,7 +7,9 @@ use tokio::sync::broadcast; use tokio::time::sleep; use uuid::Uuid; -use crate::core::nip46::session::{Nip46Session, filter_perms, session_expires_at}; +use crate::core::nip46::session::{ + Nip46Session, Nip46SessionAuthority, filter_perms, session_expires_at, +}; use crate::transport::jsonrpc::nip46::connection::{ Nip46ConnectInfo, Nip46ConnectMode, parse_connect_url, }; @@ -26,6 +28,8 @@ use radroots_nostr::prelude::{ struct Nip46ConnectParams { url: String, client_secret_key: Option<String>, + #[serde(default)] + signer_authority: Option<Nip46SessionAuthority>, } #[derive(Clone, Debug, Serialize)] @@ -40,13 +44,15 @@ struct Nip46ConnectResponse { pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("nip46.connect"); m.register_async_method("nip46.connect", |params, ctx, _| async move { - let Nip46ConnectParams { - url, - client_secret_key, - } = params - .parse() - .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let response = connect_nip46(ctx.as_ref().clone(), url, client_secret_key).await?; + let Nip46ConnectParams { + url, + client_secret_key, + signer_authority, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let response = + connect_nip46(ctx.as_ref().clone(), url, client_secret_key, signer_authority).await?; Ok::<Nip46ConnectResponse, RpcError>(response) })?; Ok(()) @@ -56,17 +62,23 @@ async fn connect_nip46( ctx: RpcContext, url: String, client_secret_key: Option<String>, + signer_authority: Option<Nip46SessionAuthority>, ) -> Result<Nip46ConnectResponse, RpcError> { + let signer_authority = Nip46Session::normalize_authority(signer_authority) + .map_err(RpcError::InvalidParams)?; let info = parse_connect_url(&url)?; match info.mode { - Nip46ConnectMode::Bunker => connect_bunker(ctx, info).await, - Nip46ConnectMode::Nostrconnect => connect_nostrconnect(ctx, info, client_secret_key).await, + Nip46ConnectMode::Bunker => connect_bunker(ctx, info, signer_authority).await, + Nip46ConnectMode::Nostrconnect => { + connect_nostrconnect(ctx, info, client_secret_key, signer_authority).await + } } } async fn connect_bunker( ctx: RpcContext, info: Nip46ConnectInfo, + signer_authority: Option<Nip46SessionAuthority>, ) -> Result<Nip46ConnectResponse, RpcError> { if info.relays.is_empty() { return Err(RpcError::InvalidParams("missing relay".to_string())); @@ -147,6 +159,7 @@ async fn connect_bunker( authorized: true, auth_url: None, pending_request: None, + signer_authority, }; ctx.state.nip46_sessions.insert(session).await; @@ -163,6 +176,7 @@ async fn connect_nostrconnect( ctx: RpcContext, info: Nip46ConnectInfo, client_secret_key: Option<String>, + signer_authority: Option<Nip46SessionAuthority>, ) -> Result<Nip46ConnectResponse, RpcError> { if info.relays.is_empty() { return Err(RpcError::InvalidParams("missing relay".to_string())); @@ -224,6 +238,7 @@ async fn connect_nostrconnect( authorized: true, auth_url: None, pending_request: None, + signer_authority, }; ctx.state.nip46_sessions.insert(session).await; diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -142,6 +142,7 @@ pub(crate) async fn handle_request( authorized: true, auth_url: None, pending_request: None, + signer_authority: None, }; radrootsd.nip46_sessions.insert(session).await; NostrConnectResponse::with_result(ResponseResult::Ack)