radrootsd

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

commit 21401de46188514f1b15772f36524a6a589f982f
parent 92761a7f6b505fe881a73a9addd4f5fa379e834f
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 00:47:53 +0000

nip46: implement bunker connect handshake

- add nip46 session store for client state
- send connect request and wait for response
- validate response result before persisting session
- enable nostr nip46/os-rng dependency

Diffstat:
MCargo.lock | 1+
MCargo.toml | 1+
Msrc/api/jsonrpc/methods/nip46/connect.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/api/jsonrpc/methods/nip46/status.rs | 2+-
Msrc/nip46/mod.rs | 1+
Asrc/nip46/session.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Msrc/radrootsd.rs | 5+++++
7 files changed, 263 insertions(+), 6 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1860,6 +1860,7 @@ dependencies = [ "anyhow", "clap", "jsonrpsee", + "nostr", "radroots-core", "radroots-events", "radroots-events-codec", diff --git a/Cargo.toml b/Cargo.toml @@ -15,6 +15,7 @@ radroots-identity = { path = "../crates/identity" } radroots-nostr = { path = "../crates/nostr", features = ["client", "codec", "http"] } radroots-runtime = { path = "../crates/runtime", features = ["cli"] } radroots-trade = { path = "../crates/trade" } +nostr = { path = "../refs/rust-nostr/crates/nostr", version = "0.44.1", features = ["nip46", "os-rng"] } anyhow = { version = "1" } clap = { version = "4", features = ["derive"] } diff --git a/src/api/jsonrpc/methods/nip46/connect.rs b/src/api/jsonrpc/methods/nip46/connect.rs @@ -1,23 +1,227 @@ +use std::time::Duration; + use anyhow::Result; use jsonrpsee::server::RpcModule; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use crate::api::jsonrpc::params::DEFAULT_TIMEOUT_SECS; use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use crate::nip46::connection::{parse_connect_url, Nip46ConnectInfo}; +use crate::nip46::connection::{parse_connect_url, Nip46ConnectInfo, Nip46ConnectMode}; +use crate::nip46::session::Nip46Session; +use radroots_nostr::prelude::{ + radroots_nostr_filter_tag, + radroots_nostr_parse_pubkey, + RadrootsNostrClient, + RadrootsNostrEventBuilder, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrKeys, + RadrootsNostrPublicKey, +}; +use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest}; +use nostr::JsonUtil; #[derive(Debug, Deserialize)] struct Nip46ConnectParams { url: String, } +#[derive(Clone, Debug, Serialize)] +struct Nip46ConnectResponse { + session_id: String, + mode: Nip46ConnectMode, + remote_signer_pubkey: String, + client_pubkey: String, + relays: Vec<String>, +} + pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("nip46.connect"); - m.register_method("nip46.connect", |params, _ctx, _| { + m.register_async_method("nip46.connect", |params, ctx, _| async move { let Nip46ConnectParams { url } = params .parse() .map_err(|e| RpcError::InvalidParams(e.to_string()))?; - let info: Nip46ConnectInfo = parse_connect_url(&url)?; - Ok::<Nip46ConnectInfo, RpcError>(info) + let response = connect_nip46(ctx.as_ref().clone(), url).await?; + Ok::<Nip46ConnectResponse, RpcError>(response) + })?; + Ok(()) +} + +async fn connect_nip46(ctx: RpcContext, url: String) -> Result<Nip46ConnectResponse, RpcError> { + let info = parse_connect_url(&url)?; + match info.mode { + Nip46ConnectMode::Bunker => connect_bunker(ctx, info).await, + Nip46ConnectMode::Nostrconnect => Err(RpcError::InvalidParams( + "nostrconnect mode not supported yet".to_string(), + )), + } +} + +async fn connect_bunker( + ctx: RpcContext, + info: Nip46ConnectInfo, +) -> Result<Nip46ConnectResponse, RpcError> { + if info.relays.is_empty() { + return Err(RpcError::InvalidParams("missing relay".to_string())); + } + + let remote_signer_raw = info.remote_signer_pubkey.as_ref().ok_or_else(|| { + RpcError::InvalidParams("missing remote signer pubkey".to_string()) })?; + let remote_signer_pubkey = radroots_nostr_parse_pubkey(remote_signer_raw) + .map_err(|e| RpcError::InvalidParams(format!("invalid remote signer: {e}")))?; + + let client_keys = RadrootsNostrKeys::generate(); + let client_pubkey = client_keys.public_key(); + let client = RadrootsNostrClient::new(client_keys.clone()); + + add_relays(&client, &info.relays).await?; + client.connect().await; + + let request_id = send_connect_request( + &client, + &client_keys, + &remote_signer_pubkey, + info.secret.as_deref(), + ) + .await?; + + let response = wait_for_connect_response( + &client, + &client_keys, + &remote_signer_pubkey, + &client_pubkey, + &request_id, + ) + .await?; + + validate_connect_response(&response, info.secret.as_deref())?; + + let session_id = Uuid::new_v4().to_string(); + let session = Nip46Session { + id: session_id.clone(), + client, + client_keys, + client_pubkey, + remote_signer_pubkey, + relays: info.relays.clone(), + }; + ctx.state.nip46_sessions.insert(session).await; + + Ok(Nip46ConnectResponse { + session_id, + mode: info.mode, + remote_signer_pubkey: remote_signer_raw.to_string(), + client_pubkey: client_pubkey.to_hex(), + relays: info.relays, + }) +} + +async fn add_relays( + client: &RadrootsNostrClient, + relays: &[String], +) -> Result<(), RpcError> { + for relay in relays { + client + .add_relay(relay) + .await + .map_err(|e| RpcError::AddRelay(relay.to_string(), e.to_string()))?; + } Ok(()) } + +async fn send_connect_request( + client: &RadrootsNostrClient, + client_keys: &RadrootsNostrKeys, + remote_signer_pubkey: &RadrootsNostrPublicKey, + secret: Option<&str>, +) -> Result<String, RpcError> { + let request = NostrConnectRequest::Connect { + remote_signer_public_key: remote_signer_pubkey.clone(), + secret: secret.map(str::to_string), + }; + let message = NostrConnectMessage::request(&request); + let request_id = message.id().to_string(); + let event = RadrootsNostrEventBuilder::nostr_connect( + client_keys, + remote_signer_pubkey.clone(), + message, + ) + .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; + client + .send_event_builder(event) + .await + .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; + Ok(request_id) +} + +async fn wait_for_connect_response( + client: &RadrootsNostrClient, + client_keys: &RadrootsNostrKeys, + remote_signer_pubkey: &RadrootsNostrPublicKey, + client_pubkey: &RadrootsNostrPublicKey, + request_id: &str, +) -> Result<NostrConnectMessage, RpcError> { + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::NostrConnect) + .author(remote_signer_pubkey.clone()); + let filter = radroots_nostr_filter_tag(filter, "p", vec![client_pubkey.to_hex()]) + .map_err(|e| RpcError::Other(format!("nip46 connect filter failed: {e}")))?; + let events = client + .fetch_events(filter, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) + .await + .map_err(|e| RpcError::Other(format!("nip46 connect failed: {e}")))?; + + for event in events { + let decrypted = nip44::decrypt( + client_keys.secret_key(), + remote_signer_pubkey, + &event.content, + ) + .map_err(|e| RpcError::Other(format!("nip46 connect decrypt failed: {e}")))?; + let message = NostrConnectMessage::from_json(&decrypted) + .map_err(|e| RpcError::Other(format!("nip46 connect response parse failed: {e}")))?; + if message.is_response() && message.id() == request_id { + return Ok(message); + } + } + + Err(RpcError::Other( + "nip46 connect response not found".to_string(), + )) +} + +fn validate_connect_response( + response: &NostrConnectMessage, + secret: Option<&str>, +) -> Result<(), RpcError> { + let (result, error) = match response { + NostrConnectMessage::Response { result, error, .. } => (result, error), + _ => { + return Err(RpcError::Other( + "nip46 connect response invalid".to_string(), + )) + } + }; + + if let Some(error) = error { + return Err(RpcError::Other(format!("nip46 connect error: {error}"))); + } + + let result = result + .as_deref() + .ok_or_else(|| RpcError::Other("nip46 connect missing result".to_string()))?; + + if result == "ack" { + return Ok(()); + } + + if secret.is_some_and(|expected| expected == result) { + return Ok(()); + } + + Err(RpcError::Other(format!( + "nip46 connect unexpected result: {result}" + ))) +} diff --git a/src/api/jsonrpc/methods/nip46/status.rs b/src/api/jsonrpc/methods/nip46/status.rs @@ -12,7 +12,7 @@ struct Nip46StatusResponse { pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { registry.track("nip46.status"); m.register_method("nip46.status", |_p, _ctx, _| { - Ok::<Nip46StatusResponse, RpcError>(Nip46StatusResponse { ready: false }) + Ok::<Nip46StatusResponse, RpcError>(Nip46StatusResponse { ready: true }) })?; Ok(()) } diff --git a/src/nip46/mod.rs b/src/nip46/mod.rs @@ -1,3 +1,4 @@ #![forbid(unsafe_code)] pub mod connection; +pub mod session; diff --git a/src/nip46/session.rs b/src/nip46/session.rs @@ -0,0 +1,45 @@ +#![forbid(unsafe_code)] + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use radroots_nostr::prelude::{ + RadrootsNostrClient, + RadrootsNostrKeys, + RadrootsNostrPublicKey, +}; + +#[derive(Clone)] +pub struct Nip46SessionStore { + inner: Arc<Mutex<HashMap<String, Nip46Session>>>, +} + +#[derive(Clone)] +pub struct Nip46Session { + pub id: String, + pub client: RadrootsNostrClient, + pub client_keys: RadrootsNostrKeys, + pub client_pubkey: RadrootsNostrPublicKey, + pub remote_signer_pubkey: RadrootsNostrPublicKey, + pub relays: Vec<String>, +} + +impl Nip46SessionStore { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub async fn insert(&self, session: Nip46Session) { + let mut sessions = self.inner.lock().await; + sessions.insert(session.id.clone(), session); + } + + pub async fn get(&self, session_id: &str) -> Option<Nip46Session> { + let sessions = self.inner.lock().await; + sessions.get(session_id).cloned() + } +} diff --git a/src/radrootsd.rs b/src/radrootsd.rs @@ -7,6 +7,8 @@ use radroots_nostr::prelude::{ RadrootsNostrPublicKey, }; +use crate::nip46::session::Nip46SessionStore; + #[derive(Clone)] pub struct Radrootsd { pub(crate) started: Instant, @@ -14,6 +16,7 @@ pub struct Radrootsd { pub pubkey: RadrootsNostrPublicKey, pub metadata: RadrootsNostrMetadata, pub info: serde_json::Value, + pub(crate) nip46_sessions: Nip46SessionStore, } impl Radrootsd { @@ -24,6 +27,7 @@ impl Radrootsd { "version": env!("CARGO_PKG_VERSION"), "build": option_env!("GIT_HASH").unwrap_or("unknown"), }); + let nip46_sessions = Nip46SessionStore::new(); Self { started: Instant::now(), @@ -31,6 +35,7 @@ impl Radrootsd { pubkey, metadata, info, + nip46_sessions, } } }