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:
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(),
- ]
-}