lib

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

commit c8acf17e291877855fdf0301fb74f58165c64955
parent 853ef12beecd5d700336e2998b6382ba91df1838
Author: triesap <tyson@radroots.org>
Date:   Fri, 31 Jul 2026 18:33:48 +0000

nostr-connect: stabilize request and response envelopes

- validate bounded request IDs, parameters, response bodies, and capabilities
- own protocol identity, relay, and event representations with redacted diagnostics
- add signer-aware correlation and replay rejection without serializing timeout policy
- migrate NIP-46 client and signer consumers with malformed-response coverage

Diffstat:
Mcrates/nostr_connect/src/client.rs | 36+++++++++++++++---------------------
Mcrates/nostr_connect/src/error.rs | 10++++++++++
Mcrates/nostr_connect/src/lib.rs | 21++++++++++++++++-----
Mcrates/nostr_connect/src/message.rs | 720++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/nostr_connect/tests/client.rs | 36++++++++++++++++++++++++++++--------
Mcrates/nostr_connect/tests/coverage.rs | 33++++++++++++++++++++++-----------
Acrates/nostr_connect/tests/message_contract.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_connect/tests/protocol.rs | 45++++++++++++++++++++++++++++++---------------
Mcrates/nostr_signer/src/backend.rs | 4++--
Mcrates/nostr_signer/src/evaluation.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/nostr_signer/src/manager.rs | 52++++++++++++++++++++++++++++++----------------------
Mcrates/nostr_signer/src/nip46.rs | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
12 files changed, 1108 insertions(+), 280 deletions(-)

diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs @@ -1,9 +1,5 @@ use crate::error::RadrootsNostrConnectError; -use crate::message::{ - RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, - RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - RadrootsNostrConnectResponseEnvelope, -}; +use crate::message::{RPC_KIND, Request, RequestMessage, Response, ResponseEnvelope}; use crate::method::Method; use nostr::nips::nip44::{self, Version}; use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag}; @@ -31,11 +27,11 @@ impl RadrootsNostrConnectClientTarget { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RadrootsNostrConnectClientRequest { pub request_id: String, - pub request: RadrootsNostrConnectRequest, + pub request: Request, } impl RadrootsNostrConnectClientRequest { - pub fn new(request_id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self { + pub fn new(request_id: impl Into<String>, request: Request) -> Self { Self { request_id: request_id.into(), request, @@ -46,8 +42,8 @@ impl RadrootsNostrConnectClientRequest { self.request.method() } - pub fn into_message(self) -> RadrootsNostrConnectRequestMessage { - RadrootsNostrConnectRequestMessage::new(self.request_id, self.request) + pub fn into_message(self) -> RequestMessage { + RequestMessage::new(self.request_id, self.request) } } @@ -60,7 +56,7 @@ pub enum RadrootsNostrConnectClientProgress { pub enum RadrootsNostrConnectClientEventOutcome { Ignore, Progress(RadrootsNostrConnectClientProgress), - Response(RadrootsNostrConnectResponse), + Response(Response), } pub trait RadrootsNostrConnectClientTransport { @@ -77,7 +73,7 @@ pub trait RadrootsNostrConnectClientTransport { pub fn build_request_event( client_keys: &Keys, target: &RadrootsNostrConnectClientTarget, - message: RadrootsNostrConnectRequestMessage, + message: RequestMessage, ) -> Result<Event, RadrootsNostrConnectError> { let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?; let ciphertext = nip44::encrypt( @@ -88,7 +84,7 @@ pub fn build_request_event( ) .map_err(encrypt_error)?; - EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) .tag(Tag::public_key(target.remote_signer_public_key)) .sign_with_keys(client_keys) .map_err(sign_error) @@ -101,7 +97,7 @@ pub fn parse_response_event( method: &Method, event: &Event, ) -> Result<RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectError> { - if event.kind != Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + if event.kind != Kind::Custom(RPC_KIND) { return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); } @@ -127,19 +123,17 @@ pub fn parse_response_event( reason: error.to_string(), })?; - let envelope: RadrootsNostrConnectResponseEnvelope = + let envelope: ResponseEnvelope = serde_json::from_str(&decrypted).map_err(RadrootsNostrConnectError::from)?; if envelope.id != request_id { return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); } - let response = RadrootsNostrConnectResponse::from_envelope(method, envelope)?; + let response = Response::from_envelope(method, envelope)?; Ok(match response { - RadrootsNostrConnectResponse::AuthUrl(url) => { - RadrootsNostrConnectClientEventOutcome::Progress( - RadrootsNostrConnectClientProgress::AuthChallenge { url }, - ) - } + Response::AuthUrl(url) => RadrootsNostrConnectClientEventOutcome::Progress( + RadrootsNostrConnectClientProgress::AuthChallenge { url }, + ), response => RadrootsNostrConnectClientEventOutcome::Response(response), }) } @@ -150,7 +144,7 @@ pub async fn execute_request_with_transport<T, F>( request: RadrootsNostrConnectClientRequest, transport: &mut T, mut on_progress: F, -) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> +) -> Result<Response, RadrootsNostrConnectError> where T: RadrootsNostrConnectClientTransport, F: FnMut(RadrootsNostrConnectClientProgress) -> Result<(), RadrootsNostrConnectError>, diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs @@ -12,6 +12,16 @@ pub enum RadrootsNostrConnectError { Transport { reason: String }, #[error("NIP-46 request timed out")] RequestTimedOut, + #[error("invalid NIP-46 request id: {reason}")] + InvalidRequestId { reason: &'static str }, + #[error("NIP-46 response id does not match the request")] + WrongRequestId, + #[error("NIP-46 response signer does not match the expected signer")] + WrongResponseSigner, + #[error("replayed NIP-46 response")] + ReplayedResponse, + #[error("invalid NIP-46 response envelope: {reason}")] + InvalidResponseEnvelope { reason: &'static str }, #[error("invalid NIP-46 method `{0}`")] InvalidMethod(String), #[error("invalid NIP-46 permission `{0}`")] diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -10,6 +10,7 @@ pub mod server; pub mod uri; pub use error::RadrootsNostrConnectError as Error; +pub use message::{Request, Response}; pub use method::Method; pub use permission::Permission; pub use uri::{BunkerUri, ClientUri}; @@ -26,11 +27,21 @@ pub mod prelude { }; pub use crate::error::RadrootsNostrConnectError; pub use crate::message::{ - RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND, - RadrootsNostrConnectPendingConnectionPollOutcome, - RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, - RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - RadrootsNostrConnectResponseEnvelope, + PENDING_CONNECTION_ERROR, PendingConnectionOutcome, REMOTE_CAPABILITY_RELAY_COUNT_MAX, + REQUEST_ID_MAX_BYTES, REQUEST_PARAM_COUNT_MAX, REQUEST_PARAM_MAX_BYTES, + REQUEST_PARAMS_MAX_BYTES, RESPONSE_ERROR_MAX_BYTES, RESPONSE_RESULT_MAX_BYTES, RPC_KIND, + RemoteSessionCapability, Request, RequestId, RequestMessage, Response, ResponseEnvelope, + ResponseValidator, SignedEvent, UnsignedEvent, + }; + pub use crate::message::{ + PENDING_CONNECTION_ERROR as RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, + PendingConnectionOutcome as RadrootsNostrConnectPendingConnectionPollOutcome, + RPC_KIND as RADROOTS_NOSTR_CONNECT_RPC_KIND, + RemoteSessionCapability as RadrootsNostrConnectRemoteSessionCapability, + Request as RadrootsNostrConnectRequest, + RequestMessage as RadrootsNostrConnectRequestMessage, + Response as RadrootsNostrConnectResponse, + ResponseEnvelope as RadrootsNostrConnectResponseEnvelope, }; pub use crate::method::Method; pub use crate::method::Method as RadrootsNostrConnectMethod; diff --git a/crates/nostr_connect/src/message.rs b/crates/nostr_connect/src/message.rs @@ -1,24 +1,224 @@ use crate::error::RadrootsNostrConnectError; use crate::method::Method; use crate::permission::Permissions; -use crate::uri::ClientMetadata; -use nostr::{Event, JsonUtil, PublicKey, RelayUrl, UnsignedEvent}; +use crate::uri::{ClientMetadata, RelayUrl}; +use nostr::JsonUtil; +use radroots_identity::PublicKey; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Value, json}; +use std::collections::BTreeSet; +use std::fmt; use std::str::FromStr; use url::Url; -pub const RADROOTS_NOSTR_CONNECT_RPC_KIND: u16 = 24_133; +pub const RPC_KIND: u16 = 24_133; +pub const REQUEST_ID_MAX_BYTES: usize = 128; +pub const REQUEST_PARAM_COUNT_MAX: usize = 64; +pub const REQUEST_PARAM_MAX_BYTES: usize = 65_536; +pub const REQUEST_PARAMS_MAX_BYTES: usize = 262_144; +pub const RESPONSE_ERROR_MAX_BYTES: usize = 4_096; +pub const RESPONSE_RESULT_MAX_BYTES: usize = 262_144; +pub const REMOTE_CAPABILITY_RELAY_COUNT_MAX: usize = 32; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrConnectRemoteSessionCapability { +/// A validated NIP-46 unsigned-event payload with package-owned representation. +#[derive(Clone, PartialEq, Eq)] +pub struct UnsignedEvent(nostr::UnsignedEvent); + +impl fmt::Debug for UnsignedEvent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("UnsignedEvent(<redacted>)") + } +} + +impl UnsignedEvent { + pub fn from_json(value: &str) -> Result<Self, RadrootsNostrConnectError> { + serde_json::from_str(value).map(Self).map_err(|error| { + RadrootsNostrConnectError::InvalidRequestPayload { + method: Method::SignEvent.to_string(), + reason: error.to_string(), + } + }) + } + + #[must_use] + pub fn as_json(&self) -> String { + self.0.as_json() + } + + #[must_use] + pub fn kind(&self) -> u16 { + self.0.kind.as_u16() + } +} + +/// A validated NIP-46 signed-event payload with package-owned representation. +#[derive(Clone, PartialEq, Eq)] +pub struct SignedEvent(nostr::Event); + +impl fmt::Debug for SignedEvent { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SignedEvent(<redacted>)") + } +} + +impl SignedEvent { + pub fn from_json(value: &str) -> Result<Self, RadrootsNostrConnectError> { + serde_json::from_str(value).map(Self).map_err(|error| { + RadrootsNostrConnectError::InvalidResponsePayload { + method: Method::SignEvent.to_string(), + reason: error.to_string(), + } + }) + } + + #[must_use] + pub fn as_json(&self) -> String { + self.0.as_json() + } +} + +/// A bounded correlation identifier carried by a NIP-46 request and response. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RequestId(String); + +impl RequestId { + pub fn parse(value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> { + let value = value.into(); + validate_request_id(&value)?; + Ok(Self(value)) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for RequestId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl FromStr for RequestId { + type Err = RadrootsNostrConnectError; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) + } +} + +impl Serialize for RequestId { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for RequestId { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSessionCapability { + #[doc(hidden)] pub user_public_key: PublicKey, + #[doc(hidden)] pub relays: Vec<RelayUrl>, + #[doc(hidden)] pub permissions: Permissions, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrConnectRequest { +impl RemoteSessionCapability { + pub fn try_new( + user_public_key: PublicKey, + relays: Vec<RelayUrl>, + permissions: Permissions, + ) -> Result<Self, RadrootsNostrConnectError> { + let capability = Self { + user_public_key, + relays, + permissions, + }; + capability.validate()?; + Ok(capability) + } + + #[must_use] + pub const fn user_public_key(&self) -> PublicKey { + self.user_public_key + } + + #[must_use] + pub fn relays(&self) -> &[RelayUrl] { + &self.relays + } + + #[must_use] + pub fn permissions(&self) -> &Permissions { + &self.permissions + } + + fn validate(&self) -> Result<(), RadrootsNostrConnectError> { + if self.relays.len() > REMOTE_CAPABILITY_RELAY_COUNT_MAX { + return Err(RadrootsNostrConnectError::InvalidResponsePayload { + method: Method::GetSessionCapability.to_string(), + reason: "remote capability relay count exceeds its limit".to_owned(), + }); + } + self.permissions + .to_string() + .parse::<Permissions>() + .map(|_| ()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RemoteSessionCapabilitySerde { + user_public_key: String, + relays: Vec<RelayUrl>, + permissions: Permissions, +} + +impl Serialize for RemoteSessionCapability { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + RemoteSessionCapabilitySerde { + user_public_key: self.user_public_key.to_hex(), + relays: self.relays.clone(), + permissions: self.permissions.clone(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for RemoteSessionCapability { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let raw = RemoteSessionCapabilitySerde::deserialize(deserializer)?; + let user_public_key = + parse_public_key(&raw.user_public_key).map_err(serde::de::Error::custom)?; + Self::try_new(user_public_key, raw.relays, raw.permissions) + .map_err(serde::de::Error::custom) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum Request { Connect { remote_signer_public_key: PublicKey, secret: Option<String>, @@ -53,7 +253,19 @@ pub enum RadrootsNostrConnectRequest { }, } -impl RadrootsNostrConnectRequest { +impl fmt::Debug for Request { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Request") + .field("method", &self.method()) + .field("payload", &"<redacted>") + .finish() + } +} + +impl Request { + /// Returns the canonical NIP-46 method represented by this payload. + #[must_use] pub fn method(&self) -> Method { match self { Self::Connect { .. } => Method::Connect, @@ -119,6 +331,7 @@ impl RadrootsNostrConnectRequest { } => vec![public_key.to_hex(), ciphertext.clone()], Self::Custom { params, .. } => params.clone(), }; + validate_params(&params)?; Ok(params) } @@ -126,6 +339,7 @@ impl RadrootsNostrConnectRequest { method: Method, params: Vec<String>, ) -> Result<Self, RadrootsNostrConnectError> { + validate_params(&params)?; match method { Method::Connect => { if params.is_empty() || params.len() > 4 { @@ -162,12 +376,7 @@ impl RadrootsNostrConnectRequest { } Method::SignEvent => { expect_param_count(&method, &params, 1)?; - let unsigned_event = serde_json::from_str(&params[0]).map_err(|error| { - RadrootsNostrConnectError::InvalidRequestPayload { - method: method.to_string(), - reason: error.to_string(), - } - })?; + let unsigned_event = UnsignedEvent::from_json(&params[0])?; Ok(Self::SignEvent(unsigned_event)) } Method::Nip04Encrypt => { @@ -219,20 +428,58 @@ impl RadrootsNostrConnectRequest { } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsNostrConnectRequestMessage { +pub struct RequestMessage { + #[doc(hidden)] pub id: String, - pub request: RadrootsNostrConnectRequest, + #[doc(hidden)] + pub request: Request, } -impl RadrootsNostrConnectRequestMessage { - pub fn new(id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self { +impl RequestMessage { + /// Creates and validates a serialized request envelope. + pub fn try_new( + id: impl Into<String>, + request: Request, + ) -> Result<Self, RadrootsNostrConnectError> { + let id = id.into(); + validate_request_id(&id)?; + request.to_params()?; + Ok(Self { id, request }) + } + + /// Compatibility constructor retained until the Step 141 consumer cutover. + #[doc(hidden)] + #[must_use] + pub fn new(id: impl Into<String>, request: Request) -> Self { Self { id: id.into(), request, } } + pub fn request_id(&self) -> Result<RequestId, RadrootsNostrConnectError> { + RequestId::parse(self.id.clone()) + } + + #[must_use] + pub fn payload(&self) -> &Request { + &self.request + } + + /// Correlates and decodes a response using this request's method. + pub fn correlate( + &self, + envelope: ResponseEnvelope, + ) -> Result<Response, RadrootsNostrConnectError> { + envelope.validate()?; + if envelope.id != self.id { + return Err(RadrootsNostrConnectError::WrongRequestId); + } + Response::from_envelope(&self.request.method(), envelope) + } + fn into_raw(self) -> Result<RawRequestMessage, RadrootsNostrConnectError> { + validate_request_id(&self.id)?; Ok(RawRequestMessage { id: self.id, method: self.request.method(), @@ -241,14 +488,12 @@ impl RadrootsNostrConnectRequestMessage { } fn from_raw(raw: RawRequestMessage) -> Result<Self, RadrootsNostrConnectError> { - Ok(Self { - id: raw.id, - request: RadrootsNostrConnectRequest::from_parts(raw.method, raw.params)?, - }) + let request = Request::from_parts(raw.method, raw.params)?; + Self::try_new(raw.id, request) } } -impl Serialize for RadrootsNostrConnectRequestMessage { +impl Serialize for RequestMessage { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer, @@ -260,7 +505,7 @@ impl Serialize for RadrootsNostrConnectRequestMessage { } } -impl<'de> Deserialize<'de> for RadrootsNostrConnectRequestMessage { +impl<'de> Deserialize<'de> for RequestMessage { fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> where D: Deserializer<'de>, @@ -270,36 +515,178 @@ impl<'de> Deserialize<'de> for RadrootsNostrConnectRequestMessage { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RadrootsNostrConnectResponseEnvelope { +#[derive(Clone, PartialEq, Eq)] +pub struct ResponseEnvelope { + #[doc(hidden)] pub id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[doc(hidden)] pub result: Option<Value>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[doc(hidden)] pub error: Option<String>, } -pub const RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR: &str = "connection is pending"; +impl fmt::Debug for ResponseEnvelope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ResponseEnvelope") + .field("id", &self.id) + .field("has_result", &self.result.is_some()) + .field("has_error", &self.error.is_some()) + .finish() + } +} + +impl ResponseEnvelope { + pub fn try_new( + id: impl Into<String>, + result: Option<Value>, + error: Option<String>, + ) -> Result<Self, RadrootsNostrConnectError> { + let envelope = Self { + id: id.into(), + result, + error, + }; + envelope.validate()?; + Ok(envelope) + } + + pub fn request_id(&self) -> Result<RequestId, RadrootsNostrConnectError> { + RequestId::parse(self.id.clone()) + } + + #[must_use] + pub fn result(&self) -> Option<&Value> { + self.result.as_ref() + } + + #[must_use] + pub fn error(&self) -> Option<&str> { + self.error.as_deref() + } + + pub fn validate(&self) -> Result<(), RadrootsNostrConnectError> { + validate_request_id(&self.id)?; + if let Some(error) = self.error.as_deref() + && (error.is_empty() + || error.len() > RESPONSE_ERROR_MAX_BYTES + || error.chars().any(char::is_control)) + { + return Err(RadrootsNostrConnectError::InvalidResponseEnvelope { + reason: "error must be non-empty, bounded, and control-free", + }); + } + if let Some(result) = self.result.as_ref() + && serde_json::to_vec(result) + .map_err(RadrootsNostrConnectError::from)? + .len() + > RESPONSE_RESULT_MAX_BYTES + { + return Err(RadrootsNostrConnectError::InvalidResponseEnvelope { + reason: "result exceeds its byte limit", + }); + } + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ResponseEnvelopeSerde { + id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + result: Option<Value>, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option<String>, +} + +impl Serialize for ResponseEnvelope { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + self.validate().map_err(serde::ser::Error::custom)?; + ResponseEnvelopeSerde { + id: self.id.clone(), + result: self.result.clone(), + error: self.error.clone(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for ResponseEnvelope { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: Deserializer<'de>, + { + let raw = ResponseEnvelopeSerde::deserialize(deserializer)?; + Self::try_new(raw.id, raw.result, raw.error).map_err(serde::de::Error::custom) + } +} + +/// Correlation state supplied by a caller that owns response-event identity. +#[derive(Debug)] +pub struct ResponseValidator { + request_id: RequestId, + expected_signer: radroots_identity::PublicKey, + seen_fingerprints: BTreeSet<String>, +} + +impl ResponseValidator { + #[must_use] + pub fn new(request_id: RequestId, expected_signer: radroots_identity::PublicKey) -> Self { + Self { + request_id, + expected_signer, + seen_fingerprints: BTreeSet::new(), + } + } + + /// Validates signer and request correlation and rejects a repeated event fingerprint. + pub fn validate( + &mut self, + signer: radroots_identity::PublicKey, + response_fingerprint: impl Into<String>, + envelope: &ResponseEnvelope, + ) -> Result<(), RadrootsNostrConnectError> { + if signer != self.expected_signer { + return Err(RadrootsNostrConnectError::WrongResponseSigner); + } + envelope.validate()?; + if envelope.id != self.request_id.as_str() { + return Err(RadrootsNostrConnectError::WrongRequestId); + } + let fingerprint = response_fingerprint.into(); + validate_response_fingerprint(&fingerprint)?; + if !self.seen_fingerprints.insert(fingerprint) { + return Err(RadrootsNostrConnectError::ReplayedResponse); + } + Ok(()) + } +} + +pub const PENDING_CONNECTION_ERROR: &str = "connection is pending"; #[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrConnectPendingConnectionPollOutcome { +pub enum PendingConnectionOutcome { PendingApproval, Approved(PublicKey), - ApprovedCapability(RadrootsNostrConnectRemoteSessionCapability), + ApprovedCapability(RemoteSessionCapability), Rejected { message: String }, AuthChallenge { url: String }, UnexpectedResponse { response: String }, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RadrootsNostrConnectResponse { +#[derive(Clone, PartialEq, Eq)] +pub enum Response { ConnectAcknowledged, ConnectSecretEcho(String), LogoutAcknowledged, PendingConnection, UserPublicKey(PublicKey), - RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability), - SignedEvent(Event), + RemoteSessionCapability(RemoteSessionCapability), + SignedEvent(SignedEvent), Pong, Nip04Encrypt(String), Nip04Decrypt(String), @@ -318,33 +705,53 @@ pub enum RadrootsNostrConnectResponse { }, } -impl RadrootsNostrConnectResponse { - pub fn into_pending_connection_poll_outcome( - self, - ) -> RadrootsNostrConnectPendingConnectionPollOutcome { +impl fmt::Debug for Response { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Response") + .field("kind", &self.kind_name()) + .field("payload", &"<redacted>") + .finish() + } +} + +impl Response { + const fn kind_name(&self) -> &'static str { match self { - Self::PendingConnection => { - RadrootsNostrConnectPendingConnectionPollOutcome::PendingApproval - } - Self::UserPublicKey(public_key) => { - RadrootsNostrConnectPendingConnectionPollOutcome::Approved(public_key) - } + Self::ConnectAcknowledged => "connect_acknowledged", + Self::ConnectSecretEcho(_) => "connect_secret_echo", + Self::LogoutAcknowledged => "logout_acknowledged", + Self::PendingConnection => "pending_connection", + Self::UserPublicKey(_) => "user_public_key", + Self::RemoteSessionCapability(_) => "remote_session_capability", + Self::SignedEvent(_) => "signed_event", + Self::Pong => "pong", + Self::Nip04Encrypt(_) => "nip04_encrypt", + Self::Nip04Decrypt(_) => "nip04_decrypt", + Self::Nip44Encrypt(_) => "nip44_encrypt", + Self::Nip44Decrypt(_) => "nip44_decrypt", + Self::RelayList(_) => "relay_list", + Self::RelayListUnchanged => "relay_list_unchanged", + Self::AuthUrl(_) => "auth_url", + Self::Error { .. } => "error", + Self::Custom { .. } => "custom", + } + } + + pub fn into_pending_connection_poll_outcome(self) -> PendingConnectionOutcome { + match self { + Self::PendingConnection => PendingConnectionOutcome::PendingApproval, + Self::UserPublicKey(public_key) => PendingConnectionOutcome::Approved(public_key), Self::RemoteSessionCapability(capability) => { - RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability) - } - Self::Error { error, .. } - if error == RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR => - { - RadrootsNostrConnectPendingConnectionPollOutcome::PendingApproval - } - Self::Error { error, .. } => { - RadrootsNostrConnectPendingConnectionPollOutcome::Rejected { message: error } + PendingConnectionOutcome::ApprovedCapability(capability) } - Self::AuthUrl(url) => { - RadrootsNostrConnectPendingConnectionPollOutcome::AuthChallenge { url } + Self::Error { error, .. } if error == PENDING_CONNECTION_ERROR => { + PendingConnectionOutcome::PendingApproval } - other => RadrootsNostrConnectPendingConnectionPollOutcome::UnexpectedResponse { - response: format!("{other:?}"), + Self::Error { error, .. } => PendingConnectionOutcome::Rejected { message: error }, + Self::AuthUrl(url) => PendingConnectionOutcome::AuthChallenge { url }, + other => PendingConnectionOutcome::UnexpectedResponse { + response: other.kind_name().to_owned(), }, } } @@ -352,42 +759,40 @@ impl RadrootsNostrConnectResponse { pub fn into_envelope( self, id: impl Into<String>, - ) -> Result<RadrootsNostrConnectResponseEnvelope, RadrootsNostrConnectError> { + ) -> Result<ResponseEnvelope, RadrootsNostrConnectError> { let id = id.into(); let envelope = match self { - Self::ConnectAcknowledged | Self::LogoutAcknowledged => { - RadrootsNostrConnectResponseEnvelope { - id, - result: Some(Value::String("ack".to_owned())), - error: None, - } - } - Self::ConnectSecretEcho(secret) => RadrootsNostrConnectResponseEnvelope { + Self::ConnectAcknowledged | Self::LogoutAcknowledged => ResponseEnvelope { + id, + result: Some(Value::String("ack".to_owned())), + error: None, + }, + Self::ConnectSecretEcho(secret) => ResponseEnvelope { id, result: Some(Value::String(secret)), error: None, }, - Self::PendingConnection => RadrootsNostrConnectResponseEnvelope { + Self::PendingConnection => ResponseEnvelope { id, result: None, - error: Some(RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR.to_owned()), + error: Some(PENDING_CONNECTION_ERROR.to_owned()), }, - Self::UserPublicKey(public_key) => RadrootsNostrConnectResponseEnvelope { + Self::UserPublicKey(public_key) => ResponseEnvelope { id, result: Some(Value::String(public_key.to_hex())), error: None, }, - Self::RemoteSessionCapability(capability) => RadrootsNostrConnectResponseEnvelope { + Self::RemoteSessionCapability(capability) => ResponseEnvelope { id, result: Some(remote_session_capability_value(capability)), error: None, }, - Self::SignedEvent(event) => RadrootsNostrConnectResponseEnvelope { + Self::SignedEvent(event) => ResponseEnvelope { id, result: Some(Value::String(event.as_json())), error: None, }, - Self::Pong => RadrootsNostrConnectResponseEnvelope { + Self::Pong => ResponseEnvelope { id, result: Some(Value::String("pong".to_owned())), error: None, @@ -395,7 +800,7 @@ impl RadrootsNostrConnectResponse { Self::Nip04Encrypt(text) | Self::Nip04Decrypt(text) | Self::Nip44Encrypt(text) - | Self::Nip44Decrypt(text) => RadrootsNostrConnectResponseEnvelope { + | Self::Nip44Decrypt(text) => ResponseEnvelope { id, result: Some(Value::String(text)), error: None, @@ -405,7 +810,7 @@ impl RadrootsNostrConnectResponse { .into_iter() .map(|relay| relay.to_string()) .collect::<Vec<_>>(); - RadrootsNostrConnectResponseEnvelope { + ResponseEnvelope { id, result: Some(Value::Array( relays.into_iter().map(Value::String).collect(), @@ -413,35 +818,35 @@ impl RadrootsNostrConnectResponse { error: None, } } - Self::RelayListUnchanged => RadrootsNostrConnectResponseEnvelope { + Self::RelayListUnchanged => ResponseEnvelope { id, result: Some(Value::Null), error: None, }, Self::AuthUrl(url) => { let normalized = validate_url(&url)?; - RadrootsNostrConnectResponseEnvelope { + ResponseEnvelope { id, result: Some(Value::String("auth_url".to_owned())), error: Some(normalized), } } - Self::Error { result, error } => RadrootsNostrConnectResponseEnvelope { + Self::Error { result, error } => ResponseEnvelope { id, result, error: Some(error), }, - Self::Custom { result, error } => { - RadrootsNostrConnectResponseEnvelope { id, result, error } - } + Self::Custom { result, error } => ResponseEnvelope { id, result, error }, }; + envelope.validate()?; Ok(envelope) } pub fn from_envelope( method: &Method, - envelope: RadrootsNostrConnectResponseEnvelope, + envelope: ResponseEnvelope, ) -> Result<Self, RadrootsNostrConnectError> { + envelope.validate()?; if let (Some(Value::String(result)), Some(url)) = (&envelope.result, &envelope.error) && result == "auth_url" { @@ -451,7 +856,7 @@ impl RadrootsNostrConnectResponse { if let Some(error) = envelope.error { if matches!(method, Method::GetPublicKey | Method::GetSessionCapability) && envelope.result.is_none() - && error == RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR + && error == PENDING_CONNECTION_ERROR { return Ok(Self::PendingConnection); } @@ -485,7 +890,8 @@ impl RadrootsNostrConnectResponse { Ok(Self::RemoteSessionCapability(capability)) } Method::SignEvent => { - let event = parse_json_string_result::<Event>(method, envelope.result)?; + let value = expect_json_string_or_value(method, envelope.result)?; + let event = SignedEvent::from_json(&value)?; Ok(Self::SignedEvent(event)) } Method::Ping => { @@ -493,7 +899,7 @@ impl RadrootsNostrConnectResponse { if result != "pong" { return Err(RadrootsNostrConnectError::InvalidResponsePayload { method: method.to_string(), - reason: format!("expected `pong`, got `{result}`"), + reason: "expected canonical `pong` result".to_owned(), }); } Ok(Self::Pong) @@ -520,7 +926,7 @@ impl RadrootsNostrConnectResponse { if result != "ack" { return Err(RadrootsNostrConnectError::InvalidResponsePayload { method: method.to_string(), - reason: format!("expected `ack`, got `{result}`"), + reason: "expected canonical `ack` result".to_owned(), }); } Ok(Self::LogoutAcknowledged) @@ -533,23 +939,76 @@ impl RadrootsNostrConnectResponse { } } -fn remote_session_capability_value( - capability: RadrootsNostrConnectRemoteSessionCapability, -) -> Value { +fn remote_session_capability_value(capability: RemoteSessionCapability) -> Value { json!({ - "user_public_key": capability.user_public_key, + "user_public_key": capability.user_public_key.to_hex(), "relays": capability.relays, "permissions": capability.permissions, }) } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] struct RawRequestMessage { id: String, method: Method, params: Vec<String>, } +fn validate_request_id(value: &str) -> Result<(), RadrootsNostrConnectError> { + if value.is_empty() { + return Err(RadrootsNostrConnectError::InvalidRequestId { + reason: "request id cannot be empty", + }); + } + if value.len() > REQUEST_ID_MAX_BYTES { + return Err(RadrootsNostrConnectError::InvalidRequestId { + reason: "request id exceeds its byte limit", + }); + } + if value.trim() != value || value.chars().any(char::is_control) { + return Err(RadrootsNostrConnectError::InvalidRequestId { + reason: "request id must be canonical and control-free", + }); + } + Ok(()) +} + +fn validate_params(params: &[String]) -> Result<(), RadrootsNostrConnectError> { + if params.len() > REQUEST_PARAM_COUNT_MAX { + return Err(RadrootsNostrConnectError::InvalidRequestPayload { + method: "custom".to_owned(), + reason: "parameter count exceeds its limit".to_owned(), + }); + } + if params + .iter() + .any(|param| param.len() > REQUEST_PARAM_MAX_BYTES) + { + return Err(RadrootsNostrConnectError::InvalidRequestPayload { + method: "unknown".to_owned(), + reason: "a parameter exceeds its byte limit".to_owned(), + }); + } + if params.iter().map(String::len).sum::<usize>() > REQUEST_PARAMS_MAX_BYTES { + return Err(RadrootsNostrConnectError::InvalidRequestPayload { + method: "unknown".to_owned(), + reason: "serialized parameters exceed their byte limit".to_owned(), + }); + } + Ok(()) +} + +fn validate_response_fingerprint(value: &str) -> Result<(), RadrootsNostrConnectError> { + if value.is_empty() || value.len() > REQUEST_ID_MAX_BYTES || value.chars().any(char::is_control) + { + return Err(RadrootsNostrConnectError::InvalidResponseEnvelope { + reason: "response fingerprint must be non-empty, bounded, and control-free", + }); + } + Ok(()) +} + fn expect_param_count( method: &Method, params: &[String], @@ -573,12 +1032,12 @@ fn expect_param_count( } 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 expect_string_result( @@ -589,7 +1048,7 @@ fn expect_string_result( Some(Value::String(value)) => Ok(value), Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload { method: method.to_string(), - reason: format!("expected string result, got {other}"), + reason: format!("expected string result, got {}", json_type(&other)), }), None => Err(RadrootsNostrConnectError::MissingResult), } @@ -619,19 +1078,33 @@ where } } +fn expect_json_string_or_value( + method: &Method, + result: Option<Value>, +) -> Result<String, RadrootsNostrConnectError> { + match result { + Some(Value::String(value)) => Ok(value), + Some(value) => serde_json::to_string(&value).map_err(|error| { + RadrootsNostrConnectError::InvalidResponsePayload { + method: method.to_string(), + reason: error.to_string(), + } + }), + None => Err(RadrootsNostrConnectError::MissingResult), + } +} + fn parse_switch_relays_response( result: Option<Value>, -) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> { +) -> Result<Response, RadrootsNostrConnectError> { let method = Method::SwitchRelays; match result { - None | Some(Value::Null) => Ok(RadrootsNostrConnectResponse::RelayListUnchanged), + None | Some(Value::Null) => Ok(Response::RelayListUnchanged), Some(Value::Array(values)) => { let relays = parse_relay_values(values)?; - Ok(RadrootsNostrConnectResponse::RelayList(relays)) - } - Some(Value::String(value)) if value == "null" => { - Ok(RadrootsNostrConnectResponse::RelayListUnchanged) + Ok(Response::RelayList(relays)) } + Some(Value::String(value)) if value == "null" => Ok(Response::RelayListUnchanged), Some(Value::String(value)) => { let parsed = serde_json::from_str::<Value>(&value).map_err(|error| { RadrootsNostrConnectError::InvalidResponsePayload { @@ -643,7 +1116,7 @@ fn parse_switch_relays_response( } Some(other) => Err(RadrootsNostrConnectError::InvalidResponsePayload { method: method.to_string(), - reason: format!("expected relay list or null, got {other}"), + reason: format!("expected relay list or null, got {}", json_type(&other)), }), } } @@ -652,25 +1125,42 @@ fn parse_relay_values(values: Vec<Value>) -> Result<Vec<RelayUrl>, RadrootsNostr values .into_iter() .map(|value| match value { - Value::String(value) => RelayUrl::parse(&value).map_err(|error| { - RadrootsNostrConnectError::InvalidRelayUrl { - value, - reason: error.to_string(), - } - }), + Value::String(value) => RelayUrl::parse(&value), other => Err(RadrootsNostrConnectError::InvalidResponsePayload { method: Method::SwitchRelays.to_string(), - reason: format!("expected relay string, got {other}"), + reason: format!("expected relay string, got {}", json_type(&other)), }), }) .collect() } 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(), - }) + if value.len() > crate::uri::CLIENT_URL_MAX_BYTES || value.chars().any(char::is_control) { + return Err(RadrootsNostrConnectError::InvalidUrl { + value: "[redacted auth URL]".to_owned(), + reason: "auth URL is oversized or contains control characters".to_owned(), + }); + } + let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl { + value: "[redacted auth URL]".to_owned(), + reason: error.to_string(), + })?; + if !matches!(url.scheme(), "http" | "https") { + return Err(RadrootsNostrConnectError::InvalidUrl { + value: "[redacted auth URL]".to_owned(), + reason: "auth URL scheme must be http or https".to_owned(), + }); + } + Ok(url.to_string()) +} + +fn json_type(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } } diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs @@ -3,7 +3,8 @@ mod test_fixtures; use nostr::nips::nip44::{self, Version}; use nostr::{ - Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent, + Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, + UnsignedEvent, }; use radroots_nostr_connect::prelude::{ Method, RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome, @@ -11,7 +12,8 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError, RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, - RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, build_request_event, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + SignedEvent as ConnectSignedEvent, UnsignedEvent as ConnectUnsignedEvent, build_request_event, execute_request_with_transport, parse_response_event, }; use std::collections::VecDeque; @@ -38,6 +40,10 @@ fn relay() -> RelayUrl { RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay") } +fn identity_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { + radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") +} + fn target(remote_keys: &Keys) -> RadrootsNostrConnectClientTarget { RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), vec![relay()]) } @@ -104,8 +110,10 @@ fn untagged_response_event( fn remote_session_capability(remote_keys: &Keys) -> RadrootsNostrConnectRemoteSessionCapability { RadrootsNostrConnectRemoteSessionCapability { - user_public_key: remote_keys.public_key(), - relays: vec![relay()], + user_public_key: identity_public_key(remote_keys.public_key()), + relays: vec![ + radroots_nostr_connect::uri::RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay"), + ], permissions: Vec::new().into(), } } @@ -159,7 +167,7 @@ async fn executes_connect_request_and_secret_echo_response() { RadrootsNostrConnectClientRequest::new( "req-connect", RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: remote_keys.public_key(), + remote_signer_public_key: identity_public_key(remote_keys.public_key()), secret: Some("connect-secret".to_owned()), requested_permissions: Vec::new().into(), client_metadata: None, @@ -321,7 +329,9 @@ async fn executes_request_through_transport_with_auth_progress() { &remote_keys, client_keys.public_key(), "req-sign", - RadrootsNostrConnectResponse::SignedEvent(signed.clone()), + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&signed.as_json()).expect("signed event payload"), + ), ), ]; let mut transport = MockTransport::new(inbound); @@ -332,7 +342,12 @@ async fn executes_request_through_transport_with_auth_progress() { &target, RadrootsNostrConnectClientRequest::new( "req-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_event(remote_keys.public_key())), + RadrootsNostrConnectRequest::SignEvent( + ConnectUnsignedEvent::from_json( + &unsigned_event(remote_keys.public_key()).as_json(), + ) + .expect("unsigned event payload"), + ), ), &mut transport, |event| { @@ -350,7 +365,12 @@ async fn executes_request_through_transport_with_auth_progress() { url: "https://auth.example.com/challenge".to_owned() }] ); - assert_eq!(response, RadrootsNostrConnectResponse::SignedEvent(signed)); + assert_eq!( + response, + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&signed.as_json()).expect("signed event payload") + ) + ); } #[tokio::test] diff --git a/crates/nostr_connect/tests/coverage.rs b/crates/nostr_connect/tests/coverage.rs @@ -1,14 +1,16 @@ #[path = "../src/test_fixtures.rs"] mod test_fixtures; -use nostr::{Event, EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ CLIENT_URL_MAX_BYTES, ClientMetadata, Method, Permission, Permissions, RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RadrootsNostrConnectError, RadrootsNostrConnectPendingConnectionPollOutcome, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, - RadrootsNostrConnectResponseEnvelope, Uri, + RadrootsNostrConnectResponseEnvelope, SignedEvent as ConnectSignedEvent, + UnsignedEvent as ConnectUnsignedEvent, Uri, }; +use radroots_nostr_connect::uri::RelayUrl; use serde_json::{Value, json}; use std::str::FromStr; use test_fixtures::{ @@ -16,8 +18,8 @@ use test_fixtures::{ RELAY_TERTIARY_WSS, }; -fn test_public_key() -> PublicKey { - PublicKey::parse(FIXTURE_ALICE.public_key_hex).expect("public key") +fn test_public_key() -> radroots_identity::PublicKey { + radroots_identity::PublicKey::from_hex(FIXTURE_ALICE.public_key_hex).expect("public key") } fn test_keys() -> Keys { @@ -433,7 +435,10 @@ fn request_surface_covers_variant_methods_serialization_and_validation() { Vec::new(), ), ( - RadrootsNostrConnectRequest::SignEvent(unsigned_event()), + RadrootsNostrConnectRequest::SignEvent( + ConnectUnsignedEvent::from_json(&unsigned_event().as_json()) + .expect("unsigned event payload"), + ), Method::SignEvent, vec![serde_json::to_string(&unsigned_event()).expect("serialize unsigned event")], ), @@ -751,9 +756,13 @@ fn response_surface_covers_success_and_error_paths() { ), ), ( - RadrootsNostrConnectResponse::SignedEvent(event.clone()), + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload"), + ), Method::SignEvent, - RadrootsNostrConnectResponse::SignedEvent(event.clone()), + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload"), + ), ), ( RadrootsNostrConnectResponse::Pong, @@ -978,7 +987,9 @@ fn response_surface_covers_success_and_error_paths() { }, ) .expect("parse object event"), - RadrootsNostrConnectResponse::SignedEvent(event) + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload") + ) ); assert_eq!( RadrootsNostrConnectResponse::from_envelope( @@ -1007,7 +1018,7 @@ fn response_surface_covers_success_and_error_paths() { assert!(matches!( RadrootsNostrConnectResponse::AuthUrl("not-a-url".to_owned()).into_envelope("req"), - Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "[redacted auth URL]" )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( @@ -1018,7 +1029,7 @@ fn response_surface_covers_success_and_error_paths() { error: Some("not-a-url".to_owned()), }, ), - Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "not-a-url" + Err(RadrootsNostrConnectError::InvalidUrl { value, .. }) if value == "[redacted auth URL]" )); assert!(matches!( RadrootsNostrConnectResponse::from_envelope( @@ -1281,6 +1292,6 @@ fn pending_connection_poll_outcome_uses_typed_variants() { assert!(matches!( RadrootsNostrConnectResponse::Pong.into_pending_connection_poll_outcome(), RadrootsNostrConnectPendingConnectionPollOutcome::UnexpectedResponse { response } - if response == "Pong" + if response == "pong" )); } diff --git a/crates/nostr_connect/tests/message_contract.rs b/crates/nostr_connect/tests/message_contract.rs @@ -0,0 +1,190 @@ +use nostr::{Keys, SecretKey}; +use radroots_identity::PublicKey as IdentityPublicKey; +use radroots_nostr_connect::message::{ + PendingConnectionOutcome, REMOTE_CAPABILITY_RELAY_COUNT_MAX, REQUEST_ID_MAX_BYTES, + RESPONSE_ERROR_MAX_BYTES, RemoteSessionCapability, RequestId, RequestMessage, ResponseEnvelope, + ResponseValidator, +}; +use radroots_nostr_connect::uri::RelayUrl; +use radroots_nostr_connect::{Error, Method, Request, Response}; +use serde_json::{Value, json}; +use std::str::FromStr; + +fn keys(secret_hex: &str) -> Keys { + Keys::new(SecretKey::from_hex(secret_hex).expect("secret key")) +} + +fn identity_key(secret_hex: &str) -> IdentityPublicKey { + radroots_nostr::key::public_key_from_nostr(keys(secret_hex).public_key()) + .expect("identity public key") +} + +#[test] +fn request_and_response_round_trip_with_bounded_correlation() { + let request = RequestMessage::try_new("request-1", Request::Ping).expect("valid request"); + let encoded = serde_json::to_string(&request).expect("serialize request"); + assert_eq!( + serde_json::from_str::<RequestMessage>(&encoded).expect("deserialize request"), + request + ); + + let response = Response::Pong + .into_envelope("request-1") + .expect("valid response"); + assert_eq!( + request.correlate(response).expect("correlated response"), + Response::Pong + ); + + assert!(matches!( + RequestId::parse(""), + Err(Error::InvalidRequestId { .. }) + )); + assert!(matches!( + RequestId::parse(" request-1"), + Err(Error::InvalidRequestId { .. }) + )); + assert!(matches!( + RequestId::parse("x".repeat(REQUEST_ID_MAX_BYTES + 1)), + Err(Error::InvalidRequestId { .. }) + )); +} + +#[test] +fn correlation_rejects_wrong_id_wrong_signer_and_replay() { + const SIGNER: &str = "0000000000000000000000000000000000000000000000000000000000000001"; + const OTHER: &str = "0000000000000000000000000000000000000000000000000000000000000002"; + let request = RequestMessage::try_new("request-2", Request::Ping).expect("request"); + let wrong_id = Response::Pong.into_envelope("request-3").expect("response"); + assert_eq!( + request.correlate(wrong_id).expect_err("wrong id"), + Error::WrongRequestId + ); + + let envelope = Response::Pong.into_envelope("request-2").expect("response"); + let mut validator = ResponseValidator::new( + RequestId::parse("request-2").expect("request id"), + identity_key(SIGNER), + ); + assert_eq!( + validator + .validate(identity_key(OTHER), "event-1", &envelope) + .expect_err("wrong signer"), + Error::WrongResponseSigner + ); + validator + .validate(identity_key(SIGNER), "event-1", &envelope) + .expect("first response"); + assert_eq!( + validator + .validate(identity_key(SIGNER), "event-1", &envelope) + .expect_err("replay"), + Error::ReplayedResponse + ); +} + +#[test] +fn malformed_envelopes_and_unsafe_auth_challenges_fail_closed() { + assert!(matches!( + ResponseEnvelope::try_new("request-3", None, Some(String::new())), + Err(Error::InvalidResponseEnvelope { .. }) + )); + assert!(matches!( + ResponseEnvelope::try_new( + "request-3", + None, + Some("x".repeat(RESPONSE_ERROR_MAX_BYTES + 1)), + ), + Err(Error::InvalidResponseEnvelope { .. }) + )); + assert!( + serde_json::from_value::<ResponseEnvelope>(json!({ + "id": "request-3", + "result": "pong", + "unexpected": true, + })) + .is_err() + ); + assert!(matches!( + Response::AuthUrl("file:///tmp/approval".to_owned()).into_envelope("request-3"), + Err(Error::InvalidUrl { value, .. }) if value == "[redacted auth URL]" + )); +} + +#[test] +fn unknown_methods_round_trip_only_when_canonical() { + let method = Method::from_str("vendor_action").expect("canonical extension method"); + let request = RequestMessage::try_new( + "request-custom", + Request::Custom { + method: method.clone(), + params: vec!["alpha".to_owned()], + }, + ) + .expect("custom request"); + let encoded = serde_json::to_value(&request).expect("serialize custom request"); + assert_eq!(encoded["method"], Value::String("vendor_action".to_owned())); + assert_eq!( + serde_json::from_value::<RequestMessage>(encoded) + .expect("deserialize custom request") + .payload() + .method(), + method + ); + assert!(Method::from_str("Vendor-Action").is_err()); +} + +#[test] +fn remote_capabilities_and_pending_outcomes_are_bounded_and_typed() { + let relay = RelayUrl::parse("wss://relay.example.test").expect("relay"); + let capability = RemoteSessionCapability::try_new( + identity_key("0000000000000000000000000000000000000000000000000000000000000003"), + vec![relay.clone()], + Default::default(), + ) + .expect("capability"); + let response = Response::RemoteSessionCapability(capability.clone()); + assert_eq!( + response.into_pending_connection_poll_outcome(), + PendingConnectionOutcome::ApprovedCapability(capability) + ); + assert!(matches!( + RemoteSessionCapability::try_new( + identity_key("0000000000000000000000000000000000000000000000000000000000000003"), + vec![relay; REMOTE_CAPABILITY_RELAY_COUNT_MAX + 1], + Default::default(), + ), + Err(Error::InvalidResponsePayload { .. }) + )); +} + +#[test] +fn diagnostics_redact_protocol_payloads() { + const SECRET: &str = "do-not-log-connect-secret"; + let request = Request::Connect { + remote_signer_public_key: identity_key( + "0000000000000000000000000000000000000000000000000000000000000004", + ), + secret: Some(SECRET.to_owned()), + requested_permissions: Default::default(), + client_metadata: None, + }; + assert!(!format!("{request:?}").contains(SECRET)); + + let envelope = ResponseEnvelope::try_new( + "request-redacted", + Some(Value::String(SECRET.to_owned())), + None, + ) + .expect("response envelope"); + assert!(!format!("{envelope:?}").contains(SECRET)); + + let response = Response::ConnectSecretEcho(SECRET.to_owned()); + assert!(!format!("{response:?}").contains(SECRET)); + assert_eq!( + response.into_pending_connection_poll_outcome(), + PendingConnectionOutcome::UnexpectedResponse { + response: "connect_secret_echo".to_owned(), + } + ); +} diff --git a/crates/nostr_connect/tests/protocol.rs b/crates/nostr_connect/tests/protocol.rs @@ -1,13 +1,15 @@ #[path = "../src/test_fixtures.rs"] mod test_fixtures; -use nostr::{EventBuilder, Keys, PublicKey, RelayUrl, SecretKey, Timestamp, UnsignedEvent}; +use nostr::{EventBuilder, JsonUtil, Keys, PublicKey, SecretKey, Timestamp, UnsignedEvent}; use radroots_nostr_connect::prelude::{ 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, + RadrootsNostrConnectResponseEnvelope, SignedEvent as ConnectSignedEvent, + UnsignedEvent as ConnectUnsignedEvent, Uri, }; +use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl; use serde_json::{Value, json}; use test_fixtures::{ APP_PRIMARY_HTTPS, CDN_PRIMARY_HTTPS, FIXTURE_ALICE, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS, @@ -39,10 +41,10 @@ fn logo_url() -> String { fn remote_session_capability() -> radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability { radroots_nostr_connect::prelude::RadrootsNostrConnectRemoteSessionCapability { - user_public_key: test_public_key(), + user_public_key: test_identity_public_key(), relays: vec![ - RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay 1"), - RelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 2"), + ConnectRelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay 1"), + ConnectRelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 2"), ], permissions: Permissions::from(vec![ Permission::new(Method::Ping), @@ -128,7 +130,7 @@ fn requested_permissions_roundtrip_as_csv() { #[test] fn connect_request_roundtrips_requested_permissions() { let request = RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: test_public_key(), + remote_signer_public_key: test_identity_public_key(), secret: Some("abcd".to_owned()), requested_permissions: Permissions::from(vec![ Permission::new(Method::Nip44Encrypt), @@ -159,7 +161,7 @@ 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(), + remote_signer_public_key: test_identity_public_key(), secret: None, requested_permissions: Permissions::default(), client_metadata: Some(ClientMetadata { @@ -295,7 +297,10 @@ fn sign_event_request_roundtrips_unsigned_event_payload() { let message = RadrootsNostrConnectRequestMessage::new( "req-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_event.clone()), + RadrootsNostrConnectRequest::SignEvent( + ConnectUnsignedEvent::from_json(&unsigned_event.as_json()) + .expect("unsigned event payload"), + ), ); let encoded = serde_json::to_value(&message).expect("serialize sign request"); assert_eq!(encoded["method"], "sign_event"); @@ -305,7 +310,10 @@ fn sign_event_request_roundtrips_unsigned_event_payload() { assert_eq!(decoded, message); assert_eq!( decoded.request, - RadrootsNostrConnectRequest::SignEvent(unsigned_event) + RadrootsNostrConnectRequest::SignEvent( + ConnectUnsignedEvent::from_json(&unsigned_event.as_json()) + .expect("unsigned event payload"), + ) ); } @@ -322,8 +330,8 @@ fn switch_relays_response_accepts_array_or_null() { assert_eq!( parsed, RadrootsNostrConnectResponse::RelayList(vec![ - RelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 1"), - RelayUrl::parse(RELAY_TERTIARY_WSS).expect("relay 2"), + ConnectRelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 1"), + ConnectRelayUrl::parse(RELAY_TERTIARY_WSS).expect("relay 2"), ]) ); @@ -407,13 +415,20 @@ fn sign_event_response_roundtrips_signed_event_json_string() { .sign_with_keys(&keys) .expect("sign event"); - let envelope = RadrootsNostrConnectResponse::SignedEvent(event.clone()) - .into_envelope("req-sign") - .expect("serialize response"); + let envelope = RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload"), + ) + .into_envelope("req-sign") + .expect("serialize response"); let parsed = RadrootsNostrConnectResponse::from_envelope(&Method::SignEvent, envelope) .expect("parse signed event response"); - assert_eq!(parsed, RadrootsNostrConnectResponse::SignedEvent(event)); + assert_eq!( + parsed, + RadrootsNostrConnectResponse::SignedEvent( + ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload") + ) + ); } #[test] diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs @@ -1303,7 +1303,7 @@ mod tests { .evaluate_connect_request( synthetic_public_key(0x93), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: identity.public_key(), + remote_signer_public_key: embedded_public_identity(&identity).public_key(), secret: Some("connect-secret".into()), requested_permissions: vec![RadrootsNostrConnectPermission::new( RadrootsNostrConnectMethod::Ping, @@ -1372,7 +1372,7 @@ mod tests { .evaluate_connect_request( synthetic_public_key(0xa1), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: identity.public_key(), + remote_signer_public_key: embedded_public_identity(&identity).public_key(), secret: Some("connect-secret-2".into()), requested_permissions: vec![RadrootsNostrConnectPermission::new( RadrootsNostrConnectMethod::Ping, diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs @@ -4,13 +4,14 @@ use crate::model::{ RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest, RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId, }; -use nostr::{PublicKey, RelayUrl}; +use nostr::PublicKey; use radroots_identity::PublicIdentity; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectClientMetadata, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, }; +use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl; #[derive(Debug, Clone)] pub enum RadrootsNostrSignerSessionLookup { @@ -37,9 +38,9 @@ pub enum RadrootsNostrSignerConnectEvaluation { pub enum RadrootsNostrSignerRequestResponseHint { None, Pong, - UserPublicKey(PublicKey), + UserPublicKey(radroots_identity::PublicKey), RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability), - RelayList(Vec<RelayUrl>), + RelayList(Vec<ConnectRelayUrl>), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -115,7 +116,7 @@ pub(crate) fn required_permission_for_request( RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { Some(RadrootsNostrConnectPermission::with_parameter( RadrootsNostrConnectMethod::SignEvent, - format!("kind:{}", unsigned_event.kind.as_u16()), + format!("kind:{}", unsigned_event.kind()), )) } RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some( @@ -167,15 +168,25 @@ pub(crate) fn response_hint_for_request( RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability( RadrootsNostrConnectRemoteSessionCapability { user_public_key: identity_public_key(&connection.user_identity)?, - relays: connection.relays.clone(), + relays: connection + .relays + .iter() + .map(|relay| ConnectRelayUrl::parse(&relay.to_string())) + .collect::<Result<Vec<_>, _>>()?, permissions: connection.effective_permissions(), }, ), ), RadrootsNostrConnectRequest::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong), - RadrootsNostrConnectRequest::SwitchRelays => Ok( - RadrootsNostrSignerRequestResponseHint::RelayList(connection.relays.clone()), - ), + RadrootsNostrConnectRequest::SwitchRelays => { + Ok(RadrootsNostrSignerRequestResponseHint::RelayList( + connection + .relays + .iter() + .map(|relay| ConnectRelayUrl::parse(&relay.to_string())) + .collect::<Result<Vec<_>, _>>()?, + )) + } _ => Ok(RadrootsNostrSignerRequestResponseHint::None), } } @@ -207,10 +218,10 @@ fn sign_event_kind_suffix(value: &str) -> &str { value.strip_prefix("kind:").unwrap_or(value) } -fn identity_public_key(identity: &PublicIdentity) -> Result<PublicKey, RadrootsNostrSignerError> { - PublicKey::from_hex(&identity.public_key().to_hex()).map_err(|_| { - RadrootsNostrSignerError::InvalidState("user identity public key is invalid".into()) - }) +fn identity_public_key( + identity: &PublicIdentity, +) -> Result<radroots_identity::PublicKey, RadrootsNostrSignerError> { + Ok(identity.public_key()) } #[cfg(test)] @@ -221,21 +232,33 @@ mod tests { api_primary_https, fixture_alice_identity, fixture_alice_public_key, fixture_bob_identity, fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_key, }; - use nostr::{PublicKey, Timestamp, UnsignedEvent}; + use nostr::{PublicKey, Timestamp}; + use radroots_nostr_connect::prelude::UnsignedEvent as ConnectUnsignedEvent; use serde_json::json; fn public_key(index: u32) -> PublicKey { synthetic_public_key(index) } - fn unsigned_event(kind: u16) -> UnsignedEvent { - serde_json::from_value(json!({ - "pubkey": fixture_alice_public_key().to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": "hello" - })) + fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { + radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") + } + + fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl { + ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay") + } + + fn unsigned_event(kind: u16) -> ConnectUnsignedEvent { + ConnectUnsignedEvent::from_json( + &json!({ + "pubkey": fixture_alice_public_key().to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": kind, + "tags": [], + "content": "hello" + }) + .to_string(), + ) .expect("unsigned event") } @@ -288,14 +311,9 @@ mod tests { ) { match hint { RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => { - let expected_public_key = - PublicKey::from_hex(&fixture_diego_identity().public_key().to_hex()) - .expect("user public key"); - assert_eq!( - capability.user_public_key.to_hex(), - expected_public_key.to_hex() - ); - assert_eq!(capability.relays, vec![primary_relay()]); + let expected_public_key = fixture_diego_identity().public_key(); + assert_eq!(capability.user_public_key, expected_public_key); + assert_eq!(capability.relays, vec![connect_relay(primary_relay())]); assert_eq!(capability.permissions, expected_permissions); } other => panic!("unexpected response hint: {other:?}"), @@ -420,7 +438,7 @@ mod tests { assert!(!request_allowed_by_permissions( &vec![sign_kind, nip44].into(), &RadrootsNostrConnectRequest::Nip04Encrypt { - public_key: public_key(7), + public_key: connect_public_key(public_key(7)), plaintext: "hello".into(), }, )); @@ -461,7 +479,7 @@ mod tests { let connection = connection(); let public_key = public_key(8); let connect = RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: public_key, + remote_signer_public_key: connect_public_key(public_key), secret: Some("secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), client_metadata: None, @@ -483,7 +501,7 @@ mod tests { assert!(required_permission_for_request(&get_session_capability).is_none()); assert_eq!( required_permission_for_request(&RadrootsNostrConnectRequest::Nip04Decrypt { - public_key, + public_key: connect_public_key(public_key), ciphertext: "cipher".into(), }) .expect("nip04 decrypt permission") @@ -492,7 +510,7 @@ mod tests { ); assert_eq!( required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Encrypt { - public_key, + public_key: connect_public_key(public_key), plaintext: "hello".into(), }) .expect("nip44 encrypt permission") @@ -501,7 +519,7 @@ mod tests { ); assert_eq!( required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Decrypt { - public_key, + public_key: connect_public_key(public_key), ciphertext: "cipher".into(), }) .expect("nip44 decrypt permission") @@ -531,7 +549,7 @@ mod tests { response_hint_for_request( &connection, &RadrootsNostrConnectRequest::Nip04Decrypt { - public_key, + public_key: connect_public_key(public_key), ciphertext: "cipher".into(), }, ) @@ -550,7 +568,7 @@ mod tests { ); assert_eq!( response_hint_for_request(&connection, &switch_relays).expect("relay hint"), - RadrootsNostrSignerRequestResponseHint::RelayList(vec![primary_relay()]) + RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay(primary_relay())]) ); } } diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs @@ -210,6 +210,8 @@ impl RadrootsNostrSignerManager { )); }; + let remote_signer_public_key = + radroots_nostr::key::public_key_to_nostr(remote_signer_public_key)?; let (connect_secret, existing_connection) = self.resolve_connect_request_context(remote_signer_public_key, secret)?; if let Some(connection) = existing_connection { @@ -1214,9 +1216,11 @@ mod tests { api_primary_https, fixture_alice_identity, primary_relay, secondary_relay, synthetic_public_identity, synthetic_public_key, tertiary_relay, }; - use nostr::{PublicKey, Timestamp, UnsignedEvent}; + use nostr::{PublicKey, Timestamp}; use radroots_identity::PublicIdentity; - use radroots_nostr_connect::prelude::RadrootsNostrConnectPermission; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectPermission, UnsignedEvent as ConnectUnsignedEvent, + }; use serde_json::json; use std::sync::Arc; use std::thread; @@ -1229,6 +1233,10 @@ mod tests { synthetic_public_key(index) } + fn connect_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { + radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") + } + fn permission( method: RadrootsNostrConnectMethod, parameter: Option<&str>, @@ -1253,14 +1261,17 @@ mod tests { RadrootsNostrConnectRequestMessage::new(id, request) } - fn unsigned_event(kind: u16) -> UnsignedEvent { - serde_json::from_value(json!({ - "pubkey": public_key(0xa1).to_hex(), - "created_at": Timestamp::from(1).as_secs(), - "kind": kind, - "tags": [], - "content": "hello" - })) + fn unsigned_event(kind: u16) -> ConnectUnsignedEvent { + ConnectUnsignedEvent::from_json( + &json!({ + "pubkey": public_key(0xa1).to_hex(), + "created_at": Timestamp::from(1).as_secs(), + "kind": kind, + "tags": [], + "content": "hello" + }) + .to_string(), + ) .expect("unsigned event") } @@ -1564,7 +1575,7 @@ mod tests { RadrootsNostrSignerApprovalState::NotRequired ); assert_eq!(record.auth_state, RadrootsNostrSignerAuthState::NotRequired); - assert_eq!(record.requested_permissions.as_slice(), &[sign_event, ping]); + assert_eq!(record.requested_permissions.as_slice(), &[ping, sign_event]); assert_eq!(record.relays, vec![secondary_relay(), primary_relay()]); } @@ -3458,10 +3469,7 @@ mod tests { .evaluate_connect_request( public_key(0x58), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: PublicKey::from_hex( - &signer_identity.public_key().to_hex(), - ) - .expect("signer public key"), + remote_signer_public_key: signer_identity.public_key(), secret: Some("secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), client_metadata: None, @@ -3734,7 +3742,7 @@ mod tests { .evaluate_connect_request( client_public_key, RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, + remote_signer_public_key: connect_public_key(signer_public_key), secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), client_metadata: None, @@ -3747,7 +3755,7 @@ mod tests { .evaluate_connect_request( client_public_key, RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: public_key(0x66), + remote_signer_public_key: connect_public_key(public_key(0x66)), secret: None, requested_permissions: RadrootsNostrConnectPermissions::default(), client_metadata: None, @@ -3764,7 +3772,7 @@ mod tests { .evaluate_connect_request( client_public_key, RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, + remote_signer_public_key: connect_public_key(signer_public_key), secret: Some(" connect-secret ".into()), requested_permissions: vec![ permission(RadrootsNostrConnectMethod::Ping, None), @@ -3781,7 +3789,7 @@ mod tests { .evaluate_connect_request( public_key(0x67), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, + remote_signer_public_key: connect_public_key(signer_public_key), secret: Some(" fresh-secret ".into()), requested_permissions: vec![ permission(RadrootsNostrConnectMethod::Ping, None), @@ -3812,8 +3820,8 @@ mod tests { assert_eq!( proposal.requested_permissions.as_slice(), &[ - permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")), permission(RadrootsNostrConnectMethod::Ping, None), + permission(RadrootsNostrConnectMethod::SignEvent, Some("kind:1")), ] ); @@ -3821,7 +3829,7 @@ mod tests { .evaluate_connect_request( public_key(0x68), RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, + remote_signer_public_key: connect_public_key(signer_public_key), secret: Some("connect-secret".into()), requested_permissions: RadrootsNostrConnectPermissions::default(), client_metadata: None, @@ -3952,7 +3960,7 @@ mod tests { request_message_with_request( "req-connect", RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: active.client_public_key, + remote_signer_public_key: connect_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 @@ -1,8 +1,8 @@ -use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl}; use nostr::{ - UnsignedEvent, + JsonUtil, UnsignedEvent, filter::{Alphabet, SingleLetterTag}, }; +use nostr::{PublicKey as RadrootsNostrPublicKey, RelayUrl as RadrootsNostrRelayUrl}; use radroots_identity::PublicIdentity; use radroots_nostr::event::Event as RadrootsNostrEvent; use radroots_nostr::event::GenericBuilder; @@ -13,6 +13,7 @@ use radroots_nostr::tag::Tag as RadrootsNostrTag; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectError, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + SignedEvent as ConnectSignedEvent, UnsignedEvent as ConnectUnsignedEvent, }; use crate::backend::RadrootsNostrSignerBackend; @@ -202,13 +203,24 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { &self, unsigned_event: UnsignedEvent, ) -> Result<RadrootsNostrConnectResponse, RadrootsNostrSignerError> { + let unsigned_event = ConnectUnsignedEvent::from_json(&unsigned_event.as_json())?; Ok(self.sign_event_response_value(unsigned_event)) } fn sign_event_response_value( &self, - unsigned_event: UnsignedEvent, + unsigned_event: ConnectUnsignedEvent, ) -> RadrootsNostrConnectResponse { + let unsigned_event = match serde_json::from_str::<UnsignedEvent>(&unsigned_event.as_json()) + { + Ok(unsigned_event) => unsigned_event, + Err(error) => { + return RadrootsNostrConnectResponse::Error { + result: None, + error: format!("invalid sign_event payload: {error}"), + }; + } + }; let user_public_key = self.signer.user_identity().public_key().to_hex(); if unsigned_event.pubkey.to_hex() != user_public_key { return RadrootsNostrConnectResponse::Error { @@ -218,7 +230,13 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { } match self.signer.sign_user_event(unsigned_event) { - Ok(event) => RadrootsNostrConnectResponse::SignedEvent(event), + Ok(event) => match ConnectSignedEvent::from_json(&event.as_json()) { + Ok(event) => RadrootsNostrConnectResponse::SignedEvent(event), + Err(error) => RadrootsNostrConnectResponse::Error { + result: None, + error: format!("failed to encode signed event: {error}"), + }, + }, Err(error) => RadrootsNostrConnectResponse::Error { result: None, error: format!("failed to sign event: {error}"), @@ -241,7 +259,9 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { RadrootsNostrConnectRequest::Nip04Encrypt { public_key, plaintext, - } => match self.signer.nip04_encrypt(&public_key, &plaintext) { + } => match nostr_public_key(public_key) + .and_then(|public_key| self.signer.nip04_encrypt(&public_key, &plaintext)) + { Ok(ciphertext) => RadrootsNostrConnectResponse::Nip04Encrypt(ciphertext), Err(error) => RadrootsNostrConnectResponse::Error { result: None, @@ -251,7 +271,9 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { RadrootsNostrConnectRequest::Nip04Decrypt { public_key, ciphertext, - } => match self.signer.nip04_decrypt(&public_key, &ciphertext) { + } => match nostr_public_key(public_key) + .and_then(|public_key| self.signer.nip04_decrypt(&public_key, &ciphertext)) + { Ok(plaintext) => RadrootsNostrConnectResponse::Nip04Decrypt(plaintext), Err(error) => RadrootsNostrConnectResponse::Error { result: None, @@ -261,7 +283,9 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { RadrootsNostrConnectRequest::Nip44Encrypt { public_key, plaintext, - } => match self.signer.nip44_encrypt(&public_key, &plaintext) { + } => match nostr_public_key(public_key) + .and_then(|public_key| self.signer.nip44_encrypt(&public_key, &plaintext)) + { Ok(ciphertext) => RadrootsNostrConnectResponse::Nip44Encrypt(ciphertext), Err(error) => RadrootsNostrConnectResponse::Error { result: None, @@ -271,7 +295,9 @@ impl<S: RadrootsNostrSignerNip46Signer> RadrootsNostrSignerNip46Codec<S> { RadrootsNostrConnectRequest::Nip44Decrypt { public_key, ciphertext, - } => match self.signer.nip44_decrypt(&public_key, &ciphertext) { + } => match nostr_public_key(public_key) + .and_then(|public_key| self.signer.nip44_decrypt(&public_key, &ciphertext)) + { Ok(plaintext) => RadrootsNostrConnectResponse::Nip44Decrypt(plaintext), Err(error) => RadrootsNostrConnectResponse::Error { result: None, @@ -512,7 +538,7 @@ where &self, client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, - unsigned_event: UnsignedEvent, + unsigned_event: ConnectUnsignedEvent, ) -> Result<RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerError> { let connection = match self.lookup_connection(client_public_key)? { Ok(connection) => connection, @@ -787,13 +813,21 @@ pub fn response_from_hint( RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => { RadrootsNostrConnectResponse::RemoteSessionCapability(capability) } - RadrootsNostrSignerRequestResponseHint::RelayList(relays) => { - if relays == connection.relays { + RadrootsNostrSignerRequestResponseHint::RelayList(relays) => match connection + .relays + .iter() + .map(|relay| radroots_nostr_connect::uri::RelayUrl::parse(&relay.to_string())) + .collect::<Result<Vec<_>, _>>() + { + Ok(connection_relays) if relays == connection_relays => { RadrootsNostrConnectResponse::RelayList(relays) - } else { - RadrootsNostrConnectResponse::RelayList(connection.relays.clone()) } - } + Ok(connection_relays) => RadrootsNostrConnectResponse::RelayList(connection_relays), + Err(error) => RadrootsNostrConnectResponse::Error { + result: None, + error: format!("invalid connection relay state: {error}"), + }, + }, RadrootsNostrSignerRequestResponseHint::None => RadrootsNostrConnectResponse::Error { result: None, error: "request evaluation did not provide a response hint".to_owned(), @@ -801,6 +835,12 @@ pub fn response_from_hint( } } +fn nostr_public_key( + public_key: radroots_identity::PublicKey, +) -> Result<RadrootsNostrPublicKey, RadrootsNostrSignerError> { + radroots_nostr::key::public_key_to_nostr(public_key).map_err(Into::into) +} + pub fn handled_request_for_action<F>( connection: &RadrootsNostrSignerConnectionRecord, action: RadrootsNostrSignerRequestAction, @@ -857,7 +897,7 @@ mod tests { use crate::store::RadrootsNostrSignerStore; use crate::test_support::{fixture_alice_identity, fixture_carol_public_key, primary_relay}; use nostr::PublicKey as RadrootsNostrPublicKey; - use nostr::{Keys, SecretKey, Timestamp, UnsignedEvent}; + use nostr::{JsonUtil, Keys, SecretKey, Timestamp, UnsignedEvent}; use radroots_identity::{PublicIdentity, PublicKey as IdentityPublicKey}; use radroots_nostr::event::Event as RadrootsNostrEvent; use radroots_nostr::event::GenericBuilder; @@ -868,7 +908,9 @@ mod tests { RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + UnsignedEvent as ConnectUnsignedEvent, }; + use radroots_nostr_connect::uri::RelayUrl as ConnectRelayUrl; use std::sync::{ Arc, RwLock, atomic::{AtomicBool, Ordering}, @@ -1083,6 +1125,14 @@ mod tests { ) } + fn connect_public_key(public_key: RadrootsNostrPublicKey) -> IdentityPublicKey { + radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") + } + + fn connect_relay(relay: nostr::RelayUrl) -> ConnectRelayUrl { + ConnectRelayUrl::parse(&relay.to_string()).expect("connect relay") + } + fn test_signer_with_options(sign_events: bool, fail_crypto: bool) -> TestSigner { TestSigner { signer_identity: keys_from_secret( @@ -1136,7 +1186,7 @@ mod tests { RadrootsNostrConnectRequestMessage::new( "req-connect", RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: signer_public_key, + remote_signer_public_key: connect_public_key(signer_public_key), secret: secret.map(ToOwned::to_owned), requested_permissions: permissions.into(), client_metadata: None, @@ -1173,6 +1223,11 @@ mod tests { .expect("unsigned event") } + fn connect_unsigned_event(kind: u16) -> ConnectUnsignedEvent { + let event = unsigned_user_event(kind); + ConnectUnsignedEvent::from_json(&event.as_json()).expect("connect unsigned event") + } + fn registered_connection( backend: &RadrootsNostrEmbeddedSignerBackend, client_public_key: &RadrootsNostrPublicKey, @@ -1280,7 +1335,7 @@ mod tests { assert_eq!( codec .crypto_response(RadrootsNostrConnectRequest::Nip04Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain".to_owned(), }) .expect("nip04 encrypt"), @@ -1289,7 +1344,7 @@ mod tests { assert_eq!( codec .crypto_response(RadrootsNostrConnectRequest::Nip04Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher".to_owned(), }) .expect("nip04 decrypt"), @@ -1298,7 +1353,7 @@ mod tests { assert_eq!( codec .crypto_response(RadrootsNostrConnectRequest::Nip44Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain44".to_owned(), }) .expect("nip44 encrypt"), @@ -1307,7 +1362,7 @@ mod tests { assert_eq!( codec .crypto_response(RadrootsNostrConnectRequest::Nip44Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher44".to_owned(), }) .expect("nip44 decrypt"), @@ -1326,19 +1381,19 @@ mod tests { super::RadrootsNostrSignerNip46Codec::new(test_signer_with_options(false, true)); for request in [ RadrootsNostrConnectRequest::Nip04Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain".to_owned(), }, RadrootsNostrConnectRequest::Nip04Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher".to_owned(), }, RadrootsNostrConnectRequest::Nip44Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain44".to_owned(), }, RadrootsNostrConnectRequest::Nip44Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher44".to_owned(), }, ] { @@ -1513,7 +1568,7 @@ mod tests { ) .expect("relays") ), - RadrootsNostrConnectResponse::RelayList(vec![primary_relay()]) + RadrootsNostrConnectResponse::RelayList(vec![connect_relay(primary_relay())]) ); assert!(matches!( response_from_outcome( @@ -1522,7 +1577,7 @@ mod tests { client_public_key, request_message( "req-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_user_event(1)), + RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(1)), ), ) .expect("sign") @@ -1537,7 +1592,7 @@ mod tests { request_message( "req-nip04-decrypt", RadrootsNostrConnectRequest::Nip04Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher".to_owned(), }, ), @@ -1554,7 +1609,7 @@ mod tests { request_message( "req-nip44-encrypt", RadrootsNostrConnectRequest::Nip44Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain".to_owned(), }, ), @@ -1597,7 +1652,7 @@ mod tests { request_message( "req-denied", RadrootsNostrConnectRequest::Nip04Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher".to_owned(), }, ), @@ -1633,7 +1688,7 @@ mod tests { client_public_key, request_message( "req-policy-denied-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_user_event(1)), + RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(1)), ), ) .expect("policy denied sign"); @@ -1647,7 +1702,7 @@ mod tests { request_message( "req-policy-denied-crypto", RadrootsNostrConnectRequest::Nip44Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain".to_owned(), }, ), @@ -1721,9 +1776,9 @@ mod tests { for request in [ RadrootsNostrConnectRequest::Ping, - RadrootsNostrConnectRequest::SignEvent(unsigned_user_event(1)), + RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(1)), RadrootsNostrConnectRequest::Nip04Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "cipher".to_owned(), }, ] { @@ -1772,7 +1827,7 @@ mod tests { client_public_key, request_message( "req-allowed-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_user_event(1)), + RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(1)), ), ) .expect("allowed sign") @@ -1787,7 +1842,7 @@ mod tests { request_message( "req-allowed-nip04-encrypt", RadrootsNostrConnectRequest::Nip04Encrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), plaintext: "plain".to_owned(), }, ), @@ -1842,7 +1897,7 @@ mod tests { let crypto = request_message( "req-eval-crypto", RadrootsNostrConnectRequest::Nip44Decrypt { - public_key: client_public_key, + public_key: connect_public_key(client_public_key), ciphertext: "sealed".to_owned(), }, ); @@ -1860,7 +1915,7 @@ mod tests { let sign = request_message( "req-eval-sign", - RadrootsNostrConnectRequest::SignEvent(unsigned_user_event(1)), + RadrootsNostrConnectRequest::SignEvent(connect_unsigned_event(1)), ); let sign_eval = backend .evaluate_request(&connection.connection_id, sign.clone()) @@ -1932,13 +1987,15 @@ mod tests { assert_eq!( super::response_from_hint( &connection, - RadrootsNostrSignerRequestResponseHint::UserPublicKey(client_public_key), + RadrootsNostrSignerRequestResponseHint::UserPublicKey(connect_public_key( + client_public_key, + )), ), - RadrootsNostrConnectResponse::UserPublicKey(client_public_key) + RadrootsNostrConnectResponse::UserPublicKey(connect_public_key(client_public_key)) ); let capability = RadrootsNostrConnectRemoteSessionCapability { - user_public_key: client_public_key, - relays: vec![primary_relay()], + user_public_key: connect_public_key(client_public_key), + relays: vec![connect_relay(primary_relay())], permissions: all_runtime_permissions().into(), }; assert_eq!( @@ -1951,16 +2008,18 @@ mod tests { assert_eq!( super::response_from_hint( &connection, - RadrootsNostrSignerRequestResponseHint::RelayList(vec![primary_relay()]), + RadrootsNostrSignerRequestResponseHint::RelayList(vec![connect_relay( + primary_relay(), + )]), ), - RadrootsNostrConnectResponse::RelayList(vec![primary_relay()]) + RadrootsNostrConnectResponse::RelayList(vec![connect_relay(primary_relay())]) ); assert_eq!( super::response_from_hint( &connection, RadrootsNostrSignerRequestResponseHint::RelayList(Vec::new()), ), - RadrootsNostrConnectResponse::RelayList(vec![primary_relay()]) + RadrootsNostrConnectResponse::RelayList(vec![connect_relay(primary_relay())]) ); assert!(matches!( super::response_from_hint(&connection, RadrootsNostrSignerRequestResponseHint::None), @@ -2169,7 +2228,9 @@ mod tests { assert_eq!( request.request, RadrootsNostrConnectRequest::Connect { - remote_signer_public_key: test_signer().signer_identity.public_key(), + remote_signer_public_key: connect_public_key( + test_signer().signer_identity.public_key(), + ), secret: None, requested_permissions: vec![RadrootsNostrConnectPermission::new( RadrootsNostrConnectMethod::Nip04Encrypt,