lib

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

commit 6693cf3f2c4a138a68db2c145ac9e951b9788dbd
parent 7a50af9cf9955f8ba42e6bf2242909b323f7c18c
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Jul 2026 21:35:05 +0000

nostr_connect: implement current session wire models

- accept bounded client metadata in the fourth connect parameter
- preserve legacy connect forms and URI relay ordering
- add typed logout requests and acknowledgement responses
- validate unsafe metadata without exposing payload values

Diffstat:
Mcrates/nostr_connect/src/error.rs | 4++++
Mcrates/nostr_connect/src/lib.rs | 2++
Mcrates/nostr_connect/src/message.rs | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mcrates/nostr_connect/src/method.rs | 3+++
Mcrates/nostr_connect/src/uri.rs | 154++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/nostr_connect/tests/client.rs | 1+
Mcrates/nostr_connect/tests/coverage.rs | 25++++++++++++++++++++-----
Mcrates/nostr_connect/tests/protocol.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr_signer/src/backend.rs | 2++
Mcrates/nostr_signer/src/evaluation.rs | 4+++-
Mcrates/nostr_signer/src/manager.rs | 9+++++++++
Mcrates/nostr_signer/src/nip46.rs | 2++
12 files changed, 380 insertions(+), 36 deletions(-)

diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs @@ -22,6 +22,10 @@ pub enum RadrootsNostrConnectError { InvalidRelayUrl { value: String, reason: String }, #[error("invalid url `{value}`: {reason}")] InvalidUrl { value: String, reason: String }, + #[error("invalid NIP-46 client metadata field `{field}`: {reason}")] + InvalidClientMetadata { field: &'static str, reason: String }, + #[error("NIP-46 client metadata exceeds {max} bytes (received {received})")] + ClientMetadataTooLarge { max: usize, received: usize }, #[error("invalid URI scheme `{0}`")] InvalidUriScheme(String), #[error("invalid NIP-46 uri")] diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -26,6 +26,8 @@ pub mod prelude { pub use crate::method::RadrootsNostrConnectMethod; pub use crate::permission::{RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions}; 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, }; diff --git a/crates/nostr_connect/src/message.rs b/crates/nostr_connect/src/message.rs @@ -1,6 +1,7 @@ use crate::error::RadrootsNostrConnectError; use crate::method::RadrootsNostrConnectMethod; use crate::permission::RadrootsNostrConnectPermissions; +use crate::uri::RadrootsNostrConnectClientMetadata; use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; @@ -22,6 +23,7 @@ pub enum RadrootsNostrConnectRequest { remote_signer_public_key: PublicKey, secret: Option<String>, requested_permissions: RadrootsNostrConnectPermissions, + client_metadata: Option<RadrootsNostrConnectClientMetadata>, }, GetPublicKey, GetSessionCapability, @@ -44,6 +46,7 @@ pub enum RadrootsNostrConnectRequest { }, Ping, SwitchRelays, + Logout, Custom { method: RadrootsNostrConnectMethod, params: Vec<String>, @@ -63,30 +66,40 @@ impl RadrootsNostrConnectRequest { Self::Nip44Decrypt { .. } => RadrootsNostrConnectMethod::Nip44Decrypt, Self::Ping => RadrootsNostrConnectMethod::Ping, Self::SwitchRelays => RadrootsNostrConnectMethod::SwitchRelays, + Self::Logout => RadrootsNostrConnectMethod::Logout, Self::Custom { method, .. } => method.clone(), } } - pub fn to_params(&self) -> Vec<String> { - match self { + pub fn to_params(&self) -> Result<Vec<String>, RadrootsNostrConnectError> { + let params = match self { Self::Connect { remote_signer_public_key, secret, requested_permissions, + client_metadata, } => { let mut params = vec![remote_signer_public_key.to_hex()]; let normalized_secret = secret.as_ref().filter(|value| !value.is_empty()).cloned(); - if normalized_secret.is_some() || !requested_permissions.is_empty() { + if normalized_secret.is_some() + || !requested_permissions.is_empty() + || client_metadata.is_some() + { params.push(normalized_secret.unwrap_or_default()); } - if !requested_permissions.is_empty() { + if !requested_permissions.is_empty() || client_metadata.is_some() { params.push(requested_permissions.to_string()); } + if let Some(client_metadata) = client_metadata { + params.push(client_metadata.to_connect_param()?); + } params } - Self::GetPublicKey | Self::GetSessionCapability | Self::Ping | Self::SwitchRelays => { - Vec::new() - } + Self::GetPublicKey + | Self::GetSessionCapability + | Self::Ping + | Self::SwitchRelays + | Self::Logout => Vec::new(), Self::SignEvent(unsigned_event) => vec![unsigned_event.as_json()], Self::Nip04Encrypt { public_key, @@ -105,7 +118,8 @@ impl RadrootsNostrConnectRequest { ciphertext, } => vec![public_key.to_hex(), ciphertext.clone()], Self::Custom { params, .. } => params.clone(), - } + }; + Ok(params) } pub fn from_parts( @@ -114,10 +128,10 @@ impl RadrootsNostrConnectRequest { ) -> Result<Self, RadrootsNostrConnectError> { match method { RadrootsNostrConnectMethod::Connect => { - if params.is_empty() || params.len() > 3 { + if params.is_empty() || params.len() > 4 { return Err(RadrootsNostrConnectError::InvalidParams { method: method.to_string(), - expected: "1 to 3 params", + expected: "1 to 4 params", received: params.len(), }); } @@ -127,10 +141,15 @@ impl RadrootsNostrConnectRequest { Some(value) => RadrootsNostrConnectPermissions::from_str(value)?, None => RadrootsNostrConnectPermissions::default(), }; + let client_metadata = params + .get(3) + .map(|value| RadrootsNostrConnectClientMetadata::from_connect_param(value)) + .transpose()?; Ok(Self::Connect { remote_signer_public_key, secret, requested_permissions, + client_metadata, }) } RadrootsNostrConnectMethod::GetPublicKey => { @@ -187,6 +206,10 @@ impl RadrootsNostrConnectRequest { expect_param_count(&method, &params, 0)?; Ok(Self::SwitchRelays) } + RadrootsNostrConnectMethod::Logout => { + expect_param_count(&method, &params, 0)?; + Ok(Self::Logout) + } custom => Ok(Self::Custom { method: custom, params, @@ -209,12 +232,12 @@ impl RadrootsNostrConnectRequestMessage { } } - fn into_raw(self) -> RawRequestMessage { - RawRequestMessage { + fn into_raw(self) -> Result<RawRequestMessage, RadrootsNostrConnectError> { + Ok(RawRequestMessage { id: self.id, method: self.request.method(), - params: self.request.to_params(), - } + params: self.request.to_params()?, + }) } fn from_raw(raw: RawRequestMessage) -> Result<Self, RadrootsNostrConnectError> { @@ -230,7 +253,10 @@ impl Serialize for RadrootsNostrConnectRequestMessage { where S: Serializer, { - self.clone().into_raw().serialize(serializer) + self.clone() + .into_raw() + .map_err(serde::ser::Error::custom)? + .serialize(serializer) } } @@ -269,6 +295,7 @@ pub enum RadrootsNostrConnectPendingConnectionPollOutcome { pub enum RadrootsNostrConnectResponse { ConnectAcknowledged, ConnectSecretEcho(String), + LogoutAcknowledged, PendingConnection, UserPublicKey(PublicKey), RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability), @@ -328,11 +355,13 @@ impl RadrootsNostrConnectResponse { ) -> Result<RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectError> { let id = id.into(); let envelope = match self { - Self::ConnectAcknowledged => RadrootsNostrConnectResponseEnvelope { - id, - result: Some(Value::String("ack".to_owned())), - error: None, - }, + Self::ConnectAcknowledged | Self::LogoutAcknowledged => { + RadrootsNostrConnectResponseEnvelope { + id, + result: Some(Value::String("ack".to_owned())), + error: None, + } + } Self::ConnectSecretEcho(secret) => RadrootsNostrConnectResponseEnvelope { id, result: Some(Value::String(secret)), @@ -487,6 +516,16 @@ impl RadrootsNostrConnectResponse { RadrootsNostrConnectMethod::SwitchRelays => { parse_switch_relays_response(envelope.result) } + RadrootsNostrConnectMethod::Logout => { + let result = expect_string_result(method, envelope.result)?; + if result != "ack" { + return Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: format!("expected `ack`, got `{result}`"), + }); + } + Ok(Self::LogoutAcknowledged) + } RadrootsNostrConnectMethod::Custom(_) => Ok(Self::Custom { result: envelope.result, error: None, diff --git a/crates/nostr_connect/src/method.rs b/crates/nostr_connect/src/method.rs @@ -15,6 +15,7 @@ pub enum RadrootsNostrConnectMethod { Nip44Decrypt, Ping, SwitchRelays, + Logout, Custom(String), } @@ -31,6 +32,7 @@ impl RadrootsNostrConnectMethod { Self::Nip44Decrypt => "nip44_decrypt", Self::Ping => "ping", Self::SwitchRelays => "switch_relays", + Self::Logout => "logout", Self::Custom(value) => value.as_str(), } } @@ -57,6 +59,7 @@ impl FromStr for RadrootsNostrConnectMethod { "nip44_decrypt" => Ok(Self::Nip44Decrypt), "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())), } diff --git a/crates/nostr_connect/src/uri.rs b/crates/nostr_connect/src/uri.rs @@ -8,6 +8,9 @@ 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 { @@ -32,6 +35,70 @@ pub struct RadrootsNostrConnectClientMetadata { pub image: Option<String>, } +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +struct RadrootsNostrConnectClientMetadataWire { + #[serde(default, skip_serializing_if = "Option::is_none")] + name: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + url: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + image: Option<String>, +} + +impl RadrootsNostrConnectClientMetadata { + pub fn normalized(self) -> Result<Self, RadrootsNostrConnectError> { + Ok(Self { + requested_permissions: self.requested_permissions, + name: self + .name + .map(|value| normalize_client_name(&value)) + .transpose()?, + url: self + .url + .map(|value| normalize_client_url("url", &value)) + .transpose()?, + image: self + .image + .map(|value| normalize_client_url("image", &value)) + .transpose()?, + }) + } + + pub fn to_connect_param(&self) -> Result<String, RadrootsNostrConnectError> { + let normalized = self.clone().normalized()?; + let wire = RadrootsNostrConnectClientMetadataWire { + name: normalized.name, + url: normalized.url, + image: normalized.image, + }; + let value = serde_json::to_string(&wire)?; + validate_metadata_size(&value)?; + Ok(value) + } + + 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(), + } + })?; + Self { + requested_permissions: RadrootsNostrConnectPermissions::default(), + name: wire.name, + url: wire.url, + image: wire.image, + } + .normalized() + } + + pub fn is_display_empty(&self) -> bool { + self.name.is_none() && self.url.is_none() && self.image.is_none() + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsNostrConnectClientUri { pub client_public_key: PublicKey, @@ -96,8 +163,8 @@ impl RadrootsNostrConnectUri { RadrootsNostrConnectPermissions::from_str(value.as_ref())?; } "name" => metadata.name = Some(value.into_owned()), - "url" => metadata.url = Some(validate_url(value.as_ref())?), - "image" => metadata.image = Some(validate_url(value.as_ref())?), + "url" => metadata.url = Some(value.into_owned()), + "image" => metadata.image = Some(value.into_owned()), _ => {} } } @@ -107,6 +174,10 @@ impl RadrootsNostrConnectUri { } let secret = secret.ok_or(RadrootsNostrConnectError::MissingSecret)?; + if secret.is_empty() { + return Err(RadrootsNostrConnectError::MissingSecret); + } + let metadata = metadata.normalized()?; Ok(Self::Client(RadrootsNostrConnectClientUri { client_public_key, @@ -194,11 +265,76 @@ fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsNostrConnectError> { }) } -fn validate_url(value: &str) -> Result<String, RadrootsNostrConnectError> { - Url::parse(value) - .map(|url| url.to_string()) - .map_err(|error| RadrootsNostrConnectError::InvalidUrl { - value: value.to_owned(), - reason: error.to_string(), - }) +fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectError> { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(invalid_client_metadata("name", "must not be empty")); + } + if normalized.len() > RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES { + return Err(invalid_client_metadata( + "name", + format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_NAME_MAX_BYTES} UTF-8 bytes"), + )); + } + if normalized.chars().any(char::is_control) { + return Err(invalid_client_metadata( + "name", + "must not contain control characters", + )); + } + Ok(normalized.to_owned()) +} + +fn normalize_client_url( + field: &'static str, + value: &str, +) -> Result<String, RadrootsNostrConnectError> { + if value.len() > RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES { + return Err(invalid_client_metadata( + field, + format!("must not exceed {RADROOTS_NOSTR_CONNECT_CLIENT_URL_MAX_BYTES} UTF-8 bytes"), + )); + } + if value.chars().any(char::is_control) { + return Err(invalid_client_metadata( + field, + "must not contain control characters", + )); + } + let parsed = + Url::parse(value).map_err(|error| invalid_client_metadata(field, error.to_string()))?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err(invalid_client_metadata( + field, + "must use the http or https scheme", + )); + } + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err(invalid_client_metadata( + field, + "must not contain credentials", + )); + } + Ok(parsed.to_string()) +} + +fn validate_metadata_size(value: &str) -> Result<(), RadrootsNostrConnectError> { + let received = value.len(); + if received > RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES { + return Err(RadrootsNostrConnectError::ClientMetadataTooLarge { + max: RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES, + received, + }); + } + Ok(()) +} + +fn invalid_client_metadata( + field: &'static str, + reason: impl Into<String>, +) -> RadrootsNostrConnectError { + RadrootsNostrConnectError::InvalidClientMetadata { + field, + reason: reason.into(), + } } diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs @@ -162,6 +162,7 @@ async fn executes_connect_request_and_secret_echo_response() { remote_signer_public_key: remote_keys.public_key(), secret: Some("connect-secret".to_owned()), requested_permissions: Vec::new().into(), + client_metadata: None, }, ), &mut transport, diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs @@ -332,7 +332,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), )), - Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "url", .. }) )); assert!(matches!( RadrootsNostrConnectUri::parse("bunker://bad-key?relay=wss%3A%2F%2Frelay.example.com"), @@ -359,7 +359,7 @@ fn uri_surface_covers_rendering_ignored_queries_and_error_paths() { FIXTURE_ALICE.public_key_hex, encode_uri_component(RELAY_PRIMARY_WSS), )), - Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "image", .. }) )); } @@ -376,6 +376,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { remote_signer_public_key: test_public_key(), secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, RadrootsNostrConnectMethod::Connect, vec![test_public_key().to_hex()], @@ -385,6 +386,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { remote_signer_public_key: test_public_key(), secret: None, requested_permissions: ping_permission.clone(), + client_metadata: None, }, RadrootsNostrConnectMethod::Connect, vec![test_public_key().to_hex(), String::new(), "ping".to_owned()], @@ -447,6 +449,11 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { Vec::new(), ), ( + RadrootsNostrConnectRequest::Logout, + RadrootsNostrConnectMethod::Logout, + Vec::new(), + ), + ( RadrootsNostrConnectRequest::Custom { method: RadrootsNostrConnectMethod::Custom("publish_note".to_owned()), params: vec!["one".to_owned(), "two".to_owned()], @@ -457,7 +464,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { ]; for (request, method, params) in requests { assert_eq!(request.method(), method); - assert_eq!(request.to_params(), params); + assert_eq!(request.to_params().expect("request params"), params); } assert_eq!( @@ -470,6 +477,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { remote_signer_public_key: test_public_key(), secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, } ); assert_eq!( @@ -484,6 +492,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { requested_permissions: RadrootsNostrConnectPermissions::from(vec![ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping), ]), + client_metadata: None, } ); assert_eq!( @@ -606,6 +615,11 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { vec!["oops".to_owned()], "no params", ), + ( + RadrootsNostrConnectMethod::Logout, + vec!["oops".to_owned()], + "no params", + ), ] { assert!(matches!( RadrootsNostrConnectRequest::from_parts(method, params), @@ -615,7 +629,7 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { assert!(matches!( RadrootsNostrConnectRequest::from_parts(RadrootsNostrConnectMethod::Connect, Vec::new()), Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. }) - if expected == "1 to 3 params" && received == 0 + if expected == "1 to 4 params" && received == 0 )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( @@ -639,10 +653,11 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { "secret".to_owned(), "ping".to_owned(), "extra".to_owned(), + "too-many".to_owned(), ], ), Err(RadrootsNostrConnectError::InvalidParams { expected, received, .. }) - if expected == "1 to 3 params" && received == 4 + if expected == "1 to 4 params" && received == 5 )); assert!(matches!( RadrootsNostrConnectRequest::from_parts( diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs @@ -3,7 +3,9 @@ mod test_fixtures; use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ - RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectMethod, + 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, @@ -135,6 +137,7 @@ fn connect_request_roundtrips_requested_permissions() { "1059", ), ]), + client_metadata: None, }; let message = RadrootsNostrConnectRequestMessage::new("req-1", request); let encoded = serde_json::to_value(&message).expect("serialize request"); @@ -157,6 +160,132 @@ fn connect_request_roundtrips_requested_permissions() { } #[test] +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(), + name: Some(" My Client ".to_owned()), + url: Some(APP_PRIMARY_HTTPS.to_owned()), + image: Some(logo_url()), + }), + }; + let message = RadrootsNostrConnectRequestMessage::new("req-metadata", request); + let encoded = serde_json::to_value(&message).expect("serialize metadata request"); + assert_eq!(encoded["params"][1], ""); + assert_eq!(encoded["params"][2], ""); + let encoded_metadata: Value = serde_json::from_str( + encoded["params"][3] + .as_str() + .expect("metadata parameter string"), + ) + .expect("metadata parameter json"); + assert_eq!( + encoded_metadata, + json!({ + "name": "My Client", + "url": format!("{APP_PRIMARY_HTTPS}/"), + "image": logo_url(), + }) + ); + + let decoded: RadrootsNostrConnectRequestMessage = + serde_json::from_value(encoded.clone()).expect("deserialize metadata request"); + match &decoded.request { + RadrootsNostrConnectRequest::Connect { + client_metadata: Some(metadata), + .. + } => { + assert_eq!(metadata.name.as_deref(), Some("My Client")); + assert!(metadata.requested_permissions.is_empty()); + } + other => panic!("expected connect metadata, got {other:?}"), + } + assert_eq!( + serde_json::to_value(&decoded).expect("re-encode normalized metadata"), + encoded + ); +} + +#[test] +fn logout_request_and_acknowledgement_roundtrip() { + let message = + RadrootsNostrConnectRequestMessage::new("req-logout", RadrootsNostrConnectRequest::Logout); + assert_eq!( + serde_json::to_value(&message).expect("serialize logout"), + json!({"id": "req-logout", "method": "logout", "params": []}) + ); + + let response = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectMethod::Logout, + RadrootsNostrConnectResponseEnvelope { + id: "req-logout".to_owned(), + result: Some(Value::String("ack".to_owned())), + error: None, + }, + ) + .expect("parse logout acknowledgement"); + assert_eq!(response, RadrootsNostrConnectResponse::LogoutAcknowledged); + assert_eq!( + response + .into_envelope("req-logout") + .expect("encode logout acknowledgement") + .result, + Some(Value::String("ack".to_owned())) + ); +} + +#[test] +fn rejects_invalid_client_metadata() { + let invalid_name = json!({ + "id": "req-invalid-name", + "method": "connect", + "params": [ + FIXTURE_ALICE.public_key_hex, + "", + "", + serde_json::to_string(&json!({"name": "line\nbreak"})).expect("metadata") + ] + }); + assert!(serde_json::from_value::<RadrootsNostrConnectRequestMessage>(invalid_name).is_err()); + + let invalid_scheme = format!( + "nostrconnect://{}?relay={}&secret=secret&url={}", + FIXTURE_ALICE.public_key_hex, + encode_uri_component(RELAY_PRIMARY_WSS), + encode_uri_component("file:///tmp/client"), + ); + assert!(RadrootsNostrConnectUri::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)), + url: None, + image: None, + }; + assert!(matches!( + oversized_name.to_connect_param(), + Err(RadrootsNostrConnectError::InvalidClientMetadata { field: "name", .. }) + )); + + let oversized_payload = "x".repeat(RADROOTS_NOSTR_CONNECT_CLIENT_METADATA_JSON_MAX_BYTES + 1); + assert!(matches!( + RadrootsNostrConnectRequest::from_parts( + RadrootsNostrConnectMethod::Connect, + vec![ + test_public_key().to_hex(), + String::new(), + String::new(), + oversized_payload, + ], + ), + Err(RadrootsNostrConnectError::ClientMetadataTooLarge { .. }) + )); +} + +#[test] fn sign_event_request_roundtrips_unsigned_event_payload() { let unsigned_event: UnsignedEvent = serde_json::from_value(json!({ "pubkey": test_public_key().to_hex(), diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs @@ -1333,6 +1333,7 @@ mod tests { RadrootsNostrConnectMethod::Ping, )] .into(), + client_metadata: None, }, ) .expect("connect evaluation"); @@ -1401,6 +1402,7 @@ mod tests { RadrootsNostrConnectMethod::Ping, )] .into(), + client_metadata: None, }, ) .expect("connect evaluation"); diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -105,7 +105,8 @@ pub(crate) fn required_permission_for_request( RadrootsNostrConnectRequest::Connect { .. } | RadrootsNostrConnectRequest::GetPublicKey | RadrootsNostrConnectRequest::GetSessionCapability - | RadrootsNostrConnectRequest::Ping => None, + | RadrootsNostrConnectRequest::Ping + | RadrootsNostrConnectRequest::Logout => None, RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { Some(RadrootsNostrConnectPermission::with_parameter( RadrootsNostrConnectMethod::SignEvent, @@ -453,6 +454,7 @@ mod tests { remote_signer_public_key: public_key, secret: Some("secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }; let ping = RadrootsNostrConnectRequest::Ping; let get_public_key = RadrootsNostrConnectRequest::GetPublicKey; diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -203,6 +203,7 @@ impl RadrootsNostrSignerManager { remote_signer_public_key, secret, requested_permissions, + client_metadata: _, } = request else { return Err(RadrootsNostrSignerError::InvalidState( @@ -3440,6 +3441,7 @@ mod tests { .expect("signer public key"), secret: Some("secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ) .expect_err("poisoned connect evaluation"); @@ -3712,6 +3714,7 @@ mod tests { remote_signer_public_key: signer_public_key, secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ) .expect_err("missing signer"); @@ -3724,6 +3727,7 @@ mod tests { remote_signer_public_key: public_key(0x66), secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ) .expect_err("signer mismatch"); @@ -3744,6 +3748,7 @@ mod tests { permission(RadrootsNostrConnectMethod::Ping, None), ] .into(), + client_metadata: None, }, ) .expect("existing connect request"); @@ -3761,6 +3766,7 @@ mod tests { permission(RadrootsNostrConnectMethod::Ping, None), ] .into(), + client_metadata: None, }, ) .expect("registration connect request"); @@ -3782,6 +3788,7 @@ mod tests { remote_signer_public_key: signer_public_key, secret: Some("connect-secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ) .expect_err("existing secret mismatch"); @@ -3809,6 +3816,7 @@ mod tests { remote_signer_public_key: signer_public_key, secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ) .expect_err("invalid signer public key"); @@ -3939,6 +3947,7 @@ mod tests { remote_signer_public_key: active.client_public_key, secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), + client_metadata: None, }, ), ) diff --git a/crates/nostr_signer/src/nip46.rs b/crates/nostr_signer/src/nip46.rs @@ -1064,6 +1064,7 @@ mod tests { remote_signer_public_key: signer_public_key, secret: secret.map(ToOwned::to_owned), requested_permissions: permissions.into(), + client_metadata: None, }, ) } @@ -2064,6 +2065,7 @@ mod tests { RadrootsNostrConnectMethod::Nip04Encrypt, )] .into(), + client_metadata: None, } ); }