radrootsd

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

commit 5b62b872c753dca50b7112217256c871898dd726
parent dc1453dfeed86140c283b7d7b282ebda0651d8da
Author: triesap <triesap@radroots.dev>
Date:   Wed,  7 Jan 2026 16:25:56 +0000

nip46: add auth challenge and session authorize flow

- track auth-required state and pending requests per session
- return auth_url responses for gated nostr requests
- add jsonrpc methods to require auth and authorize sessions
- replay pending nostr requests after authorization

Diffstat:
Msrc/core/nip46/session.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 8++++++++
Msrc/transport/jsonrpc/methods/nip46/mod.rs | 4++++
Asrc/transport/jsonrpc/methods/nip46/session_authorize.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transport/jsonrpc/methods/nip46/session_require_auth.rs | 39+++++++++++++++++++++++++++++++++++++++
Msrc/transport/jsonrpc/nip46/session.rs | 14++++++++++++++
Msrc/transport/nostr/listener.rs | 114++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
7 files changed, 310 insertions(+), 3 deletions(-)

diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -11,6 +11,7 @@ use radroots_nostr::prelude::{ RadrootsNostrKeys, RadrootsNostrPublicKey, }; +use nostr::nips::nip46::NostrConnectRequest; #[derive(Clone)] pub struct Nip46SessionStore { @@ -18,6 +19,17 @@ pub struct Nip46SessionStore { } #[derive(Clone)] +pub struct PendingNostrRequest { + pub request_id: String, + pub client_pubkey: RadrootsNostrPublicKey, + pub request: NostrConnectRequest, +} + +pub struct Nip46AuthorizeOutcome { + pub pending: Option<PendingNostrRequest>, +} + +#[derive(Clone)] pub struct Nip46Session { pub id: String, pub client: RadrootsNostrClient, @@ -31,6 +43,10 @@ pub struct Nip46Session { pub url: Option<String>, pub image: Option<String>, pub expires_at: Option<Instant>, + pub auth_required: bool, + pub authorized: bool, + pub auth_url: Option<String>, + pub pending_request: Option<PendingNostrRequest>, } impl Nip46SessionStore { @@ -82,6 +98,60 @@ impl Nip46SessionStore { } } + pub async fn require_auth(&self, session_id: &str, auth_url: String) -> bool { + let mut sessions = self.inner.lock().await; + match sessions.get_mut(session_id) { + Some(session) => { + if session.is_expired() { + sessions.remove(session_id); + return false; + } + session.auth_required = true; + session.authorized = false; + session.auth_url = Some(auth_url); + session.pending_request = None; + true + } + None => false, + } + } + + pub async fn authorize(&self, session_id: &str) -> Option<Nip46AuthorizeOutcome> { + let mut sessions = self.inner.lock().await; + match sessions.get_mut(session_id) { + Some(session) => { + if session.is_expired() { + sessions.remove(session_id); + return None; + } + session.authorized = true; + Some(Nip46AuthorizeOutcome { + pending: session.pending_request.take(), + }) + } + None => None, + } + } + + pub async fn set_pending_request( + &self, + session_id: &str, + pending: PendingNostrRequest, + ) -> bool { + let mut sessions = self.inner.lock().await; + match sessions.get_mut(session_id) { + Some(session) => { + if session.is_expired() { + sessions.remove(session_id); + return false; + } + session.pending_request = Some(pending); + true + } + None => false, + } + } + pub async fn list(&self) -> Vec<Nip46Session> { let mut sessions = self.inner.lock().await; sessions.retain(|_, session| !session.is_expired()); @@ -155,6 +225,10 @@ mod tests { url: None, image: None, expires_at, + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, } } diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -135,6 +135,10 @@ async fn connect_bunker( url: info.url.clone(), image: info.image.clone(), expires_at, + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, }; ctx.state.nip46_sessions.insert(session).await; @@ -211,6 +215,10 @@ async fn connect_nostrconnect( url: info.url.clone(), image: info.image.clone(), expires_at, + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, }; ctx.state.nip46_sessions.insert(session).await; diff --git a/src/transport/jsonrpc/methods/nip46/mod.rs b/src/transport/jsonrpc/methods/nip46/mod.rs @@ -11,8 +11,10 @@ pub mod nip04; pub mod nip44; pub mod ping; pub mod sign_event; +pub mod session_authorize; pub mod session_close; pub mod session_list; +pub mod session_require_auth; pub mod session_status; pub mod status; @@ -27,6 +29,8 @@ pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<Rpc sign_event::register(&mut m, &registry)?; session_status::register(&mut m, &registry)?; session_close::register(&mut m, &registry)?; + session_authorize::register(&mut m, &registry)?; + session_require_auth::register(&mut m, &registry)?; session_list::register(&mut m, &registry)?; Ok(m) } diff --git a/src/transport/jsonrpc/methods/nip46/session_authorize.rs b/src/transport/jsonrpc/methods/nip46/session_authorize.rs @@ -0,0 +1,60 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use nostr::nips::nip46::NostrConnectMessage; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::RadrootsNostrEventBuilder; + +#[derive(Debug, Deserialize)] +struct Nip46SessionAuthorizeParams { + session_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46SessionAuthorizeResponse { + authorized: bool, + replayed: bool, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.session.authorize"); + m.register_async_method("nip46.session.authorize", |params, ctx, _| async move { + let Nip46SessionAuthorizeParams { session_id } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + let outcome = ctx + .state + .nip46_sessions + .authorize(&session_id) + .await + .ok_or_else(|| RpcError::InvalidParams("unknown session".to_string()))?; + let mut replayed = false; + if let Some(pending) = outcome.pending { + let response = crate::transport::nostr::listener::handle_request( + &ctx.state, + &pending.client_pubkey, + &pending.request_id, + pending.request, + ) + .await; + let message = NostrConnectMessage::response(pending.request_id, response); + let response_event = RadrootsNostrEventBuilder::nostr_connect( + &ctx.state.keys, + pending.client_pubkey, + message, + ) + .map_err(|err| RpcError::Other(format!("nip46 response build failed: {err}")))?; + let _ = ctx.state.client.send_event_builder(response_event).await; + replayed = true; + } + Ok::<Nip46SessionAuthorizeResponse, RpcError>(Nip46SessionAuthorizeResponse { + authorized: true, + replayed, + }) + })?; + Ok(()) +} diff --git a/src/transport/jsonrpc/methods/nip46/session_require_auth.rs b/src/transport/jsonrpc/methods/nip46/session_require_auth.rs @@ -0,0 +1,39 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; + +#[derive(Debug, Deserialize)] +struct Nip46SessionRequireAuthParams { + session_id: String, + auth_url: String, +} + +#[derive(Clone, Debug, Serialize)] +struct Nip46SessionRequireAuthResponse { + required: bool, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("nip46.session.require_auth"); + m.register_async_method("nip46.session.require_auth", |params, ctx, _| async move { + let Nip46SessionRequireAuthParams { session_id, auth_url } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + if auth_url.trim().is_empty() { + return Err(RpcError::InvalidParams("auth_url is empty".to_string())); + } + let required = ctx + .state + .nip46_sessions + .require_auth(&session_id, auth_url) + .await; + Ok::<Nip46SessionRequireAuthResponse, RpcError>(Nip46SessionRequireAuthResponse { + required, + }) + })?; + Ok(()) +} diff --git a/src/transport/jsonrpc/nip46/session.rs b/src/transport/jsonrpc/nip46/session.rs @@ -13,6 +13,9 @@ pub async fn get_session( } pub fn require_permission(session: &Nip46Session, perm: &str) -> Result<(), RpcError> { + if session.auth_required && !session.authorized { + return Err(auth_required_error(session)); + } if session.perms.iter().any(|entry| entry == perm) { Ok(()) } else { @@ -24,9 +27,20 @@ pub fn require_sign_event_permission( session: &Nip46Session, kind: u32, ) -> Result<(), RpcError> { + if session.auth_required && !session.authorized { + return Err(auth_required_error(session)); + } if sign_event_allowed(&session.perms, kind) { Ok(()) } else { Err(RpcError::Other(format!("unauthorized sign_event:{kind}"))) } } + +fn auth_required_error(session: &Nip46Session) -> RpcError { + let url = session + .auth_url + .as_deref() + .unwrap_or("auth required"); + RpcError::Other(format!("auth_url:{url}")) +} diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -13,7 +13,12 @@ use nostr::JsonUtil; use tokio::sync::broadcast; use tracing::{info, warn}; -use crate::core::nip46::session::{session_expires_at, sign_event_allowed, Nip46Session}; +use crate::core::nip46::session::{ + session_expires_at, + sign_event_allowed, + Nip46Session, + PendingNostrRequest, +}; use crate::core::state::Radrootsd; use radroots_nostr::prelude::{ radroots_nostr_filter_tag, @@ -97,7 +102,8 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { continue; } }; - let response = handle_request(&radrootsd, &event.pubkey, request).await; + let response = + handle_request(&radrootsd, &event.pubkey, &request_id, request).await; let response_message = NostrConnectMessage::response(request_id, response); let response_event = RadrootsNostrEventBuilder::nostr_connect( &radrootsd.keys, @@ -109,9 +115,10 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { } } -async fn handle_request( +pub(crate) async fn handle_request( radrootsd: &Radrootsd, client_pubkey: &radroots_nostr::prelude::RadrootsNostrPublicKey, + request_id: &str, request: NostrConnectRequest, ) -> NostrConnectResponse { match request { @@ -138,6 +145,10 @@ async fn handle_request( url: None, image: None, expires_at, + auth_required: false, + authorized: true, + auth_url: None, + pending_request: None, }; radrootsd.nip46_sessions.insert(session).await; NostrConnectResponse::with_result(ResponseResult::Ack) @@ -153,6 +164,17 @@ async fn handle_request( if !has_sign_event_permission(&session, u32::from(unsigned.kind.as_u16())) { return NostrConnectResponse::with_error("unauthorized sign_event"); } + if let Some(response) = auth_challenge( + radrootsd, + &session, + request_id, + client_pubkey, + NostrConnectRequest::SignEvent(unsigned.clone()), + ) + .await + { + return response; + } if unsigned.pubkey != radrootsd.pubkey { return NostrConnectResponse::with_error("pubkey mismatch"); } @@ -169,6 +191,20 @@ async fn handle_request( if !has_permission(&session, "nip04_encrypt") { return NostrConnectResponse::with_error("unauthorized nip04_encrypt"); } + if let Some(response) = auth_challenge( + radrootsd, + &session, + request_id, + client_pubkey, + NostrConnectRequest::Nip04Encrypt { + public_key: public_key.clone(), + text: text.clone(), + }, + ) + .await + { + return response; + } match nip04::encrypt(radrootsd.keys.secret_key(), &public_key, text) { Ok(ciphertext) => { NostrConnectResponse::with_result(ResponseResult::Nip04Encrypt { ciphertext }) @@ -184,6 +220,20 @@ async fn handle_request( if !has_permission(&session, "nip04_decrypt") { return NostrConnectResponse::with_error("unauthorized nip04_decrypt"); } + if let Some(response) = auth_challenge( + radrootsd, + &session, + request_id, + client_pubkey, + NostrConnectRequest::Nip04Decrypt { + public_key: public_key.clone(), + ciphertext: ciphertext.clone(), + }, + ) + .await + { + return response; + } match nip04::decrypt(radrootsd.keys.secret_key(), &public_key, ciphertext) { Ok(plaintext) => { NostrConnectResponse::with_result(ResponseResult::Nip04Decrypt { plaintext }) @@ -199,6 +249,20 @@ async fn handle_request( if !has_permission(&session, "nip44_encrypt") { return NostrConnectResponse::with_error("unauthorized nip44_encrypt"); } + if let Some(response) = auth_challenge( + radrootsd, + &session, + request_id, + client_pubkey, + NostrConnectRequest::Nip44Encrypt { + public_key: public_key.clone(), + text: text.clone(), + }, + ) + .await + { + return response; + } match nip44::encrypt(radrootsd.keys.secret_key(), &public_key, text, nip44::Version::V2) { Ok(ciphertext) => { @@ -215,6 +279,20 @@ async fn handle_request( if !has_permission(&session, "nip44_decrypt") { return NostrConnectResponse::with_error("unauthorized nip44_decrypt"); } + if let Some(response) = auth_challenge( + radrootsd, + &session, + request_id, + client_pubkey, + NostrConnectRequest::Nip44Decrypt { + public_key: public_key.clone(), + ciphertext: ciphertext.clone(), + }, + ) + .await + { + return response; + } match nip44::decrypt(radrootsd.keys.secret_key(), &public_key, ciphertext) { Ok(plaintext) => { NostrConnectResponse::with_result(ResponseResult::Nip44Decrypt { plaintext }) @@ -244,3 +322,33 @@ fn has_permission(session: &Nip46Session, perm: &str) -> bool { fn has_sign_event_permission(session: &Nip46Session, kind: u32) -> bool { sign_event_allowed(&session.perms, kind) } + +async fn auth_challenge( + radrootsd: &Radrootsd, + session: &Nip46Session, + request_id: &str, + client_pubkey: &radroots_nostr::prelude::RadrootsNostrPublicKey, + request: NostrConnectRequest, +) -> Option<NostrConnectResponse> { + if !session.auth_required || session.authorized { + return None; + } + let pending = PendingNostrRequest { + request_id: request_id.to_string(), + client_pubkey: client_pubkey.clone(), + request, + }; + let _ = radrootsd + .nip46_sessions + .set_pending_request(&session.id, pending) + .await; + let auth_url = session + .auth_url + .as_deref() + .unwrap_or("auth required") + .to_string(); + Some(NostrConnectResponse::new( + Some(ResponseResult::AuthUrl), + Some(auth_url), + )) +}