radrootsd

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

commit ec5d8d1d80f1d1137930d1435f98f4c6c342e121
parent 6069b5b131ea952154942ac5c075b3c9ab3a5f5a
Author: triesap <triesap@radroots.dev>
Date:   Tue,  6 Jan 2026 17:54:15 +0000

nip46: enforce session expiry and allowlisted permissions

- add configurable TTL to NIP-46 session settings
- prune expired sessions in the session store
- gate session perms via explicit allowlist
- wire nip46 config into runtime state and connect flows

Diffstat:
Mconfig.toml | 4++++
Msrc/app/config.rs | 27+++++++++++++++++++++++++++
Msrc/app/runtime.rs | 6+++++-
Msrc/core/nip46/session.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/core/state.rs | 14+++++++++-----
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 14+++++++++++---
Msrc/transport/jsonrpc/mod.rs | 1-
Msrc/transport/nostr/listener.rs | 18+++++-------------
8 files changed, 154 insertions(+), 24 deletions(-)

diff --git a/config.toml b/config.toml @@ -17,3 +17,7 @@ relays = [ [config.rpc] addr = "127.0.0.1:7070" + +[config.nip46] +session_ttl_secs = 900 +perms = [] diff --git a/src/app/config.rs b/src/app/config.rs @@ -25,6 +25,31 @@ fn default_message_buffer_capacity() -> u32 { 1024 } +fn default_nip46_session_ttl_secs() -> u64 { + 900 +} + +fn default_nip46_perms() -> Vec<String> { + Vec::new() +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Nip46Config { + #[serde(default = "default_nip46_session_ttl_secs")] + pub session_ttl_secs: u64, + #[serde(default = "default_nip46_perms")] + pub perms: Vec<String>, +} + +impl Default for Nip46Config { + fn default() -> Self { + Self { + session_ttl_secs: default_nip46_session_ttl_secs(), + perms: default_nip46_perms(), + } + } +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct RpcConfig { #[serde(default = "default_rpc_addr")] @@ -66,6 +91,8 @@ pub struct Configuration { pub rpc_addr: Option<String>, #[serde(default)] pub relays: Vec<String>, + #[serde(default)] + pub nip46: Nip46Config, } impl Configuration { diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -31,7 +31,11 @@ pub async fn run() -> Result<()> { args.allow_generate_identity, )?; let keys = identity.keys().clone(); - let radrootsd = Radrootsd::new(keys, settings.metadata.clone()); + let radrootsd = Radrootsd::new( + keys, + settings.metadata.clone(), + settings.config.nip46.clone(), + ); for relay in settings.config.relays.iter() { radrootsd.client.add_relay(relay).await?; diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -1,6 +1,7 @@ #![forbid(unsafe_code)] use std::collections::HashMap; +use std::time::{Duration, Instant}; use std::sync::Arc; use tokio::sync::Mutex; @@ -29,6 +30,7 @@ pub struct Nip46Session { pub name: Option<String>, pub url: Option<String>, pub image: Option<String>, + pub expires_at: Option<Instant>, } impl Nip46SessionStore { @@ -44,7 +46,15 @@ impl Nip46SessionStore { } pub async fn get(&self, session_id: &str) -> Option<Nip46Session> { - let sessions = self.inner.lock().await; + let mut sessions = self.inner.lock().await; + let expired = sessions + .get(session_id) + .map(|session| session.is_expired()) + .unwrap_or(false); + if expired { + sessions.remove(session_id); + return None; + } sessions.get(session_id).cloned() } @@ -61,6 +71,10 @@ impl Nip46SessionStore { 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.user_pubkey = Some(pubkey); true } @@ -68,3 +82,81 @@ impl Nip46SessionStore { } } } + +impl Nip46Session { + pub fn is_expired(&self) -> bool { + self.expires_at + .map(|expires_at| expires_at <= Instant::now()) + .unwrap_or(false) + } +} + +pub fn filter_perms(requested: &[String], allowed: &[String]) -> Vec<String> { + if allowed.is_empty() { + return Vec::new(); + } + requested + .iter() + .filter(|perm| allowed.iter().any(|allow| allow == *perm)) + .cloned() + .collect() +} + +pub fn session_expires_at(ttl_secs: u64) -> Option<Instant> { + if ttl_secs == 0 { + None + } else { + Some(Instant::now() + Duration::from_secs(ttl_secs)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn build_session(id: &str, expires_at: Option<Instant>) -> Nip46Session { + let keys = RadrootsNostrKeys::generate(); + let client = RadrootsNostrClient::new(keys.clone()); + let pubkey = keys.public_key(); + Nip46Session { + id: id.to_string(), + client, + client_keys: keys, + client_pubkey: pubkey, + remote_signer_pubkey: pubkey, + user_pubkey: None, + relays: Vec::new(), + perms: Vec::new(), + name: None, + url: None, + image: None, + expires_at, + } + } + + #[tokio::test] + async fn session_store_removes_expired() { + let store = Nip46SessionStore::new(); + let session = build_session( + "expired", + Some(Instant::now() - Duration::from_secs(1)), + ); + store.insert(session).await; + let found = store.get("expired").await; + assert!(found.is_none()); + let found_again = store.get("expired").await; + assert!(found_again.is_none()); + } + + #[tokio::test] + async fn session_store_keeps_active() { + let store = Nip46SessionStore::new(); + let session = build_session( + "active", + Some(Instant::now() + Duration::from_secs(60)), + ); + store.insert(session).await; + let found = store.get("active").await; + assert!(found.is_some()); + } +} diff --git a/src/core/state.rs b/src/core/state.rs @@ -1,5 +1,3 @@ -use std::time::Instant; - use radroots_nostr::prelude::{ RadrootsNostrClient, RadrootsNostrKeys, @@ -7,19 +5,25 @@ use radroots_nostr::prelude::{ RadrootsNostrPublicKey, }; +use crate::app::config::Nip46Config; + #[derive(Clone)] pub struct Radrootsd { - pub(crate) started: Instant, pub client: RadrootsNostrClient, pub keys: RadrootsNostrKeys, pub pubkey: RadrootsNostrPublicKey, pub metadata: RadrootsNostrMetadata, pub info: serde_json::Value, pub(crate) nip46_sessions: crate::core::nip46::session::Nip46SessionStore, + pub nip46_config: Nip46Config, } impl Radrootsd { - pub fn new(keys: RadrootsNostrKeys, metadata: RadrootsNostrMetadata) -> Self { + pub fn new( + keys: RadrootsNostrKeys, + metadata: RadrootsNostrMetadata, + nip46_config: Nip46Config, + ) -> Self { let pubkey = keys.public_key(); let client = RadrootsNostrClient::new(keys.clone()); let info = serde_json::json!({ @@ -29,13 +33,13 @@ impl Radrootsd { let nip46_sessions = crate::core::nip46::session::Nip46SessionStore::new(); Self { - started: Instant::now(), client, keys, pubkey, metadata, info, nip46_sessions, + nip46_config, } } } diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -7,7 +7,7 @@ use tokio::sync::broadcast; use tokio::time::sleep; use uuid::Uuid; -use crate::core::nip46::session::Nip46Session; +use crate::core::nip46::session::{filter_perms, session_expires_at, Nip46Session}; use crate::transport::jsonrpc::nip46::connection::{ parse_connect_url, Nip46ConnectInfo, @@ -118,6 +118,9 @@ async fn connect_bunker( validate_connect_response(&response, info.secret.as_deref())?; + let perms = filter_perms(&info.perms, &ctx.state.nip46_config.perms); + let expires_at = session_expires_at(ctx.state.nip46_config.session_ttl_secs); + let session_id = Uuid::new_v4().to_string(); let session = Nip46Session { id: session_id.clone(), @@ -127,10 +130,11 @@ async fn connect_bunker( remote_signer_pubkey, user_pubkey: None, relays: info.relays.clone(), - perms: info.perms.clone(), + perms, name: info.name.clone(), url: info.url.clone(), image: info.image.clone(), + expires_at, }; ctx.state.nip46_sessions.insert(session).await; @@ -190,6 +194,9 @@ async fn connect_nostrconnect( .await?; validate_nostrconnect_response(&response, secret)?; + let perms = filter_perms(&info.perms, &ctx.state.nip46_config.perms); + let expires_at = session_expires_at(ctx.state.nip46_config.session_ttl_secs); + let session_id = Uuid::new_v4().to_string(); let session = Nip46Session { id: session_id.clone(), @@ -199,10 +206,11 @@ async fn connect_nostrconnect( remote_signer_pubkey, user_pubkey: None, relays: info.relays.clone(), - perms: info.perms.clone(), + perms, name: info.name.clone(), url: info.url.clone(), image: info.image.clone(), + expires_at, }; ctx.state.nip46_sessions.insert(session).await; diff --git a/src/transport/jsonrpc/mod.rs b/src/transport/jsonrpc/mod.rs @@ -20,7 +20,6 @@ pub mod nip46; pub use context::RpcContext; pub use error::RpcError; pub use registry::MethodRegistry; -pub(crate) use params::DEFAULT_TIMEOUT_SECS; pub async fn start_rpc( state: Radrootsd, diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -13,7 +13,7 @@ use nostr::JsonUtil; use tokio::sync::broadcast; use tracing::{info, warn}; -use crate::core::nip46::session::Nip46Session; +use crate::core::nip46::session::{session_expires_at, Nip46Session}; use crate::core::state::Radrootsd; use radroots_nostr::prelude::{ radroots_nostr_filter_tag, @@ -123,6 +123,8 @@ async fn handle_request( return NostrConnectResponse::with_error("remote signer pubkey mismatch"); } let session_id = client_pubkey.to_hex(); + let expires_at = + session_expires_at(radrootsd.nip46_config.session_ttl_secs); let session = Nip46Session { id: session_id, client: radrootsd.client.clone(), @@ -131,10 +133,11 @@ async fn handle_request( remote_signer_pubkey: radrootsd.pubkey, user_pubkey: Some(radrootsd.pubkey), relays: Vec::new(), - perms: default_perms(), + perms: radrootsd.nip46_config.perms.clone(), name: None, url: None, image: None, + expires_at, }; radrootsd.nip46_sessions.insert(session).await; NostrConnectResponse::with_result(ResponseResult::Ack) @@ -220,7 +223,6 @@ async fn handle_request( } } NostrConnectRequest::Ping => NostrConnectResponse::with_result(ResponseResult::Pong), - _ => NostrConnectResponse::with_error("unsupported request"), } } @@ -238,13 +240,3 @@ async fn session_for_client( fn has_permission(session: &Nip46Session, perm: &str) -> bool { session.perms.iter().any(|entry| entry == perm) } - -fn default_perms() -> Vec<String> { - vec![ - "sign_event".to_string(), - "nip04_encrypt".to_string(), - "nip04_decrypt".to_string(), - "nip44_encrypt".to_string(), - "nip44_decrypt".to_string(), - ] -}