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:
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 {