radrootsd

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

commit a826c67923d3fd8eee9e8b7fca3e1739ec6c3223
parent ba89a74a3c5756d7bdd0a772e949641f4f1bcc87
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 18:57:29 +0000

bridge: require signer sessions for actor-authored writes

Diffstat:
Msrc/transport/jsonrpc/methods/bridge/listing_publish.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/transport/jsonrpc/methods/bridge/order_request.rs | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/transport/jsonrpc/methods/bridge/shared.rs | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
3 files changed, 274 insertions(+), 23 deletions(-)

diff --git a/src/transport/jsonrpc/methods/bridge/listing_publish.rs b/src/transport/jsonrpc/methods/bridge/listing_publish.rs @@ -16,9 +16,8 @@ use crate::core::bridge::publish::{ use crate::core::bridge::store::new_listing_publish_job; 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_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}; @@ -62,7 +61,13 @@ async fn publish_listing( ensure_bridge_enabled(&ctx)?; let idempotency_key = normalize_idempotency_key(params.idempotency_key)?; let kind = resolve_listing_kind(params.kind)?; - let signer = resolve_bridge_signer(&ctx, params.signer_session_id.as_deref(), kind).await?; + let signer = resolve_actor_bridge_signer( + &ctx, + params.signer_session_id.as_deref(), + kind, + "bridge.listing.publish", + ) + .await?; let signer_pubkey = signer.signer_pubkey_hex(); let listing = canonicalize_listing_for_signer(params.listing, signer_pubkey.as_str()); let canonical = CanonicalBridgeListingPublishRequest { kind, listing }; @@ -189,10 +194,14 @@ mod tests { }; use radroots_events_codec::listing::encode::to_wire_parts_with_kind; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::RadrootsNostrMetadata; + use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrKeys, RadrootsNostrMetadata, radroots_nostr_parse_pubkey, + }; + use std::time::Instant; use crate::app::config::{BridgeConfig, Nip46Config}; use crate::core::Radrootsd; + use crate::core::nip46::session::Nip46Session; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use super::{ @@ -234,10 +243,11 @@ mod tests { ) .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); + let session_id = insert_signer_session(&ctx, "session-1").await; let params = BridgeListingPublishParams { listing: base_listing(), kind: None, - signer_session_id: None, + signer_session_id: Some(session_id.clone()), idempotency_key: Some("same-key".to_string()), }; @@ -251,7 +261,7 @@ mod tests { BridgeListingPublishParams { listing: base_listing(), kind: None, - signer_session_id: None, + signer_session_id: Some(session_id), idempotency_key: Some("same-key".to_string()), }, ) @@ -278,6 +288,7 @@ mod tests { ) .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); + let session_id = insert_signer_session(&ctx, "session-1").await; let mut listing = base_listing(); listing.farm.pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); @@ -286,7 +297,7 @@ mod tests { BridgeListingPublishParams { listing, kind: None, - signer_session_id: None, + signer_session_id: Some(session_id), idempotency_key: Some("bad-listing".to_string()), }, ) @@ -313,13 +324,14 @@ mod tests { ) .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); + let session_id = insert_signer_session(&ctx, "session-1").await; let response = publish_listing( ctx, BridgeListingPublishParams { listing: base_listing(), kind: Some(KIND_LISTING_DRAFT), - signer_session_id: None, + signer_session_id: Some(session_id), idempotency_key: Some("draft-kind".to_string()), }, ) @@ -336,6 +348,67 @@ mod tests { ); } + #[tokio::test] + async fn publish_listing_rejects_missing_signer_session() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let err = publish_listing( + ctx, + BridgeListingPublishParams { + listing: base_listing(), + kind: None, + signer_session_id: None, + idempotency_key: Some("missing-session".to_string()), + }, + ) + .await + .expect_err("missing session rejected"); + assert!(err.to_string().contains("requires signer_session_id")); + } + + async fn insert_signer_session(ctx: &RpcContext, session_id: &str) -> String { + let signer_keys = RadrootsNostrKeys::generate(); + let signer_pubkey = signer_keys.public_key().to_hex(); + let remote_signer_pubkey = + radroots_nostr_parse_pubkey(signer_pubkey.as_str()).expect("signer pubkey"); + let client = RadrootsNostrClient::new(signer_keys.clone()); + let client_keys = signer_keys.clone(); + let client_pubkey = client_keys.public_key(); + ctx.state.nip46_sessions.insert(Nip46Session { + id: session_id.to_string(), + client, + client_keys, + client_pubkey, + remote_signer_pubkey, + user_pubkey: None, + relays: Vec::new(), + 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, + }).await; + session_id.to_string() + } + fn base_listing() -> RadrootsListing { RadrootsListing { d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_string(), diff --git a/src/transport/jsonrpc/methods/bridge/order_request.rs b/src/transport/jsonrpc/methods/bridge/order_request.rs @@ -25,9 +25,8 @@ use crate::core::bridge::publish::{ use crate::core::bridge::store::new_order_request_job; 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_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}; @@ -63,10 +62,11 @@ async fn publish_order_request( ensure_bridge_enabled(&ctx)?; let idempotency_key = normalize_idempotency_key(params.idempotency_key)?; - let signer = resolve_bridge_signer( + let signer = resolve_actor_bridge_signer( &ctx, params.signer_session_id.as_deref(), u32::from(TradeListingMessageType::OrderRequest.kind()), + "bridge.order.request", ) .await?; let signer_pubkey = signer.signer_pubkey_hex(); @@ -288,10 +288,14 @@ mod tests { RadrootsTradeOrder as TradeOrder, RadrootsTradeOrderItem as TradeOrderItem, }; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::RadrootsNostrMetadata; + use radroots_nostr::prelude::{ + RadrootsNostrClient, RadrootsNostrKeys, RadrootsNostrMetadata, radroots_nostr_parse_pubkey, + }; + use std::time::Instant; use crate::app::config::{BridgeConfig, Nip46Config}; use crate::core::Radrootsd; + use crate::core::nip46::session::Nip46Session; use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use super::{ @@ -362,9 +366,10 @@ mod tests { ) .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); + let session_id = insert_signer_session(&ctx, "session-1").await; let params = BridgeOrderRequestParams { order: base_order("", ""), - signer_session_id: None, + signer_session_id: Some(session_id.clone()), idempotency_key: Some("same-key".to_string()), }; @@ -379,7 +384,7 @@ mod tests { ctx, BridgeOrderRequestParams { order: base_order("", ""), - signer_session_id: None, + signer_session_id: Some(session_id), idempotency_key: Some("same-key".to_string()), }, ) @@ -406,11 +411,12 @@ mod tests { ) .expect("state"); let ctx = RpcContext::new(state, MethodRegistry::default()); + let session_id = insert_signer_session(&ctx, "session-1").await; publish_order_request( ctx.clone(), BridgeOrderRequestParams { order: base_order("", ""), - signer_session_id: None, + signer_session_id: Some(session_id.clone()), idempotency_key: Some("same-key".to_string()), }, ) @@ -423,7 +429,7 @@ mod tests { ctx, BridgeOrderRequestParams { order: conflicting, - signer_session_id: None, + signer_session_id: Some(session_id), idempotency_key: Some("same-key".to_string()), }, ) @@ -432,6 +438,66 @@ mod tests { assert!(err.to_string().contains("conflicts")); } + #[tokio::test] + async fn publish_order_request_rejects_missing_signer_session() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig { + enabled: true, + bearer_token: Some("secret".to_string()), + ..BridgeConfig::default() + }, + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let err = publish_order_request( + ctx, + BridgeOrderRequestParams { + order: base_order("", ""), + signer_session_id: None, + idempotency_key: Some("missing-session".to_string()), + }, + ) + .await + .expect_err("missing session rejected"); + assert!(err.to_string().contains("requires signer_session_id")); + } + + async fn insert_signer_session(ctx: &RpcContext, session_id: &str) -> String { + let signer_keys = RadrootsNostrKeys::generate(); + let signer_pubkey = signer_keys.public_key().to_hex(); + let remote_signer_pubkey = + radroots_nostr_parse_pubkey(signer_pubkey.as_str()).expect("signer pubkey"); + let client = RadrootsNostrClient::new(signer_keys.clone()); + let client_keys = signer_keys.clone(); + let client_pubkey = client_keys.public_key(); + ctx.state.nip46_sessions.insert(Nip46Session { + id: session_id.to_string(), + client, + client_keys, + client_pubkey, + remote_signer_pubkey, + user_pubkey: None, + relays: Vec::new(), + 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, + }).await; + session_id.to_string() + } + fn base_listing_addr() -> &'static str { "30402:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:AAAAAAAAAAAAAAAAAAAAAg" } diff --git a/src/transport/jsonrpc/methods/bridge/shared.rs b/src/transport/jsonrpc/methods/bridge/shared.rs @@ -9,6 +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::transport::jsonrpc::nip46::{client as nip46_client, session as nip46_session}; use crate::transport::jsonrpc::{RpcContext, RpcError}; @@ -145,6 +146,37 @@ pub(super) async fn resolve_bridge_signer( } } +pub(super) async fn resolve_actor_bridge_signer( + ctx: &RpcContext, + signer_session_id: Option<&str>, + event_kind: u32, + command: &str, +) -> Result<BridgeSignerSelection, RpcError> { + let session_id = signer_session_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + RpcError::Unauthorized(format!( + "{command} requires signer_session_id for actor-authored bridge writes" + )) + })?; + let session = ctx.state.nip46_sessions.get(session_id).await.ok_or_else(|| { + RpcError::Unauthorized(format!( + "{command} signer_session_id `{session_id}` was not found" + )) + })?; + nip46_session::require_sign_event_permission(&session, event_kind).map_err(|error| { + RpcError::Unauthorized(format!( + "{command} signer_session_id `{session_id}` {}", + error + )) + })?; + Ok(BridgeSignerSelection::Nip46Session { + session_id: session_id.to_string(), + session, + }) +} + pub(super) async fn sign_bridge_event_builder( ctx: &RpcContext, signer: &BridgeSignerSelection, @@ -158,10 +190,15 @@ pub(super) async fn sign_bridge_event_builder( .sign_event_builder(builder) .map(|signed| signed.event) .map_err(|error| RpcError::Other(format!("failed to sign {label} event: {error}"))), - BridgeSignerSelection::Nip46Session { session, .. } => { - let unsigned = builder.build(session.remote_signer_pubkey); - nip46_client::sign_event(session, unsigned, label).await - } + BridgeSignerSelection::Nip46Session { session, .. } => match session.role() { + Nip46SessionRole::InboundLocalSigner => builder + .sign_with_keys(&session.client_keys) + .map_err(|error| RpcError::Other(format!("failed to sign {label} event: {error}"))), + Nip46SessionRole::OutboundRemoteSigner => { + let unsigned = builder.build(session.remote_signer_pubkey); + nip46_client::sign_event(session, unsigned, label).await + } + }, } } @@ -229,7 +266,8 @@ mod tests { use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use super::{ - BridgeJobView, fingerprint_bridge_request, normalize_idempotency_key, resolve_bridge_signer, + BridgeJobView, fingerprint_bridge_request, normalize_idempotency_key, + resolve_actor_bridge_signer, resolve_bridge_signer, }; use std::time::Instant; @@ -285,6 +323,80 @@ mod tests { assert_eq!(signer.signer_mode(), "nip46_session:session-1"); } + #[tokio::test] + async fn resolve_actor_bridge_signer_rejects_missing_session_id() { + let identity = RadrootsIdentity::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let state = Radrootsd::new( + identity, + metadata, + BridgeConfig::default(), + Nip46Config::default(), + ) + .expect("state"); + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let err = match resolve_actor_bridge_signer(&ctx, None, 30402, "bridge.listing.publish") + .await + { + Ok(_) => panic!("expected missing session to fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("requires signer_session_id")); + } + + #[tokio::test] + async fn resolve_actor_bridge_signer_rejects_sign_event_permission_gap() { + 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!["nip04_encrypt".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, + }) + .await; + let ctx = RpcContext::new(state, MethodRegistry::default()); + + let err = match resolve_actor_bridge_signer( + &ctx, + Some("session-1"), + 30402, + "bridge.listing.publish", + ) + .await + { + Ok(_) => panic!("expected permission gap to fail"), + Err(err) => err, + }; + assert!(err.to_string().contains("unauthorized")); + assert!(err.to_string().contains("sign_event:30402")); + } + #[test] fn fingerprint_bridge_request_changes_when_request_changes() { let signer = super::BridgeSignerSelection::EmbeddedServiceIdentity {