radrootsd

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

commit dc1453dfeed86140c283b7d7b282ebda0651d8da
parent 4d65e37568b074d8de5ae3af14e3449c68198baa
Author: triesap <triesap@radroots.dev>
Date:   Wed,  7 Jan 2026 16:11:22 +0000

nip46: enforce sign_event kind permissions

- allow sign_event:<kind> permissions through filtering
- add kind-scoped sign_event checks for jsonrpc and nostr listener
- add core unit coverage for permission parsing and kind gating
- constrain dev config to sign_event:1 for scoped tests

Diffstat:
Msrc/core/nip46/session.rs | 46++++++++++++++++++++++++++++++++++++++++++++--
Msrc/transport/jsonrpc/methods/nip46/sign_event.rs | 5++++-
Msrc/transport/jsonrpc/nip46/session.rs | 13++++++++++++-
Msrc/transport/nostr/listener.rs | 8++++++--
4 files changed, 66 insertions(+), 6 deletions(-)

diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -103,13 +103,29 @@ pub fn filter_perms(requested: &[String], allowed: &[String]) -> Vec<String> { if allowed.is_empty() { return Vec::new(); } + let allows_sign_event = allowed.iter().any(|entry| entry == "sign_event"); requested .iter() - .filter(|perm| allowed.iter().any(|allow| allow == *perm)) - .cloned() + .filter_map(|perm| { + if allowed.iter().any(|allow| allow == perm) { + return Some(perm.clone()); + } + if allows_sign_event && perm.starts_with("sign_event:") { + return Some(perm.clone()); + } + None + }) .collect() } +pub fn sign_event_allowed(perms: &[String], kind: u32) -> bool { + if perms.iter().any(|entry| entry == "sign_event") { + return true; + } + let entry = format!("sign_event:{kind}"); + perms.iter().any(|perm| perm == &entry) +} + pub fn session_expires_at(ttl_secs: u64) -> Option<Instant> { if ttl_secs == 0 { None @@ -187,4 +203,30 @@ mod tests { assert_eq!(listed.len(), 1); assert_eq!(listed[0].id, "active"); } + + #[test] + fn filter_perms_allows_sign_event_kinds() { + let requested = vec![ + "sign_event:1".to_string(), + "sign_event:4".to_string(), + "nip04_encrypt".to_string(), + ]; + let allowed = vec!["sign_event".to_string(), "nip04_encrypt".to_string()]; + let filtered = filter_perms(&requested, &allowed); + assert_eq!( + filtered, + vec![ + "sign_event:1".to_string(), + "sign_event:4".to_string(), + "nip04_encrypt".to_string() + ] + ); + } + + #[test] + fn sign_event_allowed_respects_kinds() { + let perms = vec!["sign_event:1".to_string()]; + assert!(sign_event_allowed(&perms, 1)); + assert!(!sign_event_allowed(&perms, 3)); + } } diff --git a/src/transport/jsonrpc/methods/nip46/sign_event.rs b/src/transport/jsonrpc/methods/nip46/sign_event.rs @@ -24,7 +24,10 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res .parse() .map_err(|e| RpcError::InvalidParams(e.to_string()))?; let session = session::get_session(ctx.as_ref(), &session_id).await?; - session::require_permission(&session, "sign_event")?; + session::require_sign_event_permission( + &session, + u32::from(event.kind.as_u16()), + )?; if event.pubkey != session.remote_signer_pubkey { return Err(RpcError::InvalidParams( "event pubkey does not match remote signer".to_string(), diff --git a/src/transport/jsonrpc/nip46/session.rs b/src/transport/jsonrpc/nip46/session.rs @@ -1,4 +1,4 @@ -use crate::core::nip46::session::Nip46Session; +use crate::core::nip46::session::{sign_event_allowed, Nip46Session}; use crate::transport::jsonrpc::{RpcContext, RpcError}; pub async fn get_session( @@ -19,3 +19,14 @@ pub fn require_permission(session: &Nip46Session, perm: &str) -> Result<(), RpcE Err(RpcError::Other(format!("unauthorized {perm}"))) } } + +pub fn require_sign_event_permission( + session: &Nip46Session, + kind: u32, +) -> Result<(), RpcError> { + if sign_event_allowed(&session.perms, kind) { + Ok(()) + } else { + Err(RpcError::Other(format!("unauthorized sign_event:{kind}"))) + } +} 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::{session_expires_at, Nip46Session}; +use crate::core::nip46::session::{session_expires_at, sign_event_allowed, Nip46Session}; use crate::core::state::Radrootsd; use radroots_nostr::prelude::{ radroots_nostr_filter_tag, @@ -150,7 +150,7 @@ async fn handle_request( Ok(session) => session, Err(response) => return response, }; - if !has_permission(&session, "sign_event") { + if !has_sign_event_permission(&session, u32::from(unsigned.kind.as_u16())) { return NostrConnectResponse::with_error("unauthorized sign_event"); } if unsigned.pubkey != radrootsd.pubkey { @@ -240,3 +240,7 @@ async fn session_for_client( fn has_permission(session: &Nip46Session, perm: &str) -> bool { session.perms.iter().any(|entry| entry == perm) } + +fn has_sign_event_permission(session: &Nip46Session, kind: u32) -> bool { + sign_event_allowed(&session.perms, kind) +}