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:
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,
}
}
}