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:
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, ®istry)?;
session_status::register(&mut m, ®istry)?;
session_close::register(&mut m, ®istry)?;
+ session_authorize::register(&mut m, ®istry)?;
+ session_require_auth::register(&mut m, ®istry)?;
session_list::register(&mut m, ®istry)?;
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),
+ ))
+}