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:
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, ¶ms, 0)?;
Ok(Self::SwitchRelays)
}
+ RadrootsNostrConnectMethod::Logout => {
+ expect_param_count(&method, ¶ms, 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,
}
);
}