lib

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

commit ec2d26f050a21b4d41de1fe67dca6add9a7d4253
parent a7857a0bde787f477788eefb7cfdf9700f15a125
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 23:10:30 +0000

nostr-connect: add async NIP-46 client substrate

- add transport-neutral client target, request, progress, and async execution primitives
- encrypt and decrypt request and response events with signer/client identity checks
- expose typed protocol, transport, timeout, signing, and crypto errors
- cover connect, capability, sign, auth, timeout, malformed, and unrelated responses

Diffstat:
MCargo.lock | 1+
Mcrates/nostr_connect/Cargo.toml | 3++-
Acrates/nostr_connect/src/client.rs | 175+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_connect/src/error.rs | 10++++++++++
Mcrates/nostr_connect/src/lib.rs | 7+++++++
Acrates/nostr_connect/tests/client.rs | 443+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 638 insertions(+), 1 deletion(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4387,6 +4387,7 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "tokio", "url", ] diff --git a/crates/nostr_connect/Cargo.toml b/crates/nostr_connect/Cargo.toml @@ -13,10 +13,11 @@ documentation = "https://docs.rs/radroots_nostr_connect" readme = "README" [dependencies] -nostr = { workspace = true } +nostr = { workspace = true, features = ["nip44"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } url = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs @@ -0,0 +1,175 @@ +use crate::error::RadrootsNostrConnectError; +use crate::message::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectResponseEnvelope, +}; +use crate::method::RadrootsNostrConnectMethod; +use nostr::nips::nip44::{self, Version}; +use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag}; +use std::future::Future; +use std::pin::Pin; + +pub type RadrootsNostrConnectClientTransportFuture<'a, T> = + Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsNostrConnectClientTarget { + pub remote_signer_public_key: PublicKey, + pub relays: Vec<RelayUrl>, +} + +impl RadrootsNostrConnectClientTarget { + pub fn new(remote_signer_public_key: PublicKey, relays: Vec<RelayUrl>) -> Self { + Self { + remote_signer_public_key, + relays, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RadrootsNostrConnectClientRequest { + pub request_id: String, + pub request: RadrootsNostrConnectRequest, +} + +impl RadrootsNostrConnectClientRequest { + pub fn new(request_id: impl Into<String>, request: RadrootsNostrConnectRequest) -> Self { + Self { + request_id: request_id.into(), + request, + } + } + + pub fn method(&self) -> RadrootsNostrConnectMethod { + self.request.method() + } + + pub fn into_message(self) -> RadrootsNostrConnectRequestMessage { + RadrootsNostrConnectRequestMessage::new(self.request_id, self.request) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectClientProgress { + AuthChallenge { url: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RadrootsNostrConnectClientEventOutcome { + Ignore, + Progress(RadrootsNostrConnectClientProgress), + Response(RadrootsNostrConnectResponse), +} + +pub trait RadrootsNostrConnectClientTransport { + fn publish_request_event<'a>( + &'a mut self, + event: Event, + ) -> RadrootsNostrConnectClientTransportFuture<'a, ()>; + + fn next_response_event<'a>( + &'a mut self, + ) -> RadrootsNostrConnectClientTransportFuture<'a, Event>; +} + +pub fn build_request_event( + client_keys: &Keys, + target: &RadrootsNostrConnectClientTarget, + message: RadrootsNostrConnectRequestMessage, +) -> Result<Event, RadrootsNostrConnectError> { + let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?; + let ciphertext = nip44::encrypt( + client_keys.secret_key(), + &target.remote_signer_public_key, + payload, + Version::V2, + ) + .map_err(|error| RadrootsNostrConnectError::Encrypt { + reason: error.to_string(), + })?; + + EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + .tag(Tag::public_key(target.remote_signer_public_key)) + .sign_with_keys(client_keys) + .map_err(|error| RadrootsNostrConnectError::Sign { + reason: error.to_string(), + }) +} + +pub fn parse_response_event( + client_keys: &Keys, + target: &RadrootsNostrConnectClientTarget, + request_id: &str, + method: &RadrootsNostrConnectMethod, + event: &Event, +) -> Result<RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectError> { + if event.kind != Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); + } + + if event.pubkey != target.remote_signer_public_key { + return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); + } + + let client_public_key = client_keys.public_key(); + if !event + .tags + .public_keys() + .any(|public_key| *public_key == client_public_key) + { + return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); + } + + let decrypted = nip44::decrypt( + client_keys.secret_key(), + &target.remote_signer_public_key, + &event.content, + ) + .map_err(|error| RadrootsNostrConnectError::Decrypt { + reason: error.to_string(), + })?; + + let envelope: RadrootsNostrConnectResponseEnvelope = + 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)?; + Ok(match response { + RadrootsNostrConnectResponse::AuthUrl(url) => { + RadrootsNostrConnectClientEventOutcome::Progress( + RadrootsNostrConnectClientProgress::AuthChallenge { url }, + ) + } + response => RadrootsNostrConnectClientEventOutcome::Response(response), + }) +} + +pub async fn execute_request_with_transport<T, F>( + client_keys: &Keys, + target: &RadrootsNostrConnectClientTarget, + request: RadrootsNostrConnectClientRequest, + transport: &mut T, + mut on_progress: F, +) -> Result<RadrootsNostrConnectResponse, RadrootsNostrConnectError> +where + T: RadrootsNostrConnectClientTransport, + F: FnMut(RadrootsNostrConnectClientProgress) -> Result<(), RadrootsNostrConnectError>, +{ + let method = request.method(); + let request_id = request.request_id.clone(); + let event = build_request_event(client_keys, target, request.into_message())?; + transport.publish_request_event(event).await?; + + loop { + let event = transport.next_response_event().await?; + match parse_response_event(client_keys, target, &request_id, &method, &event)? { + RadrootsNostrConnectClientEventOutcome::Ignore => {} + RadrootsNostrConnectClientEventOutcome::Progress(progress) => on_progress(progress)?, + RadrootsNostrConnectClientEventOutcome::Response(response) => return Ok(response), + } + } +} diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs @@ -2,6 +2,16 @@ use thiserror::Error; #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum RadrootsNostrConnectError { + #[error("NIP-46 request encryption failed: {reason}")] + Encrypt { reason: String }, + #[error("NIP-46 response decryption failed: {reason}")] + Decrypt { reason: String }, + #[error("NIP-46 event signing failed: {reason}")] + Sign { reason: String }, + #[error("NIP-46 transport failed: {reason}")] + Transport { reason: String }, + #[error("NIP-46 request timed out")] + RequestTimedOut, #[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 @@ -1,5 +1,6 @@ #![forbid(unsafe_code)] +pub mod client; pub mod error; pub mod message; pub mod method; @@ -7,6 +8,12 @@ pub mod permission; pub mod uri; pub mod prelude { + pub use crate::client::{ + RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectClientProgress, + RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget, + RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture, + build_request_event, execute_request_with_transport, parse_response_event, + }; pub use crate::error::RadrootsNostrConnectError; pub use crate::message::{ RADROOTS_NOSTR_CONNECT_PENDING_CONNECTION_ERROR, RADROOTS_NOSTR_CONNECT_RPC_KIND, diff --git a/crates/nostr_connect/tests/client.rs b/crates/nostr_connect/tests/client.rs @@ -0,0 +1,443 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use nostr::nips::nip44::{self, Version}; +use nostr::{ + Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent, +}; +use radroots_nostr_connect::prelude::{ + RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome, + RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest, + RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport, + RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError, + RadrootsNostrConnectMethod, RadrootsNostrConnectRemoteSessionCapability, + RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + build_request_event, execute_request_with_transport, parse_response_event, +}; +use std::collections::VecDeque; +use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, RELAY_PRIMARY_WSS}; + +fn keys(secret_key_hex: &str) -> Keys { + let secret_key = SecretKey::from_hex(secret_key_hex).expect("secret key"); + Keys::new(secret_key) +} + +fn client_keys() -> Keys { + keys(FIXTURE_ALICE.secret_key_hex) +} + +fn remote_signer_keys() -> Keys { + keys(FIXTURE_BOB.secret_key_hex) +} + +fn other_keys() -> Keys { + keys(FIXTURE_CAROL.secret_key_hex) +} + +fn relay() -> RelayUrl { + RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay") +} + +fn target(remote_keys: &Keys) -> RadrootsNostrConnectClientTarget { + RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), vec![relay()]) +} + +fn unsigned_event(pubkey: PublicKey) -> UnsignedEvent { + EventBuilder::text_note("remote signing") + .custom_created_at(Timestamp::from(1_714_078_911)) + .build(pubkey) +} + +fn signed_event(keys: &Keys) -> Event { + EventBuilder::text_note("signed remotely") + .custom_created_at(Timestamp::from(1_714_078_911)) + .sign_with_keys(keys) + .expect("signed event") +} + +fn response_event( + remote_keys: &Keys, + client_public_key: PublicKey, + request_id: &str, + response: RadrootsNostrConnectResponse, +) -> Event { + let envelope = response + .into_envelope(request_id) + .expect("response envelope"); + let payload = serde_json::to_string(&envelope).expect("response payload"); + let ciphertext = nip44::encrypt( + remote_keys.secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .expect("response ciphertext"); + + EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + .tag(Tag::public_key(client_public_key)) + .sign_with_keys(remote_keys) + .expect("response event") +} + +fn untagged_response_event( + remote_keys: &Keys, + client_public_key: PublicKey, + request_id: &str, + response: RadrootsNostrConnectResponse, +) -> Event { + let envelope = response + .into_envelope(request_id) + .expect("response envelope"); + let payload = serde_json::to_string(&envelope).expect("response payload"); + let ciphertext = nip44::encrypt( + remote_keys.secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .expect("response ciphertext"); + + EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) + .sign_with_keys(remote_keys) + .expect("response event") +} + +fn remote_session_capability(remote_keys: &Keys) -> RadrootsNostrConnectRemoteSessionCapability { + RadrootsNostrConnectRemoteSessionCapability { + user_public_key: remote_keys.public_key(), + relays: vec![relay()], + permissions: Vec::new().into(), + } +} + +struct MockTransport { + published: Vec<Event>, + inbound: VecDeque<Event>, +} + +impl MockTransport { + fn new(inbound: Vec<Event>) -> Self { + Self { + published: Vec::new(), + inbound: inbound.into(), + } + } +} + +impl RadrootsNostrConnectClientTransport for MockTransport { + fn publish_request_event<'a>( + &'a mut self, + event: Event, + ) -> RadrootsNostrConnectClientTransportFuture<'a, ()> { + self.published.push(event); + Box::pin(async { Ok(()) }) + } + + fn next_response_event<'a>( + &'a mut self, + ) -> RadrootsNostrConnectClientTransportFuture<'a, Event> { + let next = self.inbound.pop_front(); + Box::pin(async move { next.ok_or(RadrootsNostrConnectError::RequestTimedOut) }) + } +} + +#[tokio::test] +async fn executes_connect_request_and_secret_echo_response() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let mut transport = MockTransport::new(vec![response_event( + &remote_keys, + client_keys.public_key(), + "req-connect", + RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned()), + )]); + + let response = execute_request_with_transport( + &client_keys, + &target, + RadrootsNostrConnectClientRequest::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: remote_keys.public_key(), + secret: Some("connect-secret".to_owned()), + requested_permissions: Vec::new().into(), + }, + ), + &mut transport, + |_| Ok(()), + ) + .await + .expect("connect response"); + + assert_eq!( + response, + RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned()) + ); + assert_eq!(transport.published.len(), 1); +} + +#[tokio::test] +async fn executes_capability_request_and_typed_response() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let capability = remote_session_capability(&remote_keys); + let mut transport = MockTransport::new(vec![response_event( + &remote_keys, + client_keys.public_key(), + "req-capability", + RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone()), + )]); + + let response = execute_request_with_transport( + &client_keys, + &target, + RadrootsNostrConnectClientRequest::new( + "req-capability", + RadrootsNostrConnectRequest::GetSessionCapability, + ), + &mut transport, + |_| Ok(()), + ) + .await + .expect("capability response"); + + assert_eq!( + response, + RadrootsNostrConnectResponse::RemoteSessionCapability(capability) + ); + assert_eq!(transport.published.len(), 1); +} + +#[tokio::test] +async fn reports_timeout_when_transport_has_no_matching_response() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let mut transport = MockTransport::new(Vec::new()); + + let error = execute_request_with_transport( + &client_keys, + &target, + RadrootsNostrConnectClientRequest::new("req-timeout", RadrootsNostrConnectRequest::Ping), + &mut transport, + |_| Ok(()), + ) + .await + .expect_err("timeout"); + + assert_eq!(error, RadrootsNostrConnectError::RequestTimedOut); + assert_eq!(transport.published.len(), 1); +} + +#[test] +fn builds_encrypted_request_event_for_remote_signer() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let message = + RadrootsNostrConnectRequestMessage::new("req-ping", RadrootsNostrConnectRequest::Ping); + + let event = build_request_event(&client_keys, &target, message.clone()).expect("event"); + + assert_eq!(event.kind, Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)); + assert_eq!(event.pubkey, client_keys.public_key()); + assert!( + event + .tags + .public_keys() + .any(|public_key| *public_key == remote_keys.public_key()) + ); + assert!(!event.content.contains("ping")); + + let decrypted = nip44::decrypt( + remote_keys.secret_key(), + &client_keys.public_key(), + &event.content, + ) + .expect("decrypt request"); + let decoded: RadrootsNostrConnectRequestMessage = + serde_json::from_str(&decrypted).expect("decode request"); + assert_eq!(decoded, message); +} + +#[test] +fn ignores_response_from_unexpected_signer_identity() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let other_keys = other_keys(); + let target = target(&remote_keys); + let response = response_event( + &other_keys, + client_keys.public_key(), + "req-ping", + RadrootsNostrConnectResponse::Pong, + ); + + let outcome = parse_response_event( + &client_keys, + &target, + "req-ping", + &RadrootsNostrConnectMethod::Ping, + &response, + ) + .expect("parse response"); + + assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore); +} + +#[tokio::test] +async fn executes_request_through_transport_with_auth_progress() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let signed = signed_event(&remote_keys); + let inbound = vec![ + response_event( + &remote_keys, + client_keys.public_key(), + "other-request", + RadrootsNostrConnectResponse::Pong, + ), + response_event( + &remote_keys, + client_keys.public_key(), + "req-sign", + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()), + ), + response_event( + &remote_keys, + client_keys.public_key(), + "req-sign", + RadrootsNostrConnectResponse::SignedEvent(signed.clone()), + ), + ]; + let mut transport = MockTransport::new(inbound); + let mut progress = Vec::new(); + + let response = execute_request_with_transport( + &client_keys, + &target, + RadrootsNostrConnectClientRequest::new( + "req-sign", + RadrootsNostrConnectRequest::SignEvent(unsigned_event(remote_keys.public_key())), + ), + &mut transport, + |event| { + progress.push(event); + Ok(()) + }, + ) + .await + .expect("response"); + + assert_eq!(transport.published.len(), 1); + assert_eq!( + progress, + vec![RadrootsNostrConnectClientProgress::AuthChallenge { + url: "https://auth.example.com/challenge".to_owned() + }] + ); + assert_eq!(response, RadrootsNostrConnectResponse::SignedEvent(signed)); +} + +#[tokio::test] +async fn ignores_events_not_addressed_by_expected_signer_and_client() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let other_keys = other_keys(); + let target = target(&remote_keys); + let wrong_author = EventBuilder::new( + Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), + "not encrypted for this client", + ) + .tag(Tag::public_key(client_keys.public_key())) + .sign_with_keys(&other_keys) + .expect("wrong author event"); + let missing_client_tag = untagged_response_event( + &remote_keys, + client_keys.public_key(), + "req-ping", + RadrootsNostrConnectResponse::Pong, + ); + let valid = response_event( + &remote_keys, + client_keys.public_key(), + "req-ping", + RadrootsNostrConnectResponse::Pong, + ); + let mut transport = MockTransport::new(vec![wrong_author, missing_client_tag, valid]); + + let response = execute_request_with_transport( + &client_keys, + &target, + RadrootsNostrConnectClientRequest::new("req-ping", RadrootsNostrConnectRequest::Ping), + &mut transport, + |_| Ok(()), + ) + .await + .expect("response"); + + assert_eq!(response, RadrootsNostrConnectResponse::Pong); + assert_eq!(transport.published.len(), 1); +} + +#[test] +fn reports_decryption_failure_from_expected_signer() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let malformed = EventBuilder::new( + Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), + "not nip44 ciphertext", + ) + .tag(Tag::public_key(client_keys.public_key())) + .sign_with_keys(&remote_keys) + .expect("malformed response"); + + let error = parse_response_event( + &client_keys, + &target, + "req-ping", + &RadrootsNostrConnectMethod::Ping, + &malformed, + ) + .expect_err("decrypt failure"); + + assert!(matches!( + error, + RadrootsNostrConnectError::Decrypt { reason } if !reason.is_empty() + )); +} + +#[test] +fn parses_auth_challenge_as_progress_without_consuming_final_response() { + let client_keys = client_keys(); + let remote_keys = remote_signer_keys(); + let target = target(&remote_keys); + let auth = response_event( + &remote_keys, + client_keys.public_key(), + "req-sign", + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/continue".to_owned()), + ); + + let outcome = parse_response_event( + &client_keys, + &target, + "req-sign", + &RadrootsNostrConnectMethod::SignEvent, + &auth, + ) + .expect("parse auth"); + + assert_eq!( + outcome, + RadrootsNostrConnectClientEventOutcome::Progress( + RadrootsNostrConnectClientProgress::AuthChallenge { + url: "https://auth.example.com/continue".to_owned() + } + ) + ); +}