lib

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

commit 853ef12beecd5d700336e2998b6382ba91df1838
parent 19521ad129c89393401eba44f27d415d2b7308e1
Author: triesap <tyson@radroots.org>
Date:   Fri, 31 Jul 2026 17:22:57 +0000

nostr-connect: normalize URI, method, and permission types

- adopt concise module-owned NIP-46 type names and canonical root exports
- bound and canonicalize custom methods, permissions, metadata, relays, and secrets
- use identity-owned public keys while keeping relay representation package-private
- add protocol regressions and repair the isolated signer feature declaration

Diffstat:
Mcrates/nostr_connect/src/client.rs | 6+++---
Mcrates/nostr_connect/src/lib.rs | 30++++++++++++++++++++++++------
Mcrates/nostr_connect/src/message.rs | 137+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mcrates/nostr_connect/src/method.rs | 55++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/nostr_connect/src/permission.rs | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/nostr_connect/src/uri.rs | 532++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mcrates/nostr_connect/tests/client.rs | 49+++++++++++++------------------------------------
Mcrates/nostr_connect/tests/coverage.rs | 473+++++++++++++++++++++++++++++++------------------------------------------------
Mcrates/nostr_connect/tests/package_boundary.rs | 32++++++++++++++++++++++++++++++++
Mcrates/nostr_connect/tests/protocol.rs | 156++++++++++++++++++++++++++++++++++++++------------------------------------------
Acrates/nostr_connect/tests/type_contract.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_signer/Cargo.toml | 2+-
Mcrates/nostr_signer/src/evaluation.rs | 12+++++++-----
Mcrates/nostr_signer/src/nip46.rs | 6++++--
14 files changed, 1131 insertions(+), 670 deletions(-)

diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs @@ -4,7 +4,7 @@ use crate::message::{ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, }; -use crate::method::RadrootsNostrConnectMethod; +use crate::method::Method; use nostr::nips::nip44::{self, Version}; use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag}; use std::future::Future; @@ -42,7 +42,7 @@ impl RadrootsNostrConnectClientRequest { } } - pub fn method(&self) -> RadrootsNostrConnectMethod { + pub fn method(&self) -> Method { self.request.method() } @@ -98,7 +98,7 @@ pub fn parse_response_event( client_keys: &Keys, target: &RadrootsNostrConnectClientTarget, request_id: &str, - method: &RadrootsNostrConnectMethod, + method: &Method, event: &Event, ) -> Result<RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectError> { if event.kind != Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -9,6 +9,11 @@ pub mod permission; pub mod server; pub mod uri; +pub use error::RadrootsNostrConnectError as Error; +pub use method::Method; +pub use permission::Permission; +pub use uri::{BunkerUri, ClientUri}; + // Transitional compatibility surface for consumers migrated in Step 141. // Publication remains disabled, and Step 143 removes this module. #[doc(hidden)] @@ -27,12 +32,25 @@ pub mod prelude { RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, }; - pub use crate::method::RadrootsNostrConnectMethod; - pub use crate::permission::{RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions}; + pub use crate::method::Method; + pub use crate::method::Method as RadrootsNostrConnectMethod; + pub use crate::permission::{Permission, Permissions}; + pub use crate::permission::{ + Permission as RadrootsNostrConnectPermission, + Permissions as RadrootsNostrConnectPermissions, + }; + pub use crate::uri::{ + BUNKER_URI_SCHEME, BunkerUri, CLIENT_METADATA_JSON_MAX_BYTES, CLIENT_NAME_MAX_BYTES, + CLIENT_URL_MAX_BYTES, ClientMetadata, ClientUri, URI_SCHEME, Uri, + }; pub use crate::uri::{ - RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES, - RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES, RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, - RadrootsNostrConnectBunkerUri, RadrootsNostrConnectClientMetadata, - RadrootsNostrConnectClientUri, RadrootsNostrConnectUri, + BUNKER_URI_SCHEME as RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME, + BunkerUri as RadrootsNostrConnectBunkerUri, + CLIENT_METADATA_JSON_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES, + CLIENT_NAME_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES, + CLIENT_URL_MAX_BYTES as RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, + ClientMetadata as RadrootsNostrConnectClientMetadata, + ClientUri as RadrootsNostrConnectClientUri, + URI_SCHEME as RADROOTS_NOSTR_CONNECT_URI_SCHEME, Uri as RadrootsNostrConnectUri, }; } diff --git a/crates/nostr_connect/src/message.rs b/crates/nostr_connect/src/message.rs @@ -1,7 +1,7 @@ use crate::error::RadrootsNostrConnectError; -use crate::method::RadrootsNostrConnectMethod; -use crate::permission::RadrootsNostrConnectPermissions; -use crate::uri::RadrootsNostrConnectClientMetadata; +use crate::method::Method; +use crate::permission::Permissions; +use crate::uri::ClientMetadata; use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; @@ -14,7 +14,7 @@ pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133; pub struct RadrootsNostrConnectRemoteSessionCapability { pub user_public_key: PublicKey, pub relays: Vec<RelayUrl>, - pub permissions: RadrootsNostrConnectPermissions, + pub permissions: Permissions, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -22,8 +22,8 @@ pub enum RadrootsNostrConnectRequest { Connect { remote_signer_public_key: PublicKey, secret: Option<String>, - requested_permissions: RadrootsNostrConnectPermissions, - client_metadata: Option<RadrootsNostrConnectClientMetadata>, + requested_permissions: Permissions, + client_metadata: Option<ClientMetadata>, }, GetPublicKey, GetSessionCapability, @@ -48,25 +48,25 @@ pub enum RadrootsNostrConnectRequest { SwitchRelays, Logout, Custom { - method: RadrootsNostrConnectMethod, + method: Method, params: Vec<String>, }, } impl RadrootsNostrConnectRequest { - pub fn method(&self) -> RadrootsNostrConnectMethod { + pub fn method(&self) -> Method { match self { - Self::Connect { .. } => RadrootsNostrConnectMethod::Connect, - Self::GetPublicKey => RadrootsNostrConnectMethod::GetPublicKey, - Self::GetSessionCapability => RadrootsNostrConnectMethod::GetSessionCapability, - Self::SignEvent(_) => RadrootsNostrConnectMethod::SignEvent, - Self::Nip04Encrypt { .. } => RadrootsNostrConnectMethod::Nip04Encrypt, - Self::Nip04Decrypt { .. } => RadrootsNostrConnectMethod::Nip04Decrypt, - Self::Nip44Encrypt { .. } => RadrootsNostrConnectMethod::Nip44Encrypt, - Self::Nip44Decrypt { .. } => RadrootsNostrConnectMethod::Nip44Decrypt, - Self::Ping => RadrootsNostrConnectMethod::Ping, - Self::SwitchRelays => RadrootsNostrConnectMethod::SwitchRelays, - Self::Logout => RadrootsNostrConnectMethod::Logout, + Self::Connect { .. } => Method::Connect, + Self::GetPublicKey => Method::GetPublicKey, + Self::GetSessionCapability => Method::GetSessionCapability, + Self::SignEvent(_) => Method::SignEvent, + Self::Nip04Encrypt { .. } => Method::Nip04Encrypt, + Self::Nip04Decrypt { .. } => Method::Nip04Decrypt, + Self::Nip44Encrypt { .. } => Method::Nip44Encrypt, + Self::Nip44Decrypt { .. } => Method::Nip44Decrypt, + Self::Ping => Method::Ping, + Self::SwitchRelays => Method::SwitchRelays, + Self::Logout => Method::Logout, Self::Custom { method, .. } => method.clone(), } } @@ -123,11 +123,11 @@ impl RadrootsNostrConnectRequest { } pub fn from_parts( - method: RadrootsNostrConnectMethod, + method: Method, params: Vec<String>, ) -> Result<Self, RadrootsNostrConnectError> { match method { - RadrootsNostrConnectMethod::Connect => { + Method::Connect => { if params.is_empty() || params.len() > 4 { return Err(RadrootsNostrConnectError::InvalidParams { method: method.to_string(), @@ -138,12 +138,12 @@ impl RadrootsNostrConnectRequest { let remote_signer_public_key = parse_public_key(&params[0])?; let secret = params.get(1).cloned().filter(|value| !value.is_empty()); let requested_permissions = match params.get(2) { - Some(value) => RadrootsNostrConnectPermissions::from_str(value)?, - None => RadrootsNostrConnectPermissions::default(), + Some(value) => Permissions::from_str(value)?, + None => Permissions::default(), }; let client_metadata = params .get(3) - .map(|value| RadrootsNostrConnectClientMetadata::from_connect_param(value)) + .map(|value| ClientMetadata::from_connect_param(value)) .transpose()?; Ok(Self::Connect { remote_signer_public_key, @@ -152,15 +152,15 @@ impl RadrootsNostrConnectRequest { client_metadata, }) } - RadrootsNostrConnectMethod::GetPublicKey => { + Method::GetPublicKey => { expect_param_count(&method, &params, 0)?; Ok(Self::GetPublicKey) } - RadrootsNostrConnectMethod::GetSessionCapability => { + Method::GetSessionCapability => { expect_param_count(&method, &params, 0)?; Ok(Self::GetSessionCapability) } - RadrootsNostrConnectMethod::SignEvent => { + Method::SignEvent => { expect_param_count(&method, &params, 1)?; let unsigned_event = serde_json::from_str(&params[0]).map_err(|error| { RadrootsNostrConnectError::InvalidRequestPayload { @@ -170,43 +170,43 @@ impl RadrootsNostrConnectRequest { })?; Ok(Self::SignEvent(unsigned_event)) } - RadrootsNostrConnectMethod::Nip04Encrypt => { + Method::Nip04Encrypt => { expect_param_count(&method, &params, 2)?; Ok(Self::Nip04Encrypt { public_key: parse_public_key(&params[0])?, plaintext: params[1].clone(), }) } - RadrootsNostrConnectMethod::Nip04Decrypt => { + Method::Nip04Decrypt => { expect_param_count(&method, &params, 2)?; Ok(Self::Nip04Decrypt { public_key: parse_public_key(&params[0])?, ciphertext: params[1].clone(), }) } - RadrootsNostrConnectMethod::Nip44Encrypt => { + Method::Nip44Encrypt => { expect_param_count(&method, &params, 2)?; Ok(Self::Nip44Encrypt { public_key: parse_public_key(&params[0])?, plaintext: params[1].clone(), }) } - RadrootsNostrConnectMethod::Nip44Decrypt => { + Method::Nip44Decrypt => { expect_param_count(&method, &params, 2)?; Ok(Self::Nip44Decrypt { public_key: parse_public_key(&params[0])?, ciphertext: params[1].clone(), }) } - RadrootsNostrConnectMethod::Ping => { + Method::Ping => { expect_param_count(&method, &params, 0)?; Ok(Self::Ping) } - RadrootsNostrConnectMethod::SwitchRelays => { + Method::SwitchRelays => { expect_param_count(&method, &params, 0)?; Ok(Self::SwitchRelays) } - RadrootsNostrConnectMethod::Logout => { + Method::Logout => { expect_param_count(&method, &params, 0)?; Ok(Self::Logout) } @@ -439,7 +439,7 @@ impl RadrootsNostrConnectResponse { } pub fn from_envelope( - method: &RadrootsNostrConnectMethod, + method: &Method, envelope: RadrootsNostrConnectResponseEnvelope, ) -> Result<Self, RadrootsNostrConnectError> { if let (Some(Value::String(result)), Some(url)) = (&envelope.result, &envelope.error) @@ -449,16 +449,13 @@ impl RadrootsNostrConnectResponse { } if let Some(error) = envelope.error { - if matches!( - method, - RadrootsNostrConnectMethod::GetPublicKey - | RadrootsNostrConnectMethod::GetSessionCapability - ) && envelope.result.is_none() + if matches!(method, Method::GetPublicKey | Method::GetSessionCapability) + && envelope.result.is_none() && error == RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR { return Ok(Self::PendingConnection); } - if let RadrootsNostrConnectMethod::Custom(_) = method { + if let Method::Custom(_) = method { return Ok(Self::Custom { result: envelope.result, error: Some(error), @@ -471,7 +468,7 @@ impl RadrootsNostrConnectResponse { } match method { - RadrootsNostrConnectMethod::Connect => { + Method::Connect => { let result = expect_string_result(method, envelope.result)?; if result == "ack" { Ok(Self::ConnectAcknowledged) @@ -479,19 +476,19 @@ impl RadrootsNostrConnectResponse { Ok(Self::ConnectSecretEcho(result)) } } - RadrootsNostrConnectMethod::GetPublicKey => { + Method::GetPublicKey => { let result = expect_string_result(method, envelope.result)?; Ok(Self::UserPublicKey(parse_public_key(&result)?)) } - RadrootsNostrConnectMethod::GetSessionCapability => { + Method::GetSessionCapability => { let capability = parse_json_string_result(method, envelope.result)?; Ok(Self::RemoteSessionCapability(capability)) } - RadrootsNostrConnectMethod::SignEvent => { + Method::SignEvent => { let event = parse_json_string_result::<Event>(method, envelope.result)?; Ok(Self::SignedEvent(event)) } - RadrootsNostrConnectMethod::Ping => { + Method::Ping => { let result = expect_string_result(method, envelope.result)?; if result != "pong" { return Err(RadrootsNostrConnectError::InvalidResponsePayload { @@ -501,22 +498,24 @@ impl RadrootsNostrConnectResponse { } Ok(Self::Pong) } - RadrootsNostrConnectMethod::Nip04Encrypt => Ok(Self::Nip04Encrypt( - expect_string_result(method, envelope.result)?, - )), - RadrootsNostrConnectMethod::Nip04Decrypt => Ok(Self::Nip04Decrypt( - expect_string_result(method, envelope.result)?, - )), - RadrootsNostrConnectMethod::Nip44Encrypt => Ok(Self::Nip44Encrypt( - expect_string_result(method, envelope.result)?, - )), - RadrootsNostrConnectMethod::Nip44Decrypt => Ok(Self::Nip44Decrypt( - expect_string_result(method, envelope.result)?, - )), - RadrootsNostrConnectMethod::SwitchRelays => { - parse_switch_relays_response(envelope.result) - } - RadrootsNostrConnectMethod::Logout => { + Method::Nip04Encrypt => Ok(Self::Nip04Encrypt(expect_string_result( + method, + envelope.result, + )?)), + Method::Nip04Decrypt => Ok(Self::Nip04Decrypt(expect_string_result( + method, + envelope.result, + )?)), + Method::Nip44Encrypt => Ok(Self::Nip44Encrypt(expect_string_result( + method, + envelope.result, + )?)), + Method::Nip44Decrypt => Ok(Self::Nip44Decrypt(expect_string_result( + method, + envelope.result, + )?)), + Method::SwitchRelays => parse_switch_relays_response(envelope.result), + Method::Logout => { let result = expect_string_result(method, envelope.result)?; if result != "ack" { return Err(RadrootsNostrConnectError::InvalidResponsePayload { @@ -526,7 +525,7 @@ impl RadrootsNostrConnectResponse { } Ok(Self::LogoutAcknowledged) } - RadrootsNostrConnectMethod::Custom(_) => Ok(Self::Custom { + Method::Custom(_) => Ok(Self::Custom { result: envelope.result, error: None, }), @@ -547,12 +546,12 @@ fn remote_session_capability_value( #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct RawRequestMessage { id: String, - method: RadrootsNostrConnectMethod, + method: Method, params: Vec<String>, } fn expect_param_count( - method: &RadrootsNostrConnectMethod, + method: &Method, params: &[String], expected: usize, ) -> Result<(), RadrootsNostrConnectError> { @@ -583,7 +582,7 @@ fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> } fn expect_string_result( - method: &RadrootsNostrConnectMethod, + method: &Method, result: Option<Value>, ) -> Result<String, RadrootsNostrConnectError> { match result { @@ -597,7 +596,7 @@ fn expect_string_result( } fn parse_json_string_result<T>( - method: &RadrootsNostrConnectMethod, + method: &Method, result: Option<Value>, ) -> Result<T, RadrootsNostrConnectError> where @@ -623,7 +622,7 @@ where fn parse_switch_relays_response( result: Option<Value>, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> { - let method = RadrootsNostrConnectMethod::SwitchRelays; + let method = Method::SwitchRelays; match result { None | Some(Value::Null) => Ok(RadrootsNostrConnectResponse::RelayListUnchanged), Some(Value::Array(values)) => { @@ -660,7 +659,7 @@ fn parse_relay_values(values: Vec<Value>) -> Result<Vec<RelayUrl>, RadrootsNostr } }), other => Err(RadrootsNostrConnectError::InvalidResponsePayload { - method: RadrootsNostrConnectMethod::SwitchRelays.to_string(), + method: Method::SwitchRelays.to_string(), reason: format!("expected relay string, got {other}"), }), }) diff --git a/crates/nostr_connect/src/method.rs b/crates/nostr_connect/src/method.rs @@ -3,8 +3,27 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use std::str::FromStr; +/// Maximum UTF-8 byte length of a NIP-46 method identifier. +pub const METHOD_MAX_BYTES: usize = 64; + +/// A validated extension method identifier. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum RadrootsNostrConnectMethod { +pub struct CustomMethod(String); + +impl CustomMethod { + fn new(value: String) -> Result<Self, RadrootsNostrConnectError> { + validate_custom_method(&value)?; + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Method { Connect, GetPublicKey, GetSessionCapability, @@ -16,10 +35,17 @@ pub enum RadrootsNostrConnectMethod { Ping, SwitchRelays, Logout, - Custom(String), + Custom(CustomMethod), } -impl RadrootsNostrConnectMethod { +impl Method { + /// Creates a bounded custom method identifier. + pub fn custom(value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> { + CustomMethod::new(value.into()).map(Self::Custom) + } + + /// Returns the canonical wire identifier. + #[must_use] pub fn as_str(&self) -> &str { match self { Self::Connect => "connect", @@ -38,13 +64,13 @@ impl RadrootsNostrConnectMethod { } } -impl fmt::Display for RadrootsNostrConnectMethod { +impl fmt::Display for Method { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } } -impl FromStr for RadrootsNostrConnectMethod { +impl FromStr for Method { type Err = RadrootsNostrConnectError; fn from_str(value: &str) -> Result<Self, Self::Err> { @@ -60,13 +86,12 @@ impl FromStr for RadrootsNostrConnectMethod { "ping" => Ok(Self::Ping), "switch_relays" => Ok(Self::SwitchRelays), "logout" => Ok(Self::Logout), - other if !other.trim().is_empty() => Ok(Self::Custom(other.to_owned())), - _ => Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned())), + other => Self::custom(other), } } } -impl Serialize for RadrootsNostrConnectMethod { +impl Serialize for Method { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, @@ -75,7 +100,7 @@ impl Serialize for RadrootsNostrConnectMethod { } } -impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod { +impl<'de> Deserialize<'de> for Method { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -84,3 +109,15 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectMethod { Self::from_str(&value).map_err(serde::de::Error::custom) } } + +fn validate_custom_method(value: &str) -> Result<(), RadrootsNostrConnectError> { + if value.is_empty() + || value.len() > METHOD_MAX_BYTES + || !value + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_') + { + return Err(RadrootsNostrConnectError::InvalidMethod(value.to_owned())); + } + Ok(()) +} diff --git a/crates/nostr_connect/src/permission.rs b/crates/nostr_connect/src/permission.rs @@ -1,44 +1,75 @@ use crate::error::RadrootsNostrConnectError; -use crate::method::RadrootsNostrConnectMethod; +use crate::method::Method; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::fmt; use std::str::FromStr; +/// Maximum UTF-8 byte length of one permission parameter. +pub const PERMISSION_PARAMETER_MAX_BYTES: usize = 64; +/// Maximum number of permissions accepted from one wire value. +pub const PERMISSION_COUNT_MAX: usize = 64; +/// Maximum UTF-8 byte length of the comma-separated permission wire value. +pub const PERMISSIONS_MAX_BYTES: usize = 4_096; + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsNostrConnectPermission { - pub method: RadrootsNostrConnectMethod, +pub struct Permission { + #[doc(hidden)] + pub method: Method, + #[doc(hidden)] pub parameter: Option<String>, } -impl RadrootsNostrConnectPermission { - pub fn new(method: RadrootsNostrConnectMethod) -> Self { +impl Permission { + #[must_use] + pub fn new(method: Method) -> Self { Self { method, parameter: None, } } - pub fn with_parameter( - method: RadrootsNostrConnectMethod, + /// Creates a permission with a bounded canonical parameter. + pub fn try_with_parameter( + method: Method, parameter: impl Into<String>, - ) -> Self { + ) -> Result<Self, RadrootsNostrConnectError> { + let parameter = parameter.into(); + validate_parameter(&parameter)?; + Ok(Self { + method, + parameter: Some(parameter), + }) + } + + /// Compatibility constructor retained until the Step 141 consumer cutover. + #[doc(hidden)] + #[must_use] + pub fn with_parameter(method: Method, parameter: impl Into<String>) -> Self { Self { method, parameter: Some(parameter.into()), } } - pub fn matches_request( - &self, - method: &RadrootsNostrConnectMethod, - parameter: Option<&str>, - ) -> bool { + /// Returns the permission method. + #[must_use] + pub fn method(&self) -> &Method { + &self.method + } + + /// Returns the optional method-specific parameter. + #[must_use] + pub fn parameter(&self) -> Option<&str> { + self.parameter.as_deref() + } + + pub fn matches_request(&self, method: &Method, parameter: Option<&str>) -> bool { if self.method != *method { return false; } match (&self.method, self.parameter.as_deref(), parameter) { - (RadrootsNostrConnectMethod::SignEvent, None, _) => true, - (RadrootsNostrConnectMethod::SignEvent, Some(configured), Some(requested)) => { + (Method::SignEvent, None, _) => true, + (Method::SignEvent, Some(configured), Some(requested)) => { match ( sign_event_kind_parameter(configured), sign_event_kind_parameter(requested), @@ -55,14 +86,11 @@ impl RadrootsNostrConnectPermission { pub fn matches_sign_event_kind(&self, event_kind: u32) -> bool { let event_kind = event_kind.to_string(); - self.matches_request( - &RadrootsNostrConnectMethod::SignEvent, - Some(event_kind.as_str()), - ) + self.matches_request(&Method::SignEvent, Some(event_kind.as_str())) } } -impl fmt::Display for RadrootsNostrConnectPermission { +impl fmt::Display for Permission { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self.parameter.as_deref() { Some(parameter) => write!(f, "{}:{parameter}", self.method), @@ -71,7 +99,7 @@ impl fmt::Display for RadrootsNostrConnectPermission { } } -impl FromStr for RadrootsNostrConnectPermission { +impl FromStr for Permission { type Err = RadrootsNostrConnectError; fn from_str(value: &str) -> Result<Self, Self::Err> { @@ -92,26 +120,27 @@ impl FromStr for RadrootsNostrConnectPermission { None => (trimmed, None), }; - Ok(Self { - method: RadrootsNostrConnectMethod::from_str(method)?, - parameter: parameter.map(ToOwned::to_owned), - }) + let method = Method::from_str(method)?; + match parameter { + Some(parameter) => Self::try_with_parameter(method, parameter), + None => Ok(Self::new(method)), + } } } #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RadrootsNostrConnectPermissions(Vec<RadrootsNostrConnectPermission>); +pub struct Permissions(Vec<Permission>); -impl RadrootsNostrConnectPermissions { +impl Permissions { pub fn new() -> Self { Self::default() } - pub fn as_slice(&self) -> &[RadrootsNostrConnectPermission] { + pub fn as_slice(&self) -> &[Permission] { self.0.as_slice() } - pub fn into_vec(self) -> Vec<RadrootsNostrConnectPermission> { + pub fn into_vec(self) -> Vec<Permission> { self.0 } @@ -119,11 +148,14 @@ impl RadrootsNostrConnectPermissions { self.0.is_empty() } - pub fn allows_request( - &self, - method: &RadrootsNostrConnectMethod, - parameter: Option<&str>, - ) -> bool { + /// Validates and canonicalizes a permission collection. + pub fn try_from_vec(value: Vec<Permission>) -> Result<Self, RadrootsNostrConnectError> { + let value = canonicalize(value); + validate_permissions(&value)?; + Ok(Self(value)) + } + + pub fn allows_request(&self, method: &Method, parameter: Option<&str>) -> bool { self.0 .iter() .any(|permission| permission.matches_request(method, parameter)) @@ -144,13 +176,13 @@ fn sign_event_kind_parameter(value: &str) -> Option<u32> { value.parse().ok() } -impl From<Vec<RadrootsNostrConnectPermission>> for RadrootsNostrConnectPermissions { - fn from(value: Vec<RadrootsNostrConnectPermission>) -> Self { - Self(value) +impl From<Vec<Permission>> for Permissions { + fn from(value: Vec<Permission>) -> Self { + Self(canonicalize(value)) } } -impl fmt::Display for RadrootsNostrConnectPermissions { +impl fmt::Display for Permissions { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let rendered = self .0 @@ -162,7 +194,7 @@ impl fmt::Display for RadrootsNostrConnectPermissions { } } -impl FromStr for RadrootsNostrConnectPermissions { +impl FromStr for Permissions { type Err = RadrootsNostrConnectError; fn from_str(value: &str) -> Result<Self, Self::Err> { @@ -170,25 +202,31 @@ impl FromStr for RadrootsNostrConnectPermissions { if trimmed.is_empty() { return Ok(Self::default()); } + if trimmed.len() > PERMISSIONS_MAX_BYTES { + return Err(invalid_permissions( + "serialized permission set exceeds its byte limit", + )); + } let permissions = trimmed .split(',') - .map(RadrootsNostrConnectPermission::from_str) + .map(Permission::from_str) .collect::<Result<Vec<_>, _>>()?; - Ok(Self(permissions)) + Self::try_from_vec(permissions) } } -impl Serialize for RadrootsNostrConnectPermissions { +impl Serialize for Permissions { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, { + validate_permissions(&self.0).map_err(serde::ser::Error::custom)?; serializer.serialize_str(&self.to_string()) } } -impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions { +impl<'de> Deserialize<'de> for Permissions { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -197,3 +235,50 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectPermissions { Self::from_str(&value).map_err(serde::de::Error::custom) } } + +fn canonicalize(mut permissions: Vec<Permission>) -> Vec<Permission> { + permissions.sort_by_key(ToString::to_string); + permissions.dedup(); + permissions +} + +fn validate_permissions(permissions: &[Permission]) -> Result<(), RadrootsNostrConnectError> { + if permissions.len() > PERMISSION_COUNT_MAX { + return Err(invalid_permissions("permission count exceeds its limit")); + } + for permission in permissions { + Method::from_str(permission.method.as_str())?; + if let Some(parameter) = permission.parameter.as_deref() { + validate_parameter(parameter)?; + } + } + let rendered = permissions + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>() + .join(","); + if rendered.len() > PERMISSIONS_MAX_BYTES { + return Err(invalid_permissions( + "serialized permission set exceeds its byte limit", + )); + } + Ok(()) +} + +fn validate_parameter(value: &str) -> Result<(), RadrootsNostrConnectError> { + if value.is_empty() + || value.len() > PERMISSION_PARAMETER_MAX_BYTES + || value.trim() != value + || value.contains(',') + || value.chars().any(char::is_control) + { + return Err(RadrootsNostrConnectError::InvalidPermission( + value.to_owned(), + )); + } + Ok(()) +} + +fn invalid_permissions(reason: &str) -> RadrootsNostrConnectError { + RadrootsNostrConnectError::InvalidPermission(reason.to_owned()) +} diff --git a/crates/nostr_connect/src/uri.rs b/crates/nostr_connect/src/uri.rs @@ -1,54 +1,136 @@ use crate::error::RadrootsNostrConnectError; -use crate::permission::RadrootsNostrConnectPermissions; -use nostr::{PublicKey, RelayUrl}; +use crate::permission::Permissions; +use radroots_identity::PublicKey; use serde::{Deserialize, Serialize}; use std::fmt; use std::str::FromStr; use url::Url; -pub const RADROOTS_NOSTR_CONNECT_URI_SCHEME: &str = "nostrconnect"; -pub const RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME: &str = "bunker"; -pub const RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES: usize = 128; -pub const RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES: usize = 2_048; -pub const RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES: usize = 4_352; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrConnectBunkerUri { - pub remote_signer_public_key: PublicKey, - pub relays: Vec<RelayUrl>, - #[serde(skip_serializing_if = "Option::is_none")] - pub secret: Option<String>, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] -pub struct RadrootsNostrConnectClientMetadata { - #[serde( - default, - skip_serializing_if = "RadrootsNostrConnectPermissions::is_empty" - )] - pub requested_permissions: RadrootsNostrConnectPermissions, - #[serde(skip_serializing_if = "Option::is_none")] +pub const URI_SCHEME: &str = "nostrconnect"; +pub const BUNKER_URI_SCHEME: &str = "bunker"; +pub const CLIENT_NAME_MAX_BYTES: usize = 128; +pub const CLIENT_URL_MAX_BYTES: usize = 2_048; +pub const CLIENT_METADATA_JSON_MAX_BYTES: usize = 4_352; +pub const URI_MAX_BYTES: usize = 16_384; +pub const RELAY_COUNT_MAX: usize = 32; +pub const SECRET_MAX_BYTES: usize = 1_024; + +/// A validated WebSocket relay URL owned by the NIP-46 protocol package. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct RelayUrl(nostr::RelayUrl); + +impl RelayUrl { + /// Parses a relay URL accepted by the underlying Nostr wire protocol. + pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> { + nostr::RelayUrl::parse(value).map(Self).map_err(|error| { + RadrootsNostrConnectError::InvalidRelayUrl { + value: value.to_owned(), + reason: error.to_string(), + } + }) + } +} + +impl fmt::Debug for RelayUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("RelayUrl").field(&self.0).finish() + } +} + +impl fmt::Display for RelayUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for RelayUrl { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl Serialize for RelayUrl { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for RelayUrl { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct BunkerUri { + remote_signer_public_key: PublicKey, + relays: Vec<RelayUrl>, + secret: Option<String>, +} + +impl BunkerUri { + #[must_use] + pub const fn remote_signer_public_key(&self) -> PublicKey { + self.remote_signer_public_key + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.relays + } + + #[must_use] + pub fn secret(&self) -> Option<&str> { + self.secret.as_deref() + } +} + +impl fmt::Debug for BunkerUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BunkerUri") + .field("remote_signer_public_key", &self.remote_signer_public_key) + .field("relays", &self.relays) + .field("secret", &self.secret.as_ref().map(|_| "[redacted]")) + .finish() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ClientMetadata { + #[doc(hidden)] + pub requested_permissions: Permissions, + #[doc(hidden)] pub name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] + #[doc(hidden)] pub url: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] + #[doc(hidden)] pub image: Option<String>, } -#[derive(Debug, Deserialize)] -struct RadrootsNostrConnectClientMetadataSerde { - #[serde(default)] - requested_permissions: RadrootsNostrConnectPermissions, - #[serde(default)] +#[derive(Debug, Serialize, Deserialize)] +struct ClientMetadataSerde { + #[serde(default, skip_serializing_if = "Permissions::is_empty")] + requested_permissions: Permissions, + #[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] url: Option<String>, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] image: Option<String>, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -struct RadrootsNostrConnectClientMetadataWire { +struct ClientMetadataWire { #[serde(default, skip_serializing_if = "Option::is_none")] name: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -57,7 +139,59 @@ struct RadrootsNostrConnectClientMetadataWire { image: Option<String>, } -impl RadrootsNostrConnectClientMetadata { +impl ClientMetadata { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn requested_permissions(&self) -> &Permissions { + &self.requested_permissions + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + #[must_use] + pub fn url(&self) -> Option<&str> { + self.url.as_deref() + } + + #[must_use] + pub fn image(&self) -> Option<&str> { + self.image.as_deref() + } + + #[must_use] + pub fn with_requested_permissions(mut self, permissions: Permissions) -> Self { + self.requested_permissions = permissions; + self + } + + pub fn with_name( + mut self, + value: impl Into<String>, + ) -> Result<Self, RadrootsNostrConnectError> { + self.name = Some(normalize_client_name(&value.into())?); + Ok(self) + } + + pub fn with_url(mut self, value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> { + self.url = Some(normalize_client_url("url", &value.into())?); + Ok(self) + } + + pub fn with_image( + mut self, + value: impl Into<String>, + ) -> Result<Self, RadrootsNostrConnectError> { + self.image = Some(normalize_client_url("image", &value.into())?); + Ok(self) + } + pub fn normalized(self) -> Result<Self, RadrootsNostrConnectError> { Ok(Self { requested_permissions: self.requested_permissions, @@ -78,7 +212,7 @@ impl RadrootsNostrConnectClientMetadata { pub fn to_connect_param(&self) -> Result<String, RadrootsNostrConnectError> { let normalized = self.clone().normalized()?; - let wire = RadrootsNostrConnectClientMetadataWire { + let wire = ClientMetadataWire { name: normalized.name, url: normalized.url, image: normalized.image, @@ -90,15 +224,14 @@ impl RadrootsNostrConnectClientMetadata { pub fn from_connect_param(value: &str) -> Result<Self, RadrootsNostrConnectError> { validate_metadata_size(value)?; - let wire: RadrootsNostrConnectClientMetadataWire = - serde_json::from_str(value).map_err(|error| { - RadrootsNostrConnectError::InvalidClientMetadata { - field: "payload", - reason: error.to_string(), - } - })?; + let wire: ClientMetadataWire = serde_json::from_str(value).map_err(|error| { + RadrootsNostrConnectError::InvalidClientMetadata { + field: "payload", + reason: error.to_string(), + } + })?; Self { - requested_permissions: RadrootsNostrConnectPermissions::default(), + requested_permissions: Permissions::default(), name: wire.name, url: wire.url, image: wire.image, @@ -111,12 +244,31 @@ impl RadrootsNostrConnectClientMetadata { } } -impl<'de> Deserialize<'de> for RadrootsNostrConnectClientMetadata { +impl Serialize for ClientMetadata { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + let normalized = self + .clone() + .normalized() + .map_err(serde::ser::Error::custom)?; + ClientMetadataSerde { + requested_permissions: normalized.requested_permissions, + name: normalized.name, + url: normalized.url, + image: normalized.image, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ClientMetadata { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: serde::Deserializer<'de>, { - let metadata = RadrootsNostrConnectClientMetadataSerde::deserialize(deserializer)?; + let metadata = ClientMetadataSerde::deserialize(deserializer)?; Self { requested_permissions: metadata.requested_permissions, name: metadata.name, @@ -128,25 +280,61 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectClientMetadata { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrConnectClientUri { - pub client_public_key: PublicKey, - pub relays: Vec<RelayUrl>, - pub secret: String, - #[serde(default)] - pub metadata: RadrootsNostrConnectClientMetadata, +#[derive(Clone, PartialEq, Eq)] +pub struct ClientUri { + client_public_key: PublicKey, + relays: Vec<RelayUrl>, + secret: String, + metadata: ClientMetadata, +} + +impl ClientUri { + #[must_use] + pub const fn client_public_key(&self) -> PublicKey { + self.client_public_key + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.relays + } + + #[must_use] + pub fn secret(&self) -> &str { + &self.secret + } + + #[must_use] + pub fn metadata(&self) -> &ClientMetadata { + &self.metadata + } +} + +impl fmt::Debug for ClientUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientUri") + .field("client_public_key", &self.client_public_key) + .field("relays", &self.relays) + .field("secret", &"[redacted]") + .field("metadata", &self.metadata) + .finish() + } } #[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrConnectUri { - Bunker(RadrootsNostrConnectBunkerUri), - Client(RadrootsNostrConnectClientUri), +pub enum Uri { + Bunker(BunkerUri), + Client(ClientUri), } -impl RadrootsNostrConnectUri { +impl Uri { pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> { + if value.len() > URI_MAX_BYTES { + return Err(RadrootsNostrConnectError::InvalidUri); + } let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl { - value: value.to_owned(), + value: "[redacted NIP-46 URI]".to_owned(), reason: error.to_string(), })?; let host = url @@ -154,15 +342,15 @@ impl RadrootsNostrConnectUri { .ok_or(RadrootsNostrConnectError::MissingPublicKey)?; match url.scheme() { - RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME => { + BUNKER_URI_SCHEME => { let remote_signer_public_key = parse_public_key(host)?; let mut relays = Vec::new(); let mut secret = None; for (key, value) in url.query_pairs() { match key.as_ref() { - "relay" => relays.push(parse_relay_url(value.as_ref())?), - "secret" => secret = Some(value.into_owned()), + "relay" => push_relay(&mut relays, value.as_ref())?, + "secret" => set_once(&mut secret, value.into_owned())?, _ => {} } } @@ -171,29 +359,34 @@ impl RadrootsNostrConnectUri { return Err(RadrootsNostrConnectError::MissingRelay); } - Ok(Self::Bunker(RadrootsNostrConnectBunkerUri { + validate_optional_secret(secret.as_deref())?; + Ok(Self::Bunker(BunkerUri { remote_signer_public_key, relays, secret, })) } - RADROOTS_NOSTR_CONNECT_URI_SCHEME => { + URI_SCHEME => { let client_public_key = parse_public_key(host)?; let mut relays = Vec::new(); let mut secret = None; - let mut metadata = RadrootsNostrConnectClientMetadata::default(); + let mut metadata = ClientMetadata::default(); + let mut permissions_seen = false; for (key, value) in url.query_pairs() { match key.as_ref() { - "relay" => relays.push(parse_relay_url(value.as_ref())?), - "secret" => secret = Some(value.into_owned()), + "relay" => push_relay(&mut relays, value.as_ref())?, + "secret" => set_once(&mut secret, value.into_owned())?, "perms" => { - metadata.requested_permissions = - RadrootsNostrConnectPermissions::from_str(value.as_ref())?; + if permissions_seen { + return Err(RadrootsNostrConnectError::InvalidUri); + } + permissions_seen = true; + metadata.requested_permissions = Permissions::from_str(value.as_ref())?; } - "name" => metadata.name = Some(value.into_owned()), - "url" => metadata.url = Some(value.into_owned()), - "image" => metadata.image = Some(value.into_owned()), + "name" => set_once(&mut metadata.name, value.into_owned())?, + "url" => set_once(&mut metadata.url, value.into_owned())?, + "image" => set_once(&mut metadata.image, value.into_owned())?, _ => {} } } @@ -206,9 +399,10 @@ impl RadrootsNostrConnectUri { if secret.is_empty() { return Err(RadrootsNostrConnectError::MissingSecret); } + validate_secret(&secret)?; let metadata = metadata.normalized()?; - Ok(Self::Client(RadrootsNostrConnectClientUri { + Ok(Self::Client(ClientUri { client_public_key, relays, secret, @@ -222,7 +416,7 @@ impl RadrootsNostrConnectUri { } } -impl FromStr for RadrootsNostrConnectUri { +impl FromStr for Uri { type Err = RadrootsNostrConnectError; fn from_str(value: &str) -> Result<Self, Self::Err> { @@ -230,68 +424,154 @@ impl FromStr for RadrootsNostrConnectUri { } } -impl fmt::Display for RadrootsNostrConnectUri { +impl fmt::Display for Uri { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Bunker(uri) => { - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - for relay in &uri.relays { - serializer.append_pair("relay", &relay.to_string()); - } - if let Some(secret) = &uri.secret { - serializer.append_pair("secret", secret); - } - let query = serializer.finish(); - write!( - f, - "{RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME}://{}?{query}", - uri.remote_signer_public_key - ) + Self::Bunker(uri) => uri.fmt(f), + Self::Client(uri) => uri.fmt(f), + } + } +} + +impl fmt::Display for BunkerUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for relay in &self.relays { + serializer.append_pair("relay", &relay.to_string()); + } + if let Some(secret) = &self.secret { + serializer.append_pair("secret", secret); + } + let query = serializer.finish(); + write!( + formatter, + "{BUNKER_URI_SCHEME}://{}?{query}", + self.remote_signer_public_key + ) + } +} + +impl fmt::Display for ClientUri { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + for relay in &self.relays { + serializer.append_pair("relay", &relay.to_string()); + } + serializer.append_pair("secret", &self.secret); + if !self.metadata.requested_permissions.is_empty() { + serializer.append_pair("perms", &self.metadata.requested_permissions.to_string()); + } + if let Some(name) = &self.metadata.name { + serializer.append_pair("name", name); + } + if let Some(url) = &self.metadata.url { + serializer.append_pair("url", url); + } + if let Some(image) = &self.metadata.image { + serializer.append_pair("image", image); + } + let query = serializer.finish(); + write!( + formatter, + "{URI_SCHEME}://{}?{query}", + self.client_public_key + ) + } +} + +macro_rules! impl_uri_serde { + ($type:ty, $variant:path) => { + impl Serialize for $type { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) } - Self::Client(uri) => { - let mut serializer = url::form_urlencoded::Serializer::new(String::new()); - for relay in &uri.relays { - serializer.append_pair("relay", &relay.to_string()); - } - serializer.append_pair("secret", &uri.secret); - if !uri.metadata.requested_permissions.is_empty() { - serializer - .append_pair("perms", &uri.metadata.requested_permissions.to_string()); - } - if let Some(name) = &uri.metadata.name { - serializer.append_pair("name", name); - } - if let Some(url) = &uri.metadata.url { - serializer.append_pair("url", url); - } - if let Some(image) = &uri.metadata.image { - serializer.append_pair("image", image); + } + + impl<'de> Deserialize<'de> for $type { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + match Uri::parse(&value).map_err(serde::de::Error::custom)? { + $variant(uri) => Ok(uri), + _ => Err(serde::de::Error::custom("unexpected NIP-46 URI scheme")), } - let query = serializer.finish(); - write!( - f, - "{RADROOTS_NOSTR_CONNECT_URI_SCHEME}://{}?{query}", - uri.client_public_key - ) } } + }; +} + +impl_uri_serde!(BunkerUri, Uri::Bunker); +impl_uri_serde!(ClientUri, Uri::Client); + +impl Serialize for Uri { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Uri { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) } } fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> { - PublicKey::parse(value) - .or_else(|_| PublicKey::from_hex(value)) - .map_err(|error| RadrootsNostrConnectError::InvalidPublicKey { + radroots_nostr::key::parse_public_key(value).map_err(|error| { + RadrootsNostrConnectError::InvalidPublicKey { value: value.to_owned(), reason: error.to_string(), - }) + } + }) } fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsNostrConnectError> { - RelayUrl::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidRelayUrl { - value: value.to_owned(), - reason: error.to_string(), - }) + RelayUrl::parse(value) +} + +fn push_relay(relays: &mut Vec<RelayUrl>, value: &str) -> Result<(), RadrootsNostrConnectError> { + let relay = parse_relay_url(value)?; + if relays.contains(&relay) { + return Ok(()); + } + if relays.len() == RELAY_COUNT_MAX { + return Err(RadrootsNostrConnectError::InvalidUri); + } + relays.push(relay); + Ok(()) +} + +fn set_once(slot: &mut Option<String>, value: String) -> Result<(), RadrootsNostrConnectError> { + if slot.replace(value).is_some() { + return Err(RadrootsNostrConnectError::InvalidUri); + } + Ok(()) +} + +fn validate_optional_secret(secret: Option<&str>) -> Result<(), RadrootsNostrConnectError> { + match secret { + Some("") => Err(RadrootsNostrConnectError::InvalidUri), + Some(secret) => validate_secret(secret), + None => Ok(()), + } +} + +fn validate_secret(secret: &str) -> Result<(), RadrootsNostrConnectError> { + if secret.len() > SECRET_MAX_BYTES || secret.chars().any(char::is_control) { + return Err(RadrootsNostrConnectError::InvalidUri); + } + Ok(()) } fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectError> { @@ -299,10 +579,10 @@ fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectErro if normalized.is_empty() { return Err(invalid_client_metadata("name", "must not be empty")); } - if normalized.len() > RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES { + if normalized.len() > CLIENT_NAME_MAX_BYTES { return Err(invalid_client_metadata( "name", - format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES} UTF-8 bytes"), + format!("must not exceed {CLIENT_NAME_MAX_BYTES} UTF-8 bytes"), )); } if normalized.chars().any(char::is_control) { @@ -318,10 +598,10 @@ fn normalize_client_url( field: &'static str, value: &str, ) -> Result<String, RadrootsNostrConnectError> { - if value.len() > RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES { + if value.len() > CLIENT_URL_MAX_BYTES { return Err(invalid_client_metadata( field, - format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES} UTF-8 bytes"), + format!("must not exceed {CLIENT_URL_MAX_BYTES} UTF-8 bytes"), )); } if value.chars().any(char::is_control) { @@ -349,9 +629,9 @@ fn normalize_client_url( fn validate_metadata_size(value: &str) -> Result<(), RadrootsNostrConnectError> { let received = value.len(); - if received > RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES { + if received > CLIENT_METADATA_JSON_MAX_BYTES { return Err(RadrootsNostrConnectError::ClientMetadataTooLarge { - max: RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES, + max: CLIENT_METADATA_JSON_MAX_BYTES, received, }); } diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs @@ -6,13 +6,13 @@ use nostr::{ Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent, }; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome, + Method, RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError, - RadrootsNostrConnectMethod, RadrootsNostrConnectRemoteSessionCapability, - RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - build_request_event, execute_request_with_transport, parse_response_event, + RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, build_request_event, + execute_request_with_transport, parse_response_event, }; use std::collections::VecDeque; use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, RELAY_PRIMARY_WSS}; @@ -276,14 +276,8 @@ fn ignores_response_from_unexpected_signer_identity() { RadrootsNostrConnectResponse::Pong, ); - let outcome = parse_response_event( - &client_keys, - &target, - "req-ping", - &RadrootsNostrConnectMethod::Ping, - &response, - ) - .expect("parse response"); + let outcome = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &response) + .expect("parse response"); assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore); } @@ -298,14 +292,8 @@ fn ignores_non_rpc_kind_from_expected_signer() { .sign_with_keys(&remote_keys) .expect("non-rpc response"); - let outcome = parse_response_event( - &client_keys, - &target, - "req-ping", - &RadrootsNostrConnectMethod::Ping, - &response, - ) - .expect("parse response"); + let outcome = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &response) + .expect("parse response"); assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore); } @@ -419,14 +407,8 @@ fn reports_decryption_failure_from_expected_signer() { .sign_with_keys(&remote_keys) .expect("malformed response"); - let error = parse_response_event( - &client_keys, - &target, - "req-ping", - &RadrootsNostrConnectMethod::Ping, - &malformed, - ) - .expect_err("decrypt failure"); + let error = parse_response_event(&client_keys, &target, "req-ping", &Method::Ping, &malformed) + .expect_err("decrypt failure"); assert!(matches!( error, @@ -446,14 +428,9 @@ fn parses_auth_challenge_as_progress_without_consuming_final_response() { RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/continue".to_owned()), ); - let outcome = parse_response_event( - &client_keys, - &target, - "req-sign", - &RadrootsNostrConnectMethod::SignEvent, - &auth, - ) - .expect("parse auth"); + let outcome = + parse_response_event(&client_keys, &target, "req-sign", &Method::SignEvent, &auth) + .expect("parse auth"); assert_eq!( outcome, diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs @@ -3,12 +3,11 @@ mod test_fixtures; use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, - RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod, - RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectPermission, - RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + CLIENT_URL_MAX_BYTES, ClientMetadata, Method, Permission, Permissions, + RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError, + RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, + RadrootsNostrConnectResponseEnvelope, Uri, }; use serde_json::{Value, json}; use std::str::FromStr; @@ -65,90 +64,80 @@ fn error_method_and_permission_surfaces_cover_public_paths() { )); let methods = [ - (RadrootsNostrConnectMethod::Connect, "connect"), - (RadrootsNostrConnectMethod::GetPublicKey, "get_public_key"), - ( - RadrootsNostrConnectMethod::GetSessionCapability, - "get_session_capability", - ), - (RadrootsNostrConnectMethod::SignEvent, "sign_event"), - (RadrootsNostrConnectMethod::Nip04Encrypt, "nip04_encrypt"), - (RadrootsNostrConnectMethod::Nip04Decrypt, "nip04_decrypt"), - (RadrootsNostrConnectMethod::Nip44Encrypt, "nip44_encrypt"), - (RadrootsNostrConnectMethod::Nip44Decrypt, "nip44_decrypt"), - (RadrootsNostrConnectMethod::Ping, "ping"), - (RadrootsNostrConnectMethod::SwitchRelays, "switch_relays"), + (Method::Connect, "connect"), + (Method::GetPublicKey, "get_public_key"), + (Method::GetSessionCapability, "get_session_capability"), + (Method::SignEvent, "sign_event"), + (Method::Nip04Encrypt, "nip04_encrypt"), + (Method::Nip04Decrypt, "nip04_decrypt"), + (Method::Nip44Encrypt, "nip44_encrypt"), + (Method::Nip44Decrypt, "nip44_decrypt"), + (Method::Ping, "ping"), + (Method::SwitchRelays, "switch_relays"), ]; for (method, raw) in methods { assert_eq!(method.as_str(), raw); assert_eq!(method.to_string(), raw); - assert_eq!( - RadrootsNostrConnectMethod::from_str(raw).expect("parse method"), - method - ); + assert_eq!(Method::from_str(raw).expect("parse method"), method); } assert_eq!( - RadrootsNostrConnectMethod::from_str("publish_note").expect("custom method"), - RadrootsNostrConnectMethod::Custom("publish_note".to_owned()) + Method::from_str("publish_note").expect("custom method"), + Method::custom("publish_note").expect("valid custom NIP-46 method") ); assert!(matches!( - RadrootsNostrConnectMethod::from_str(" "), + Method::from_str(" "), Err(RadrootsNostrConnectError::InvalidMethod(value)) if value == " " )); assert_eq!( - serde_json::from_str::<RadrootsNostrConnectMethod>("\"do_work\"") - .expect("deserialize custom method"), - RadrootsNostrConnectMethod::Custom("do_work".to_owned()) + serde_json::from_str::<Method>("\"do_work\"").expect("deserialize custom method"), + Method::custom("do_work").expect("valid custom NIP-46 method") ); assert!( - serde_json::from_str::<RadrootsNostrConnectMethod>("123") + serde_json::from_str::<Method>("123") .expect_err("non-string method") .to_string() .contains("invalid type") ); assert!( - serde_json::from_str::<RadrootsNostrConnectMethod>("\"\"") + serde_json::from_str::<Method>("\"\"") .expect_err("blank method") .to_string() .contains("invalid NIP-46 method") ); - let simple = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping); + let simple = Permission::new(Method::Ping); assert_eq!(simple.to_string(), "ping"); - let parameterized = RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "1059", - ); + let parameterized = Permission::with_parameter(Method::SignEvent, "1059"); assert_eq!(parameterized.to_string(), "sign_event:1059"); assert_eq!( - RadrootsNostrConnectPermission::from_str("sign_event:1059").expect("parse permission"), + Permission::from_str("sign_event:1059").expect("parse permission"), parameterized ); assert!(matches!( - RadrootsNostrConnectPermission::from_str(" "), + Permission::from_str(" "), Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == " " )); assert!(matches!( - RadrootsNostrConnectPermission::from_str("sign_event:"), + Permission::from_str("sign_event:"), Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" )); assert!(matches!( - RadrootsNostrConnectPermission::from_str(" :kind"), + Permission::from_str(" :kind"), Err(RadrootsNostrConnectError::InvalidMethod(_)) )); - let empty = RadrootsNostrConnectPermissions::new(); + let empty = Permissions::new(); assert!(empty.is_empty()); assert!(empty.as_slice().is_empty()); assert!(empty.clone().into_vec().is_empty()); assert_eq!( - RadrootsNostrConnectPermissions::from_str(" ").expect("empty permissions"), + Permissions::from_str(" ").expect("empty permissions"), empty ); - let permissions = RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), - RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"), + let permissions = Permissions::from(vec![ + Permission::new(Method::Nip44Encrypt), + Permission::with_parameter(Method::SignEvent, "13"), ]); assert_eq!(permissions.to_string(), "nip44_encrypt,sign_event:13"); assert_eq!( @@ -156,88 +145,60 @@ fn error_method_and_permission_surfaces_cover_public_paths() { "\"nip44_encrypt,sign_event:13\"" ); assert_eq!( - serde_json::from_str::<RadrootsNostrConnectPermissions>("\"nip44_encrypt,sign_event:13\"") + serde_json::from_str::<Permissions>("\"nip44_encrypt,sign_event:13\"") .expect("deserialize permissions"), permissions ); assert!( - serde_json::from_str::<RadrootsNostrConnectPermissions>("123") + serde_json::from_str::<Permissions>("123") .expect_err("non-string permissions") .to_string() .contains("invalid type") ); assert!(matches!( - RadrootsNostrConnectPermissions::from_str("sign_event:,ping"), + Permissions::from_str("sign_event:,ping"), Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" )); - let all_sign_events = - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent); + let all_sign_events = Permission::new(Method::SignEvent); assert!(all_sign_events.matches_sign_event_kind(30402)); - assert!(all_sign_events.matches_request(&RadrootsNostrConnectMethod::SignEvent, None)); - assert!(!all_sign_events.matches_request(&RadrootsNostrConnectMethod::Ping, None)); + assert!(all_sign_events.matches_request(&Method::SignEvent, None)); + assert!(!all_sign_events.matches_request(&Method::Ping, None)); - let numeric_sign_event = RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "30402", - ); - let kind_prefixed_sign_event = RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "kind:30402", - ); + let numeric_sign_event = Permission::with_parameter(Method::SignEvent, "30402"); + let kind_prefixed_sign_event = Permission::with_parameter(Method::SignEvent, "kind:30402"); assert!(numeric_sign_event.matches_sign_event_kind(30402)); assert!(kind_prefixed_sign_event.matches_sign_event_kind(30402)); - assert!( - numeric_sign_event - .matches_request(&RadrootsNostrConnectMethod::SignEvent, Some("kind:30402")) - ); + assert!(numeric_sign_event.matches_request(&Method::SignEvent, Some("kind:30402"))); assert!(!numeric_sign_event.matches_sign_event_kind(3040)); assert!( - !RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "130402" - ) - .matches_sign_event_kind(30402) + !Permission::with_parameter(Method::SignEvent, "130402").matches_sign_event_kind(30402) ); assert!( - !RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "not-a-kind" - ) - .matches_request( - &RadrootsNostrConnectMethod::SignEvent, - Some("also-not-a-kind") - ) + !Permission::with_parameter(Method::SignEvent, "not-a-kind") + .matches_request(&Method::SignEvent, Some("also-not-a-kind")) ); + assert!(!Permission::with_parameter(Method::SignEvent, "kind:").matches_sign_event_kind(30402)); + let encrypt_permission = + Permission::with_parameter(Method::Nip44Encrypt, test_public_key().to_hex()); assert!( - !RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "kind:" - ) - .matches_sign_event_kind(30402) - ); - let encrypt_permission = RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::Nip44Encrypt, - test_public_key().to_hex(), + encrypt_permission + .matches_request(&Method::Nip44Encrypt, Some(&test_public_key().to_hex())) ); - assert!(encrypt_permission.matches_request( - &RadrootsNostrConnectMethod::Nip44Encrypt, - Some(&test_public_key().to_hex()) - )); - assert!(!encrypt_permission.matches_request(&RadrootsNostrConnectMethod::Nip44Encrypt, None)); + assert!(!encrypt_permission.matches_request(&Method::Nip44Encrypt, None)); - let typed_permissions = RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), + let typed_permissions = Permissions::from(vec![ + Permission::new(Method::Ping), kind_prefixed_sign_event, ]); - assert!(typed_permissions.allows_request(&RadrootsNostrConnectMethod::Ping, None)); + assert!(typed_permissions.allows_request(&Method::Ping, None)); assert!(typed_permissions.allows_sign_event_kind(30402)); assert!(!typed_permissions.allows_sign_event_kind(0)); } #[test] fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { - let bunker = RadrootsNostrConnectUri::parse(&format!( + let bunker = Uri::parse(&format!( "bunker://{}?relay={}&foo=bar", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -250,7 +211,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { ))); assert!(!bunker_rendered.contains("secret=")); - let minimal_client: RadrootsNostrConnectUri = format!( + let minimal_client: Uri = format!( "nostrconnect://{}?relay={}&secret=shared", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -264,7 +225,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { assert!(!minimal_client_rendered.contains("url=")); assert!(!minimal_client_rendered.contains("image=")); - let metadata_client = RadrootsNostrConnectUri::parse(&format!( + let metadata_client = Uri::parse(&format!( "nostrconnect://{}?relay={}&secret=shared&perms=ping&name=myc&url={}&image={}&ignored=value", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -282,28 +243,26 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { assert!(metadata_rendered.contains(&format!("image={}", encode_uri_component(&logo_url())))); assert!(matches!( - RadrootsNostrConnectUri::parse("not a uri"), + Uri::parse("not a uri"), Err(RadrootsNostrConnectError::InvalidUrl { .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse( - "nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc" - ), + Uri::parse("nostrconnect:///path?relay=wss%3A%2F%2Frelay.example.com&secret=abc"), Err(RadrootsNostrConnectError::MissingPublicKey) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!("bunker://{}", FIXTURE_ALICE.public_key_hex)), + Uri::parse(&format!("bunker://{}", FIXTURE_ALICE.public_key_hex)), Err(RadrootsNostrConnectError::MissingRelay) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?secret=abc", FIXTURE_ALICE.public_key_hex )), Err(RadrootsNostrConnectError::MissingRelay) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay={}", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -311,24 +270,22 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { Err(RadrootsNostrConnectError::MissingSecret) )); assert!(matches!( - RadrootsNostrConnectUri::parse("https://example.com"), + Uri::parse("https://example.com"), Err(RadrootsNostrConnectError::InvalidUriScheme(value)) if value == "https" )); assert!(matches!( - RadrootsNostrConnectUri::parse( - "nostrconnect://bad-key?relay=wss%3A%2F%2Frelay.example.com&secret=abc" - ), + Uri::parse("nostrconnect://bad-key?relay=wss%3A%2F%2Frelay.example.com&secret=abc"), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay=http%3A%2F%2Frelay.example.com&secret=abc", FIXTURE_ALICE.public_key_hex )), Err(RadrootsNostrConnectError::InvalidRelayUrl { .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay={}&secret=abc&url=not-a-url", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -336,18 +293,18 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "url", .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"), + Uri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "bunker://{}?relay=http%3A%2F%2Frelay.example.com", FIXTURE_ALICE.public_key_hex )), Err(RadrootsNostrConnectError::InvalidRelayUrl { .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay={}&secret=abc&perms=sign_event%3A", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -355,7 +312,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay={}&secret=abc&image=not-a-url", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -363,7 +320,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "image", .. }) )); assert!(matches!( - RadrootsNostrConnectUri::parse(&format!( + Uri::parse(&format!( "nostrconnect://{}?relay={}&secret=", FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), @@ -374,9 +331,9 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { #[test] fn client_metadata_rejects_malformed_and_unsafe_display_fields() { - let empty = RadrootsNostrConnectClientMetadata::default(); + let empty = ClientMetadata::default(); assert!(empty.is_display_empty()); - let decoded: RadrootsNostrConnectClientMetadata = serde_json::from_value(json!({ + let decoded: ClientMetadata = serde_json::from_value(json!({ "requested_permissions": "ping", "name": " client ", "url": APP_PRIMARY_HTTPS, @@ -389,21 +346,21 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() { Some(format!("{APP_PRIMARY_HTTPS}/").as_str()) ); assert!( - serde_json::from_value::<RadrootsNostrConnectClientMetadata>(json!({ + serde_json::from_value::<ClientMetadata>(json!({ "name": "line\nbreak" })) .is_err() ); for metadata in [ - RadrootsNostrConnectClientMetadata { + ClientMetadata { name: Some("client".to_owned()), ..empty.clone() }, - RadrootsNostrConnectClientMetadata { + ClientMetadata { url: Some(APP_PRIMARY_HTTPS.to_owned()), ..empty.clone() }, - RadrootsNostrConnectClientMetadata { + ClientMetadata { image: Some(logo_url()), ..empty.clone() }, @@ -412,7 +369,7 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() { } assert!(matches!( - RadrootsNostrConnectClientMetadata::from_connect_param("{"), + ClientMetadata::from_connect_param("{"), Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "payload", .. @@ -420,15 +377,12 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() { )); for (value, field) in [ - ( - "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES + 1), - "url", - ), + ("x".repeat(CLIENT_URL_MAX_BYTES + 1), "url"), ("https://example.com/\n".to_owned(), "url"), ("https://user@example.com".to_owned(), "url"), ("https://:secret@example.com".to_owned(), "image"), ] { - let metadata = RadrootsNostrConnectClientMetadata { + let metadata = ClientMetadata { url: (field == "url").then_some(value.clone()), image: (field == "image").then_some(value), ..empty.clone() @@ -445,20 +399,17 @@ fn client_metadata_rejects_malformed_and_unsafe_display_fields() { #[test] fn request_surface_covers_variant_methods_serialization_and_validation() { - let ping_permission = - RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new( - RadrootsNostrConnectMethod::Ping, - )]); + let ping_permission = Permissions::from(vec![Permission::new(Method::Ping)]); let requests = vec![ ( RadrootsNostrConnectRequest::Connect { remote_signer_public_key: test_public_key(), secret: None, - requested_permissions: RadrootsNostrConnectPermissions::default(), + requested_permissions: Permissions::default(), client_metadata: None, }, - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![test_public_key().to_hex()], ), ( @@ -468,22 +419,22 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { requested_permissions: ping_permission.clone(), client_metadata: None, }, - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![test_public_key().to_hex(), String::new(), "ping".to_owned()], ), ( RadrootsNostrConnectRequest::GetPublicKey, - RadrootsNostrConnectMethod::GetPublicKey, + Method::GetPublicKey, Vec::new(), ), ( RadrootsNostrConnectRequest::GetSessionCapability, - RadrootsNostrConnectMethod::GetSessionCapability, + Method::GetSessionCapability, Vec::new(), ), ( RadrootsNostrConnectRequest::SignEvent(unsigned_event()), - RadrootsNostrConnectMethod::SignEvent, + Method::SignEvent, vec![serde_json::to_string(&unsigned_event()).expect("serialize unsigned event")], ), ( @@ -491,7 +442,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { public_key: test_public_key(), plaintext: "hello".to_owned(), }, - RadrootsNostrConnectMethod::Nip04Encrypt, + Method::Nip04Encrypt, vec![test_public_key().to_hex(), "hello".to_owned()], ), ( @@ -499,7 +450,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { public_key: test_public_key(), ciphertext: "cipher".to_owned(), }, - RadrootsNostrConnectMethod::Nip04Decrypt, + Method::Nip04Decrypt, vec![test_public_key().to_hex(), "cipher".to_owned()], ), ( @@ -507,7 +458,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { public_key: test_public_key(), plaintext: "hello".to_owned(), }, - RadrootsNostrConnectMethod::Nip44Encrypt, + Method::Nip44Encrypt, vec![test_public_key().to_hex(), "hello".to_owned()], ), ( @@ -515,30 +466,26 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { public_key: test_public_key(), ciphertext: "cipher".to_owned(), }, - RadrootsNostrConnectMethod::Nip44Decrypt, + Method::Nip44Decrypt, vec![test_public_key().to_hex(), "cipher".to_owned()], ), - ( - RadrootsNostrConnectRequest::Ping, - RadrootsNostrConnectMethod::Ping, - Vec::new(), - ), + (RadrootsNostrConnectRequest::Ping, Method::Ping, Vec::new()), ( RadrootsNostrConnectRequest::SwitchRelays, - RadrootsNostrConnectMethod::SwitchRelays, + Method::SwitchRelays, Vec::new(), ), ( RadrootsNostrConnectRequest::Logout, - RadrootsNostrConnectMethod::Logout, + Method::Logout, Vec::new(), ), ( RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + method: Method::custom("publish_note").expect("valid custom NIP-46 method"), params: vec!["one".to_owned(), "two".to_owned()], }, - RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + Method::custom("publish_note").expect("valid custom NIP-46 method"), vec!["one".to_owned(), "two".to_owned()], ), ]; @@ -548,52 +495,41 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { } assert_eq!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, - vec![test_public_key().to_hex()], - ) - .expect("connect without secret or perms"), + RadrootsNostrConnectRequest::from_parts(Method::Connect, vec![test_public_key().to_hex()],) + .expect("connect without secret or perms"), RadrootsNostrConnectRequest::Connect { remote_signer_public_key: test_public_key(), secret: None, - requested_permissions: RadrootsNostrConnectPermissions::default(), + requested_permissions: Permissions::default(), client_metadata: None, } ); assert_eq!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![test_public_key().to_hex(), String::new(), "ping".to_owned()], ) .expect("connect with empty secret"), RadrootsNostrConnectRequest::Connect { remote_signer_public_key: test_public_key(), secret: None, - requested_permissions: RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), - ]), + requested_permissions: Permissions::from(vec![Permission::new(Method::Ping),]), client_metadata: None, } ); assert_eq!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::GetPublicKey, - Vec::new(), - ) - .expect("get_public_key from parts"), + RadrootsNostrConnectRequest::from_parts(Method::GetPublicKey, Vec::new(),) + .expect("get_public_key from parts"), RadrootsNostrConnectRequest::GetPublicKey ); assert_eq!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::GetSessionCapability, - Vec::new(), - ) - .expect("get_session_capability from parts"), + RadrootsNostrConnectRequest::from_parts(Method::GetSessionCapability, Vec::new(),) + .expect("get_session_capability from parts"), RadrootsNostrConnectRequest::GetSessionCapability ); assert_eq!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip04Encrypt, + Method::Nip04Encrypt, vec![test_public_key().to_hex(), "hello".to_owned()], ) .expect("nip04 encrypt from parts"), @@ -604,7 +540,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { ); assert_eq!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip04Decrypt, + Method::Nip04Decrypt, vec![test_public_key().to_hex(), "cipher".to_owned()], ) .expect("nip04 decrypt from parts"), @@ -615,7 +551,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { ); assert_eq!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip44Encrypt, + Method::Nip44Encrypt, vec![test_public_key().to_hex(), "hello".to_owned()], ) .expect("nip44 encrypt from parts"), @@ -626,7 +562,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { ); assert_eq!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip44Decrypt, + Method::Nip44Decrypt, vec![test_public_key().to_hex(), "cipher".to_owned()], ) .expect("nip44 decrypt from parts"), @@ -636,70 +572,46 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { } ); assert_eq!( - RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Ping, Vec::new()) - .expect("ping from parts"), + RadrootsNostrConnectRequest::from_parts(Method::Ping, Vec::new()).expect("ping from parts"), RadrootsNostrConnectRequest::Ping ); assert_eq!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::SwitchRelays, - Vec::new(), - ) - .expect("switch relays from parts"), + RadrootsNostrConnectRequest::from_parts(Method::SwitchRelays, Vec::new(),) + .expect("switch relays from parts"), RadrootsNostrConnectRequest::SwitchRelays ); for (method, params, expected_error) in [ + (Method::GetPublicKey, vec!["oops".to_owned()], "no params"), ( - RadrootsNostrConnectMethod::GetPublicKey, - vec!["oops".to_owned()], - "no params", - ), - ( - RadrootsNostrConnectMethod::GetSessionCapability, + Method::GetSessionCapability, vec!["oops".to_owned()], "no params", ), + (Method::SignEvent, Vec::new(), "exactly 1 param"), ( - RadrootsNostrConnectMethod::SignEvent, - Vec::new(), - "exactly 1 param", - ), - ( - RadrootsNostrConnectMethod::Nip04Encrypt, + Method::Nip04Encrypt, vec!["only-one".to_owned()], "exactly 2 params", ), ( - RadrootsNostrConnectMethod::Nip04Decrypt, + Method::Nip04Decrypt, vec!["only-one".to_owned()], "exactly 2 params", ), ( - RadrootsNostrConnectMethod::Nip44Encrypt, + Method::Nip44Encrypt, vec!["only-one".to_owned()], "exactly 2 params", ), ( - RadrootsNostrConnectMethod::Nip44Decrypt, + Method::Nip44Decrypt, vec!["only-one".to_owned()], "exactly 2 params", ), - ( - RadrootsNostrConnectMethod::Ping, - vec!["oops".to_owned()], - "no params", - ), - ( - RadrootsNostrConnectMethod::SwitchRelays, - vec!["oops".to_owned()], - "no params", - ), - ( - RadrootsNostrConnectMethod::Logout, - vec!["oops".to_owned()], - "no params", - ), + (Method::Ping, vec!["oops".to_owned()], "no params"), + (Method::SwitchRelays, vec!["oops".to_owned()], "no params"), + (Method::Logout, vec!["oops".to_owned()], "no params"), ] { assert!(matches!( RadrootsNostrConnectRequest::from_parts(method, params), @@ -707,27 +619,24 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { )); } assert!(matches!( - RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Connect, Vec::new()), + RadrootsNostrConnectRequest::from_parts(Method::Connect, Vec::new()), Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. }) if expected == "1 to 4 params" && received == 0 )); assert!(matches!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, - vec!["bad-key".to_owned()], - ), + RadrootsNostrConnectRequest::from_parts(Method::Connect, vec!["bad-key".to_owned()],), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![test_public_key().to_hex(), "secret".to_owned(), "sign_event:".to_owned()], ), Err(RadrootsNostrConnectError::InvalidPermission(value)) if value == "sign_event:" )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![ test_public_key().to_hex(), "secret".to_owned(), @@ -740,36 +649,33 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { if expected == "1 to 4 params" && received == 5 )); assert!(matches!( - RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::SignEvent, - vec!["not-json".to_owned()], - ), + RadrootsNostrConnectRequest::from_parts(Method::SignEvent, vec!["not-json".to_owned()],), Err(RadrootsNostrConnectError::InvalidRequestPayload { .. }) )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip04Encrypt, + Method::Nip04Encrypt, vec!["bad-key".to_owned(), "hello".to_owned()], ), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip04Decrypt, + Method::Nip04Decrypt, vec!["bad-key".to_owned(), "cipher".to_owned()], ), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip44Encrypt, + Method::Nip44Encrypt, vec!["bad-key".to_owned(), "hello".to_owned()], ), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Nip44Decrypt, + Method::Nip44Decrypt, vec!["bad-key".to_owned(), "cipher".to_owned()], ), Err(RadrootsNostrConnectError::InvalidPublicKey { .. }) @@ -778,7 +684,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { let custom_message = RadrootsNostrConnectRequestMessage::new( "req-custom", RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + method: Method::custom("publish_note").expect("valid custom NIP-46 method"), params: vec!["a".to_owned()], }, ); @@ -809,72 +715,69 @@ fn response_surface_covers_success_and_error_paths() { radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability { user_public_key: test_public_key(), relays: vec![relay(RELAY_PRIMARY_WSS), relay(RELAY_SECONDARY_WSS)], - permissions: RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "kind:1", - ), + permissions: Permissions::from(vec![ + Permission::new(Method::Ping), + Permission::with_parameter(Method::SignEvent, "kind:1"), ]), }; let cases = vec![ ( RadrootsNostrConnectResponse::ConnectAcknowledged, - RadrootsNostrConnectMethod::Connect, + Method::Connect, RadrootsNostrConnectResponse::ConnectAcknowledged, ), ( RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()), - RadrootsNostrConnectMethod::Connect, + Method::Connect, RadrootsNostrConnectResponse::ConnectSecretEcho("secret".to_owned()), ), ( RadrootsNostrConnectResponse::UserPublicKey(test_public_key()), - RadrootsNostrConnectMethod::GetPublicKey, + Method::GetPublicKey, RadrootsNostrConnectResponse::UserPublicKey(test_public_key()), ), ( RadrootsNostrConnectResponse::PendingConnection, - RadrootsNostrConnectMethod::GetSessionCapability, + Method::GetSessionCapability, RadrootsNostrConnectResponse::PendingConnection, ), ( RadrootsNostrConnectResponse::RemoteSessionCapability( remote_session_capability.clone(), ), - RadrootsNostrConnectMethod::GetSessionCapability, + Method::GetSessionCapability, RadrootsNostrConnectResponse::RemoteSessionCapability( remote_session_capability.clone(), ), ), ( RadrootsNostrConnectResponse::SignedEvent(event.clone()), - RadrootsNostrConnectMethod::SignEvent, + Method::SignEvent, RadrootsNostrConnectResponse::SignedEvent(event.clone()), ), ( RadrootsNostrConnectResponse::Pong, - RadrootsNostrConnectMethod::Ping, + Method::Ping, RadrootsNostrConnectResponse::Pong, ), ( RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()), - RadrootsNostrConnectMethod::Nip04Encrypt, + Method::Nip04Encrypt, RadrootsNostrConnectResponse::Nip04Encrypt("cipher".to_owned()), ), ( RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()), - RadrootsNostrConnectMethod::Nip04Decrypt, + Method::Nip04Decrypt, RadrootsNostrConnectResponse::Nip04Decrypt("plain".to_owned()), ), ( RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()), - RadrootsNostrConnectMethod::Nip44Encrypt, + Method::Nip44Encrypt, RadrootsNostrConnectResponse::Nip44Encrypt("cipher".to_owned()), ), ( RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()), - RadrootsNostrConnectMethod::Nip44Decrypt, + Method::Nip44Decrypt, RadrootsNostrConnectResponse::Nip44Decrypt("plain".to_owned()), ), ( @@ -882,7 +785,7 @@ fn response_surface_covers_success_and_error_paths() { relay(RELAY_SECONDARY_WSS), relay(RELAY_TERTIARY_WSS), ]), - RadrootsNostrConnectMethod::SwitchRelays, + Method::SwitchRelays, RadrootsNostrConnectResponse::RelayList(vec![ relay(RELAY_SECONDARY_WSS), relay(RELAY_TERTIARY_WSS), @@ -890,7 +793,7 @@ fn response_surface_covers_success_and_error_paths() { ), ( RadrootsNostrConnectResponse::RelayListUnchanged, - RadrootsNostrConnectMethod::SwitchRelays, + Method::SwitchRelays, RadrootsNostrConnectResponse::RelayListUnchanged, ), ]; @@ -922,17 +825,14 @@ fn response_surface_covers_success_and_error_paths() { .into_envelope("req-auth") .expect("serialize auth_url"); assert_eq!( - RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, - auth_envelope, - ) - .expect("parse auth_url"), + RadrootsNostrConnectResponse::from_envelope(&Method::SignEvent, auth_envelope,) + .expect("parse auth_url"), RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()) ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + &Method::custom("publish_note").expect("valid custom NIP-46 method"), RadrootsNostrConnectResponseEnvelope { id: "req-custom".to_owned(), result: Some(json!("ok")), @@ -947,7 +847,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + &Method::custom("publish_note").expect("valid custom NIP-46 method"), RadrootsNostrConnectResponseEnvelope { id: "req-custom".to_owned(), result: Some(json!({"ok": true})), @@ -962,7 +862,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetPublicKey, + &Method::GetPublicKey, RadrootsNostrConnectResponseEnvelope { id: "req-pending".to_owned(), result: None, @@ -974,7 +874,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, RadrootsNostrConnectResponseEnvelope { id: "req-pending-capability".to_owned(), result: None, @@ -986,7 +886,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetPublicKey, + &Method::GetPublicKey, RadrootsNostrConnectResponseEnvelope { id: "req-nonpending-public-key".to_owned(), result: None, @@ -1001,7 +901,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, RadrootsNostrConnectResponseEnvelope { id: "req-capability-error-with-result".to_owned(), result: Some(json!({"code": "retry"})), @@ -1016,7 +916,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, RadrootsNostrConnectResponseEnvelope { id: "req-capability-invalid-result".to_owned(), result: Some(json!({"permissions": "ping"})), @@ -1028,7 +928,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, RadrootsNostrConnectResponseEnvelope { id: "req-capability-string-result".to_owned(), result: Some(json!( @@ -1043,7 +943,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, RadrootsNostrConnectResponseEnvelope { id: "req-capability-invalid-string".to_owned(), result: Some(json!("{")), @@ -1055,7 +955,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Ping, + &Method::Ping, RadrootsNostrConnectResponseEnvelope { id: "req-error".to_owned(), result: Some(json!("partial")), @@ -1070,7 +970,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-event".to_owned(), result: Some(serde_json::to_value(&event).expect("event value")), @@ -1082,7 +982,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!("null")), @@ -1094,7 +994,7 @@ fn response_surface_covers_success_and_error_paths() { ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!(format!("[\"{RELAY_SECONDARY_WSS}\"]"))), @@ -1111,7 +1011,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-auth".to_owned(), result: Some(json!("auth_url")), @@ -1122,7 +1022,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetPublicKey, + &Method::GetPublicKey, RadrootsNostrConnectResponseEnvelope { id: "req-key".to_owned(), result: Some(json!("bad-key")), @@ -1133,7 +1033,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Connect, + &Method::Connect, RadrootsNostrConnectResponseEnvelope { id: "req-connect".to_owned(), result: None, @@ -1144,7 +1044,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetPublicKey, + &Method::GetPublicKey, RadrootsNostrConnectResponseEnvelope { id: "req-key".to_owned(), result: None, @@ -1155,7 +1055,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Ping, + &Method::Ping, RadrootsNostrConnectResponseEnvelope { id: "req-ping".to_owned(), result: Some(json!("nope")), @@ -1166,7 +1066,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Ping, + &Method::Ping, RadrootsNostrConnectResponseEnvelope { id: "req-ping".to_owned(), result: None, @@ -1177,7 +1077,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Nip04Encrypt, + &Method::Nip04Encrypt, RadrootsNostrConnectResponseEnvelope { id: "req-nip04".to_owned(), result: Some(json!(5)), @@ -1188,7 +1088,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Nip04Encrypt, + &Method::Nip04Encrypt, RadrootsNostrConnectResponseEnvelope { id: "req-nip04".to_owned(), result: None, @@ -1199,7 +1099,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-event".to_owned(), result: Some(json!("not-json")), @@ -1210,7 +1110,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-event".to_owned(), result: Some(json!(5)), @@ -1221,7 +1121,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-event".to_owned(), result: None, @@ -1232,7 +1132,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Nip04Decrypt, + &Method::Nip04Decrypt, RadrootsNostrConnectResponseEnvelope { id: "req-nip04d".to_owned(), result: None, @@ -1243,7 +1143,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Nip44Encrypt, + &Method::Nip44Encrypt, RadrootsNostrConnectResponseEnvelope { id: "req-nip44e".to_owned(), result: None, @@ -1254,7 +1154,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Nip44Decrypt, + &Method::Nip44Decrypt, RadrootsNostrConnectResponseEnvelope { id: "req-nip44d".to_owned(), result: None, @@ -1265,7 +1165,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!("[invalid")), @@ -1276,7 +1176,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!([1])), @@ -1287,7 +1187,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!(["http://relay.example.com"])), @@ -1298,7 +1198,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(json!(5)), @@ -1309,7 +1209,7 @@ fn response_surface_covers_success_and_error_paths() { )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Logout, + &Method::Logout, RadrootsNostrConnectResponseEnvelope { id: "req-logout".to_owned(), result: Some(json!("not-ack")), @@ -1327,12 +1227,9 @@ fn pending_connection_poll_outcome_uses_typed_variants() { radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability { user_public_key: test_public_key(), relays: vec![relay(RELAY_PRIMARY_WSS), relay(RELAY_SECONDARY_WSS)], - permissions: RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "kind:1", - ), + permissions: Permissions::from(vec![ + Permission::new(Method::Ping), + Permission::with_parameter(Method::SignEvent, "kind:1"), ]), }; diff --git a/crates/nostr_connect/tests/package_boundary.rs b/crates/nostr_connect/tests/package_boundary.rs @@ -1,8 +1,11 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); +const METHOD: &str = include_str!("../src/method.rs"); +const PERMISSION: &str = include_str!("../src/permission.rs"); const ROOT: &str = include_str!("../src/lib.rs"); const SERVER: &str = include_str!("../src/server.rs"); +const URI: &str = include_str!("../src/uri.rs"); #[test] fn manifest_has_final_identity_feature_vocabulary_and_radroots_dependencies() { @@ -66,6 +69,35 @@ fn crate_root_contains_the_approved_module_skeleton() { ); } +#[test] +fn uri_method_and_permission_types_use_canonical_owners_and_names() { + for root_export in [ + "pub use method::Method;", + "pub use permission::Permission;", + "pub use uri::{BunkerUri, ClientUri};", + ] { + assert!(ROOT.contains(root_export), "missing `{root_export}`"); + } + for forbidden in [ + "pub enum RadrootsNostrConnectMethod", + "pub struct RadrootsNostrConnectPermission", + "pub struct RadrootsNostrConnectPermissions", + ] { + assert!(!METHOD.contains(forbidden)); + assert!(!PERMISSION.contains(forbidden)); + } + for forbidden in [ + "pub struct RadrootsNostrConnectBunkerUri", + "pub struct RadrootsNostrConnectClientUri", + "pub enum RadrootsNostrConnectUri", + "use nostr::{PublicKey", + ] { + assert!(!URI.contains(forbidden), "URI source retains `{forbidden}`"); + } + assert!(URI.contains("use radroots_identity::PublicKey;")); + assert!(URI.contains("radroots_nostr::key::parse_public_key")); +} + fn table_keys<'a>(source: &'a str, header: &str) -> BTreeSet<&'a str> { let Some((_, tail)) = source.split_once(header) else { panic!("manifest is missing {header}"); diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs @@ -3,12 +3,10 @@ mod test_fixtures; use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES, - RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, - RadrootsNostrConnectClientMetadata, RadrootsNostrConnectError, RadrootsNostrConnectMethod, - RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, - RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectUri, + CLIENT_METADATA_JSON_MAX_BYTES, CLIENT_NAME_MAX_BYTES, ClientMetadata, Method, Permission, + Permissions, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, Uri, }; use serde_json::{Value, json}; use test_fixtures::{ @@ -20,6 +18,11 @@ fn test_public_key() -> PublicKey { PublicKey::parse(FIXTURE_ALICE.public_key_hex).expect("public key") } +fn test_identity_public_key() -> radroots_identity::PublicKey { + radroots_identity::PublicKey::from_hex(FIXTURE_ALICE.public_key_hex) + .expect("identity public key") +} + fn test_keys() -> Keys { let secret_key = SecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).expect("secret key"); Keys::new(secret_key) @@ -41,12 +44,9 @@ fn remote_session_capability() RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay 1"), RelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 2"), ], - permissions: RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "kind:1", - ), + permissions: Permissions::from(vec![ + Permission::new(Method::Ping), + Permission::with_parameter(Method::SignEvent, "kind:1"), ]), } } @@ -61,29 +61,29 @@ fn parses_client_uri_with_current_spec_query_fields() { encode_uri_component(APP_PRIMARY_HTTPS), encode_uri_component(&logo_url()), ); - let parsed = RadrootsNostrConnectUri::parse(&uri).expect("parse client uri"); + let parsed = Uri::parse(&uri).expect("parse client uri"); match parsed { - RadrootsNostrConnectUri::Client(client) => { - assert_eq!(client.client_public_key, test_public_key()); - assert_eq!(client.relays.len(), 2); - assert_eq!(client.secret, "0s8j2djs"); - assert_eq!(client.metadata.name.as_deref(), Some("My Client")); + Uri::Client(client) => { + assert_eq!(client.client_public_key(), test_identity_public_key()); + assert_eq!(client.relays().len(), 2); + assert_eq!(client.secret(), "0s8j2djs"); + assert_eq!(client.metadata().name.as_deref(), Some("My Client")); assert_eq!( - client.metadata.requested_permissions, - RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt,), - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "1059", - ), + client.metadata().requested_permissions, + Permissions::from(vec![ + Permission::new(Method::Nip44Encrypt,), + Permission::with_parameter(Method::SignEvent, "1059",), ]) ); assert_eq!( - client.metadata.url.as_deref(), + client.metadata().url.as_deref(), Some(format!("{APP_PRIMARY_HTTPS}/").as_str()) ); - assert_eq!(client.metadata.image.as_deref(), Some(logo_url().as_str())); + assert_eq!( + client.metadata().image.as_deref(), + Some(logo_url().as_str()) + ); } other => panic!("expected client uri, got {other:?}"), } @@ -96,9 +96,9 @@ fn parses_bunker_uri_and_roundtrips() { FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), ); - let parsed = RadrootsNostrConnectUri::parse(&source).expect("parse bunker uri"); + let parsed = Uri::parse(&source).expect("parse bunker uri"); let rendered = parsed.to_string(); - let reparsed = RadrootsNostrConnectUri::parse(&rendered).expect("reparse bunker uri"); + let reparsed = Uri::parse(&rendered).expect("reparse bunker uri"); assert_eq!(parsed, reparsed); } @@ -109,19 +109,19 @@ fn rejects_client_uri_without_required_secret() { FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), ); - assert!(RadrootsNostrConnectUri::parse(&source).is_err()); + assert!(Uri::parse(&source).is_err()); } #[test] fn requested_permissions_roundtrip_as_csv() { - let permissions = RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), - RadrootsNostrConnectPermission::with_parameter(RadrootsNostrConnectMethod::SignEvent, "13"), + let permissions = Permissions::from(vec![ + Permission::new(Method::Nip44Encrypt), + Permission::with_parameter(Method::SignEvent, "13"), ]); let rendered = permissions.to_string(); assert_eq!(rendered, "nip44_encrypt,sign_event:13"); - let reparsed: RadrootsNostrConnectPermissions = rendered.parse().expect("parse permissions"); + let reparsed: Permissions = rendered.parse().expect("parse permissions"); assert_eq!(permissions, reparsed); } @@ -130,12 +130,9 @@ fn connect_request_roundtrips_requested_permissions() { let request = RadrootsNostrConnectRequest::Connect { remote_signer_public_key: test_public_key(), secret: Some("abcd".to_owned()), - requested_permissions: RadrootsNostrConnectPermissions::from(vec![ - RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), - RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::SignEvent, - "1059", - ), + requested_permissions: Permissions::from(vec![ + Permission::new(Method::Nip44Encrypt), + Permission::with_parameter(Method::SignEvent, "1059"), ]), client_metadata: None, }; @@ -164,9 +161,9 @@ fn connect_request_roundtrips_client_metadata_in_fourth_parameter() { let request = RadrootsNostrConnectRequest::Connect { remote_signer_public_key: test_public_key(), secret: None, - requested_permissions: RadrootsNostrConnectPermissions::default(), - client_metadata: Some(RadrootsNostrConnectClientMetadata { - requested_permissions: RadrootsNostrConnectPermissions::default(), + requested_permissions: Permissions::default(), + client_metadata: Some(ClientMetadata { + requested_permissions: Permissions::default(), name: Some(" My Client ".to_owned()), url: Some(APP_PRIMARY_HTTPS.to_owned()), image: Some(logo_url()), @@ -219,7 +216,7 @@ fn logout_request_and_acknowledgement_roundtrip() { ); let response = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::Logout, + &Method::Logout, RadrootsNostrConnectResponseEnvelope { id: "req-logout".to_owned(), result: Some(Value::String("ack".to_owned())), @@ -257,11 +254,11 @@ fn rejects_invalid_client_metadata() { encode_uri_component(RELAY_PRIMARY_WSS), encode_uri_component("file:///tmp/client"), ); - assert!(RadrootsNostrConnectUri::parse(&invalid_scheme).is_err()); + assert!(Uri::parse(&invalid_scheme).is_err()); - let oversized_name = RadrootsNostrConnectClientMetadata { - requested_permissions: RadrootsNostrConnectPermissions::default(), - name: Some("a".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES + 1)), + let oversized_name = ClientMetadata { + requested_permissions: Permissions::default(), + name: Some("a".repeat(CLIENT_NAME_MAX_BYTES + 1)), url: None, image: None, }; @@ -270,10 +267,10 @@ fn rejects_invalid_client_metadata() { Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "name", .. }) )); - let oversized_payload = "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES + 1); + let oversized_payload = "x".repeat(CLIENT_METADATA_JSON_MAX_BYTES + 1); assert!(matches!( RadrootsNostrConnectRequest::from_parts( - RadrootsNostrConnectMethod::Connect, + Method::Connect, vec![ test_public_key().to_hex(), String::new(), @@ -319,11 +316,9 @@ fn switch_relays_response_accepts_array_or_null() { result: Some(json!([RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS])), error: None, }; - let parsed = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, - relays_response, - ) - .expect("parse relay list"); + let parsed = + RadrootsNostrConnectResponse::from_envelope(&Method::SwitchRelays, relays_response) + .expect("parse relay list"); assert_eq!( parsed, RadrootsNostrConnectResponse::RelayList(vec![ @@ -333,7 +328,7 @@ fn switch_relays_response_accepts_array_or_null() { ); let unchanged = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SwitchRelays, + &Method::SwitchRelays, RadrootsNostrConnectResponseEnvelope { id: "req-switch".to_owned(), result: Some(Value::Null), @@ -361,7 +356,7 @@ fn get_session_capability_request_and_response_roundtrip() { .into_envelope("resp-cap") .expect("serialize response"); let decoded_response = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetSessionCapability, + &Method::GetSessionCapability, response_envelope, ) .expect("deserialize response"); @@ -374,7 +369,7 @@ fn get_session_capability_request_and_response_roundtrip() { #[test] fn auth_url_response_parses_from_result_and_error_fields() { let response = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, + &Method::SignEvent, RadrootsNostrConnectResponseEnvelope { id: "req-auth".to_owned(), result: Some(json!("auth_url")), @@ -392,7 +387,7 @@ fn auth_url_response_parses_from_result_and_error_fields() { #[test] fn get_public_key_pending_response_parses_as_typed_pending_connection() { let response = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::GetPublicKey, + &Method::GetPublicKey, RadrootsNostrConnectResponseEnvelope { id: "req-pending".to_owned(), result: None, @@ -415,11 +410,8 @@ fn sign_event_response_roundtrips_signed_event_json_string() { let envelope = RadrootsNostrConnectResponse::SignedEvent(event.clone()) .into_envelope("req-sign") .expect("serialize response"); - let parsed = RadrootsNostrConnectResponse::from_envelope( - &RadrootsNostrConnectMethod::SignEvent, - envelope, - ) - .expect("parse signed event response"); + let parsed = RadrootsNostrConnectResponse::from_envelope(&Method::SignEvent, envelope) + .expect("parse signed event response"); assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event)); } @@ -458,29 +450,29 @@ fn checked_in_current_session_vectors_match_protocol_behavior() { "nip46.metadata.invalid" => { let count = input["count"].as_u64().expect("metadata repeat count") as usize; let repeat = input["repeat"].as_str().expect("metadata repeat value"); - let metadata = RadrootsNostrConnectClientMetadata { + let metadata = ClientMetadata { name: Some(repeat.repeat(count)), - ..RadrootsNostrConnectClientMetadata::default() + ..ClientMetadata::default() }; let error = metadata.normalized().expect_err("invalid metadata vector"); assert_vector_error(id, expected, error); } "nip46.uri.valid" => { let uri = input["uri"].as_str().expect("NIP-46 URI"); - let parsed = RadrootsNostrConnectUri::parse(uri) - .unwrap_or_else(|error| panic!("{id}: parse URI: {error}")); + let parsed = + Uri::parse(uri).unwrap_or_else(|error| panic!("{id}: parse URI: {error}")); assert_uri_vector(id, parsed, expected); } "nip46.uri.invalid" => { let uri = input["uri"].as_str().expect("NIP-46 URI"); - let error = RadrootsNostrConnectUri::parse(uri).expect_err("invalid URI vector"); + let error = Uri::parse(uri).expect_err("invalid URI vector"); assert_vector_error(id, expected, error); } "nip46.response.valid" => { let method = input["method"] .as_str() .expect("response method") - .parse::<RadrootsNostrConnectMethod>() + .parse::<Method>() .expect("typed response method"); let envelope: RadrootsNostrConnectResponseEnvelope = serde_json::from_value(input["envelope"].clone()) @@ -499,7 +491,7 @@ fn checked_in_current_session_vectors_match_protocol_behavior() { let method = input["method"] .as_str() .expect("response method") - .parse::<RadrootsNostrConnectMethod>() + .parse::<Method>() .expect("typed response method"); let envelope: RadrootsNostrConnectResponseEnvelope = serde_json::from_value(input["envelope"].clone()) @@ -513,7 +505,7 @@ fn checked_in_current_session_vectors_match_protocol_behavior() { } } -fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value) { +fn assert_uri_vector(id: &str, parsed: Uri, expected: &Value) { let expected_relays = expected["relays"] .as_array() .expect("expected relays") @@ -522,42 +514,42 @@ fn assert_uri_vector(id: &str, parsed: RadrootsNostrConnectUri, expected: &Value .collect::<Vec<_>>(); match parsed { - RadrootsNostrConnectUri::Bunker(uri) => { + Uri::Bunker(uri) => { assert_eq!(expected["variant"], "bunker", "{id}"); let relays = uri - .relays + .relays() .iter() .map(ToString::to_string) .collect::<Vec<_>>(); assert_eq!(relays, expected_relays, "{id}"); - assert_eq!(uri.secret.as_deref(), expected["secret"].as_str(), "{id}"); + assert_eq!(uri.secret(), expected["secret"].as_str(), "{id}"); } - RadrootsNostrConnectUri::Client(uri) => { + Uri::Client(uri) => { assert_eq!(expected["variant"], "nostrconnect", "{id}"); let relays = uri - .relays + .relays() .iter() .map(ToString::to_string) .collect::<Vec<_>>(); assert_eq!(relays, expected_relays, "{id}"); - assert_eq!(uri.secret, expected["secret"].as_str().expect("secret")); + assert_eq!(uri.secret(), expected["secret"].as_str().expect("secret")); assert_eq!( - uri.metadata.name.as_deref(), + uri.metadata().name.as_deref(), expected["metadata"]["name"].as_str(), "{id}" ); assert_eq!( - uri.metadata.url.as_deref(), + uri.metadata().url.as_deref(), expected["metadata"]["url"].as_str(), "{id}" ); assert_eq!( - uri.metadata.image.as_deref(), + uri.metadata().image.as_deref(), expected["metadata"]["image"].as_str(), "{id}" ); assert_eq!( - uri.metadata.requested_permissions.to_string(), + uri.metadata().requested_permissions.to_string(), expected["metadata"]["permissions"] .as_str() .expect("permissions"), diff --git a/crates/nostr_connect/tests/type_contract.rs b/crates/nostr_connect/tests/type_contract.rs @@ -0,0 +1,140 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use std::str::FromStr; + +use radroots_nostr_connect::{ + BunkerUri, ClientUri, Method, Permission, + permission::{PERMISSION_COUNT_MAX, PERMISSION_PARAMETER_MAX_BYTES, Permissions}, + uri::{ClientMetadata, Uri}, +}; + +use test_fixtures::{FIXTURE_ALICE, RELAY_PRIMARY_WSS}; + +#[test] +fn methods_and_permissions_are_bounded_and_canonical() { + assert_eq!( + Method::custom("publish_note").expect("custom method"), + Method::from_str("publish_note").expect("parsed custom method") + ); + for invalid in [ + "", + "PublishNote", + "publish-note", + &"x".repeat(radroots_nostr_connect::method::METHOD_MAX_BYTES + 1), + ] { + assert!(Method::from_str(invalid).is_err(), "accepted `{invalid}`"); + } + assert!(Method::custom("not canonical").is_err()); + + let sign_event = + Permission::try_with_parameter(Method::SignEvent, "kind:1").expect("bounded permission"); + assert_eq!(sign_event.method(), &Method::SignEvent); + assert_eq!(sign_event.parameter(), Some("kind:1")); + assert!( + Permission::try_with_parameter( + Method::SignEvent, + "x".repeat(PERMISSION_PARAMETER_MAX_BYTES + 1), + ) + .is_err() + ); + + let permissions = Permissions::try_from_vec(vec![ + sign_event.clone(), + Permission::new(Method::Ping), + sign_event, + ]) + .expect("canonical permissions"); + assert_eq!(permissions.to_string(), "ping,sign_event:kind:1"); + assert_eq!(permissions.as_slice().len(), 2); + assert!( + Permissions::try_from_vec( + (0..=PERMISSION_COUNT_MAX) + .map(|index| { + Permission::try_with_parameter(Method::SignEvent, index.to_string()) + .expect("bounded parameter") + }) + .collect(), + ) + .is_err() + ); +} + +#[test] +fn uri_keys_are_identity_owned_and_secret_diagnostics_are_redacted() { + let encoded_relay: String = + url::form_urlencoded::byte_serialize(RELAY_PRIMARY_WSS.as_bytes()).collect(); + let source = format!( + "bunker://{}?relay={encoded_relay}&secret=do-not-log", + FIXTURE_ALICE.npub + ); + let uri = Uri::parse(&source).expect("bunker URI"); + let Uri::Bunker(bunker) = &uri else { + panic!("expected bunker URI"); + }; + + let key: radroots_identity::PublicKey = bunker.remote_signer_public_key(); + assert_eq!(key.to_hex(), FIXTURE_ALICE.public_key_hex); + assert_eq!(bunker.relays().len(), 1); + assert_eq!(bunker.secret(), Some("do-not-log")); + assert!(!format!("{bunker:?}").contains("do-not-log")); + assert!(!format!("{uri:?}").contains("do-not-log")); + + let canonical = uri.to_string(); + assert!(canonical.starts_with(&format!("bunker://{}?", FIXTURE_ALICE.public_key_hex))); + let serialized = serde_json::to_string(&uri).expect("serialize URI"); + let decoded: Uri = serde_json::from_str(&serialized).expect("deserialize URI"); + assert_eq!(decoded, uri); + + let duplicate = format!( + "bunker://{}?relay={encoded_relay}&secret=one&secret=two", + FIXTURE_ALICE.public_key_hex + ); + assert!(Uri::parse(&duplicate).is_err()); + let duplicate_relay = format!( + "bunker://{}?relay={encoded_relay}&relay={encoded_relay}", + FIXTURE_ALICE.public_key_hex + ); + let Uri::Bunker(deduplicated) = Uri::parse(&duplicate_relay).expect("deduplicated relay") + else { + panic!("expected bunker URI"); + }; + assert_eq!(deduplicated.relays().len(), 1); + assert!( + Uri::parse(&format!( + "bunker://{}?relay={encoded_relay}&secret={}", + FIXTURE_ALICE.public_key_hex, + "x".repeat(radroots_nostr_connect::uri::SECRET_MAX_BYTES + 1) + )) + .is_err() + ); + let malformed = "not a uri?secret=do-not-log"; + let error = Uri::parse(malformed).expect_err("malformed URI"); + assert!(!error.to_string().contains("do-not-log")); + assert!(!format!("{error:?}").contains("do-not-log")); + + let _: &BunkerUri = bunker; + let _client_type: Option<&ClientUri> = None; +} + +#[test] +fn client_metadata_serialization_revalidates_public_fields() { + let metadata = ClientMetadata::new() + .with_name(" My Client ") + .expect("name") + .with_url("https://client.example.com") + .expect("URL") + .with_requested_permissions( + Permissions::try_from_vec(vec![Permission::new(Method::Ping)]).expect("permissions"), + ); + assert_eq!(metadata.name(), Some("My Client")); + assert_eq!(metadata.url(), Some("https://client.example.com/")); + assert!(metadata.image().is_none()); + assert_eq!(metadata.requested_permissions().to_string(), "ping"); + + let invalid = ClientMetadata { + name: Some("x".repeat(radroots_nostr_connect::uri::CLIENT_NAME_MAX_BYTES + 1)), + ..ClientMetadata::default() + }; + assert!(serde_json::to_string(&invalid).is_err()); +} diff --git a/crates/nostr_signer/Cargo.toml b/crates/nostr_signer/Cargo.toml @@ -22,7 +22,7 @@ radroots_identity = { workspace = true, default-features = false, features = [ "serde", "std", ] } -radroots_nostr = { workspace = true, features = ["std"] } +radroots_nostr = { workspace = true, features = ["events", "std"] } radroots_nostr_connect = { workspace = true } radroots_runtime = { workspace = true } radroots_sql_core = { workspace = true, optional = true } diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -430,12 +430,13 @@ mod tests { )); assert!(!request_allowed_by_permissions( &vec![RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::Custom("do_thing".into()), + RadrootsNostrConnectMethod::custom("do_thing").expect("valid custom NIP-46 method"), "scoped", )] .into(), &RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("do_thing".into()), + method: RadrootsNostrConnectMethod::custom("do_thing") + .expect("valid custom NIP-46 method"), params: vec!["value".into()], }, )); @@ -445,11 +446,11 @@ mod tests { )); assert!(permission_matches( &RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::Custom("scoped".into()), + RadrootsNostrConnectMethod::custom("scoped").expect("valid custom NIP-46 method"), "alpha", ), &RadrootsNostrConnectPermission::with_parameter( - RadrootsNostrConnectMethod::Custom("scoped".into()), + RadrootsNostrConnectMethod::custom("scoped").expect("valid custom NIP-46 method"), "alpha", ), )); @@ -471,7 +472,8 @@ mod tests { let switch_relays = RadrootsNostrConnectRequest::SwitchRelays; let sign_event = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7)); let custom = RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("do_thing".into()), + method: RadrootsNostrConnectMethod::custom("do_thing") + .expect("valid custom NIP-46 method"), params: vec!["a".into()], }; diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -1570,7 +1570,8 @@ mod tests { request_message( "req-custom", RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), + method: RadrootsNostrConnectMethod::custom("publish_note") + .expect("valid custom NIP-46 method"), params: vec![], }, ), @@ -1876,7 +1877,8 @@ mod tests { let custom = request_message( "req-eval-custom", RadrootsNostrConnectRequest::Custom { - method: RadrootsNostrConnectMethod::Custom("do_work".to_owned()), + method: RadrootsNostrConnectMethod::custom("do_work") + .expect("valid custom NIP-46 method"), params: vec![], }, );