lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 1c016b0015c42762abd61b770789527aa1316a98
parent 85144e8d709a7d4729a9067d931386061fa73fb8
Author: triesap <tyson@radroots.org>
Date:   Fri, 31 Jul 2026 19:36:11 +0000

nostr-connect: add the server state machine

- parse bounded decrypted requests into protocol-owned server evaluations
- expose permission requirements without owning approval or persistence policy
- reject unsupported extensions, malformed inputs, and bounded replay attempts
- construct correlated redacted responses at the host signing boundary

Diffstat:
Mcrates/nostr_connect/src/error.rs | 8++++++++
Mcrates/nostr_connect/src/lib.rs | 1+
Mcrates/nostr_connect/src/server.rs | 191++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/nostr_connect/tests/package_boundary.rs | 3++-
Acrates/nostr_connect/tests/server_state_machine.rs | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_signer/src/evaluation.rs | 32+-------------------------------
6 files changed, 329 insertions(+), 37 deletions(-)

diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs @@ -20,6 +20,14 @@ pub enum RadrootsNostrConnectError { InvalidClientEvent, #[error("invalid NIP-46 client state: {reason}")] InvalidClientState { reason: &'static str }, + #[error("invalid NIP-46 server request: {reason}")] + InvalidServerRequest { reason: &'static str }, + #[error("invalid NIP-46 server state: {reason}")] + InvalidServerState { reason: &'static str }, + #[error("unsupported NIP-46 method `{0}`")] + UnsupportedMethod(crate::method::Method), + #[error("replayed NIP-46 request")] + ReplayedRequest, #[error("invalid NIP-46 request id: {reason}")] InvalidRequestId { reason: &'static str }, #[error("NIP-46 response id does not match the request")] diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -14,6 +14,7 @@ pub use error::RadrootsNostrConnectError as Error; pub use message::{Request, Response}; pub use method::Method; pub use permission::Permission; +pub use server::Server; pub use uri::{BunkerUri, ClientUri}; // Transitional compatibility surface for consumers migrated in Step 141. diff --git a/crates/nostr_connect/src/server.rs b/crates/nostr_connect/src/server.rs @@ -1,5 +1,186 @@ -//! NIP-46 server-side protocol state. -//! -//! The protocol-owned server state machine is introduced in Step 138. Host -//! approval policy, persistence, and relay execution remain outside this -//! module and outside the package charter. +//! Relay- and persistence-independent NIP-46 server state. + +use crate::error::RadrootsNostrConnectError; +use crate::message::{Request, RequestId, RequestMessage, Response, ResponseEnvelope}; +use crate::method::Method; +use crate::permission::{Permission, Permissions}; +use std::collections::BTreeSet; + +pub const SERVER_MESSAGE_MAX_BYTES: usize = 524_288; +pub const SERVER_REPLAY_WINDOW_MAX: usize = 4_096; + +/// Protocol-only server state for one bounded replay window. +/// +/// Approval UI, durable session state, encryption/signing keys, and relay +/// execution remain host responsibilities. +#[derive(Debug)] +pub struct Server { + supported_extensions: BTreeSet<Method>, + seen_request_ids: BTreeSet<RequestId>, + seen_fingerprints: BTreeSet<String>, +} + +impl Server { + #[must_use] + pub fn new() -> Self { + Self { + supported_extensions: BTreeSet::new(), + seen_request_ids: BTreeSet::new(), + seen_fingerprints: BTreeSet::new(), + } + } + + pub fn with_supported_extensions( + extensions: impl IntoIterator<Item = Method>, + ) -> Result<Self, RadrootsNostrConnectError> { + let mut server = Self::new(); + for extension in extensions { + if !matches!(extension, Method::Custom(_)) { + return Err(RadrootsNostrConnectError::InvalidServerState { + reason: "server extensions must use custom methods", + }); + } + server.supported_extensions.insert(extension); + } + Ok(server) + } + + /// Parses and admits one decrypted request after the host verifies its event. + pub fn parse( + &mut self, + fingerprint: impl Into<String>, + message_json: &str, + ) -> Result<ServerRequest, RadrootsNostrConnectError> { + if message_json.len() > SERVER_MESSAGE_MAX_BYTES { + return Err(RadrootsNostrConnectError::InvalidServerRequest { + reason: "request message exceeds its byte limit", + }); + } + let fingerprint = fingerprint.into(); + if fingerprint.is_empty() + || fingerprint.len() > 128 + || fingerprint.chars().any(char::is_control) + { + return Err(RadrootsNostrConnectError::InvalidServerRequest { + reason: "request fingerprint must be non-empty, bounded, and control-free", + }); + } + if self.seen_fingerprints.len() >= SERVER_REPLAY_WINDOW_MAX + || self.seen_request_ids.len() >= SERVER_REPLAY_WINDOW_MAX + { + return Err(RadrootsNostrConnectError::InvalidServerState { + reason: "server replay window is full", + }); + } + + let message: RequestMessage = + serde_json::from_str(message_json).map_err(RadrootsNostrConnectError::from)?; + let request_id = message.request_id()?; + let method = message.payload().method(); + if matches!(&method, Method::Custom(_)) && !self.supported_extensions.contains(&method) { + return Err(RadrootsNostrConnectError::UnsupportedMethod(method)); + } + if self.seen_fingerprints.contains(&fingerprint) + || self.seen_request_ids.contains(&request_id) + { + return Err(RadrootsNostrConnectError::ReplayedRequest); + } + self.seen_fingerprints.insert(fingerprint); + self.seen_request_ids.insert(request_id.clone()); + + Ok(ServerRequest { + request_id, + required_permission: required_permission(message.payload()), + request: message.request, + }) + } +} + +impl Default for Server { + fn default() -> Self { + Self::new() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServerRequest { + request_id: RequestId, + request: Request, + required_permission: Option<Permission>, +} + +impl ServerRequest { + #[must_use] + pub fn request_id(&self) -> &RequestId { + &self.request_id + } + + #[must_use] + pub fn request(&self) -> &Request { + &self.request + } + + #[must_use] + pub fn required_permission(&self) -> Option<&Permission> { + self.required_permission.as_ref() + } + + #[must_use] + pub fn is_allowed_by(&self, granted: &Permissions) -> bool { + self.required_permission.as_ref().is_none_or(|permission| { + granted.allows_request(permission.method(), permission.parameter()) + }) + } + + /// Constructs a correlated plaintext response for host encryption/signing. + pub fn respond(self, response: Response) -> Result<ServerResponse, RadrootsNostrConnectError> { + let envelope = response.into_envelope(self.request_id.to_string())?; + let json = serde_json::to_string(&envelope).map_err(RadrootsNostrConnectError::from)?; + Ok(ServerResponse { envelope, json }) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ServerResponse { + envelope: ResponseEnvelope, + json: String, +} + +impl ServerResponse { + #[must_use] + pub fn envelope(&self) -> &ResponseEnvelope { + &self.envelope + } + + #[must_use] + pub fn as_json(&self) -> &str { + &self.json + } +} + +impl std::fmt::Debug for ServerResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ServerResponse(<redacted>)") + } +} + +#[must_use] +pub fn required_permission(request: &Request) -> Option<Permission> { + match request { + Request::Connect { .. } + | Request::GetPublicKey + | Request::GetSessionCapability + | Request::Ping + | Request::Logout => None, + Request::SignEvent(event) => Some(Permission::with_parameter( + Method::SignEvent, + format!("kind:{}", event.kind()), + )), + Request::Nip04Encrypt { .. } => Some(Permission::new(Method::Nip04Encrypt)), + Request::Nip04Decrypt { .. } => Some(Permission::new(Method::Nip04Decrypt)), + Request::Nip44Encrypt { .. } => Some(Permission::new(Method::Nip44Encrypt)), + Request::Nip44Decrypt { .. } => Some(Permission::new(Method::Nip44Decrypt)), + Request::SwitchRelays => Some(Permission::new(Method::SwitchRelays)), + Request::Custom { method, .. } => Some(Permission::new(method.clone())), + } +} diff --git a/crates/nostr_connect/tests/package_boundary.rs b/crates/nostr_connect/tests/package_boundary.rs @@ -62,7 +62,8 @@ fn crate_root_contains_the_approved_module_skeleton() { ); } - assert!(SERVER.starts_with("//! NIP-46 server-side protocol state.")); + assert!(SERVER.starts_with("//! Relay- and persistence-independent NIP-46 server state.")); + assert!(ROOT.contains("pub use server::Server;")); assert!(ROOT.contains("#[doc(hidden)]\npub mod prelude")); assert!( ROOT.contains("Step 143 removes this module"), diff --git a/crates/nostr_connect/tests/server_state_machine.rs b/crates/nostr_connect/tests/server_state_machine.rs @@ -0,0 +1,131 @@ +use radroots_nostr_connect::message::{RequestId, RequestMessage}; +use radroots_nostr_connect::permission::{Permission, Permissions}; +use radroots_nostr_connect::{Error, Method, Request, Response, Server}; +use std::str::FromStr; + +fn request_json(id: &str, request: Request) -> String { + serde_json::to_string(&RequestMessage::try_new(id, request).expect("request")) + .expect("request JSON") +} + +#[test] +fn server_exposes_permission_evaluation_without_owning_policy() { + let mut server = Server::new(); + let request = server + .parse( + "event-permission", + &request_json( + "request-permission", + Request::Nip44Encrypt { + public_key: radroots_identity::PublicKey::from_hex( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .expect("public key"), + plaintext: "payload".to_owned(), + }, + ), + ) + .expect("server request"); + assert_eq!( + request.required_permission(), + Some(&Permission::new(Method::Nip44Encrypt)) + ); + assert!(!request.is_allowed_by(&Permissions::new())); + assert!( + request.is_allowed_by( + &Permissions::try_from_vec(vec![Permission::new(Method::Nip44Encrypt)]) + .expect("permissions") + ) + ); +} + +#[test] +fn server_rejects_unsupported_extensions_and_malformed_requests() { + let mut server = Server::new(); + let extension = Method::from_str("vendor_action").expect("extension"); + assert_eq!( + server + .parse( + "event-extension", + &request_json( + "request-extension", + Request::Custom { + method: extension.clone(), + params: Vec::new(), + }, + ), + ) + .expect_err("unsupported extension"), + Error::UnsupportedMethod(extension) + ); + assert!(matches!( + server.parse("event-malformed", "not JSON"), + Err(Error::Json(_)) + )); +} + +#[test] +fn configured_extension_is_admitted_with_a_permission_input() { + let extension = Method::from_str("vendor_action").expect("extension"); + let mut server = Server::with_supported_extensions([extension.clone()]).expect("server"); + let request = server + .parse( + "event-extension", + &request_json( + "request-extension", + Request::Custom { + method: extension.clone(), + params: Vec::new(), + }, + ), + ) + .expect("extension request"); + assert_eq!( + request.required_permission(), + Some(&Permission::new(extension)) + ); +} + +#[test] +fn server_constructs_correlated_plaintext_for_host_signing() { + let mut server = Server::new(); + let request = server + .parse( + "event-response", + &request_json("request-response", Request::Ping), + ) + .expect("request"); + assert_eq!( + request.request_id(), + &RequestId::parse("request-response").expect("request id") + ); + let response = request.respond(Response::Pong).expect("response"); + assert_eq!( + response.envelope().request_id().expect("response id"), + RequestId::parse("request-response").expect("request id") + ); + assert!(response.as_json().contains("\"pong\"")); + assert_eq!(format!("{response:?}"), "ServerResponse(<redacted>)"); +} + +#[test] +fn server_rejects_fingerprint_and_request_id_replays() { + let mut server = Server::new(); + let first = request_json("request-replay", Request::Ping); + server.parse("event-replay", &first).expect("first request"); + assert_eq!( + server + .parse( + "event-replay", + &request_json("request-other", Request::Ping) + ) + .expect_err("fingerprint replay"), + Error::ReplayedRequest + ); + assert_eq!( + server + .parse("event-other", &first) + .expect_err("request id replay"), + Error::ReplayedRequest + ); +} diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -107,37 +107,7 @@ impl RadrootsNostrSignerRequestAction { pub(crate) fn required_permission_for_request( request: &RadrootsNostrConnectRequest, ) -> Option<RadrootsNostrConnectPermission> { - match request { - RadrootsNostrConnectRequest::Connect { .. } - | RadrootsNostrConnectRequest::GetPublicKey - | RadrootsNostrConnectRequest::GetSessionCapability - | RadrootsNostrConnectRequest::Ping - | RadrootsNostrConnectRequest::Logout => None, - RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { - Some(RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - format!("kind:{}", unsigned_event.kind()), - )) - } - RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some( - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), - ), - RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some( - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt), - ), - RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some( - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), - ), - RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some( - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt), - ), - RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new( - RadrootsNostrConnectMethod::SwitchRelays, - )), - RadrootsNostrConnectRequest::Custom { method, .. } => { - Some(RadrootsNostrConnectPermission::new(method.clone())) - } - } + radroots_nostr_connect::server::required_permission(request) } pub(crate) fn request_allowed_by_permissions(