commit 8c98d750c8f11a9b8230a09ce51c38b66bd4a952
parent 795c7fc148ef8d2fb41b0efa386f7ad48054b220
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Apr 2026 21:00:59 +0000
bridge: enforce signer authority continuity
Diffstat:
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)