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:
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(