lib

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

commit 85144e8d709a7d4729a9067d931386061fa73fb8
parent c8acf17e291877855fdf0301fb74f58165c64955
Author: triesap <tyson@radroots.org>
Date:   Fri, 31 Jul 2026 19:16:30 +0000

nostr-connect: refactor the client state machine

- add a package-owned client, target, event, and relay-independent transport SPI
- model prepared, published, completed, timed-out, and cancelled request outcomes
- verify signer events, correlate responses, reject replays, and redact diagnostics
- cover happy, auth, timeout, cancellation, wrong-response, and duplicate paths

Diffstat:
Mcrates/nostr_connect/src/client.rs | 456++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr_connect/src/error.rs | 8++++++++
Mcrates/nostr_connect/src/lib.rs | 9++++++---
Acrates/nostr_connect/tests/client_state_machine.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/nostr_connect/tests/package_boundary.rs | 25+++++++++++++++++++++++++
5 files changed, 820 insertions(+), 5 deletions(-)

diff --git a/crates/nostr_connect/src/client.rs b/crates/nostr_connect/src/client.rs @@ -1,10 +1,459 @@ use crate::error::RadrootsNostrConnectError; -use crate::message::{RPC_KIND, Request, RequestMessage, Response, ResponseEnvelope}; +use crate::message::{ + RPC_KIND, Request, RequestId, RequestMessage, Response, ResponseEnvelope, ResponseValidator, +}; use crate::method::Method; +use crate::uri::{RELAY_COUNT_MAX, RelayUrl as ConnectRelayUrl}; use nostr::nips::nip44::{self, Version}; -use nostr::{Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, Tag}; +use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag}; use std::future::Future; use std::pin::Pin; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +pub const CLIENT_EVENT_MAX_BYTES: usize = 524_288; + +/// A relay-independent NIP-46 client with single-owner session key material. +/// +/// The client owns request encryption, event signing, response selection, and +/// protocol state. A host-provided [`Transport`] owns publication, waiting, +/// timeout policy, and cancellation wakeups. Dropping an execution future after +/// publication does not retract the remote request; use [`CancellationToken`] +/// so the resulting [`CancellationPhase`] is observed explicitly. +pub struct Client { + keys: Keys, + target: Target, + target_nostr_public_key: PublicKey, +} + +impl Client { + /// Generates fresh single-owner client key material for this session. + pub fn generate(target: Target) -> Result<Self, RadrootsNostrConnectError> { + Self::from_keys(Keys::generate(), target) + } + + /// Builds a client from a persisted hexadecimal or NIP-19 secret. + /// + /// Invalid inputs are normalized and never retained in diagnostics. + pub fn from_secret(secret: &str, target: Target) -> Result<Self, RadrootsNostrConnectError> { + let secret = + SecretKey::parse(secret).map_err(|_| RadrootsNostrConnectError::InvalidClientKey)?; + Self::from_keys(Keys::new(secret), target) + } + + fn from_keys(keys: Keys, target: Target) -> Result<Self, RadrootsNostrConnectError> { + let target_nostr_public_key = radroots_nostr::key::public_key_to_nostr( + target.remote_signer_public_key, + ) + .map_err(|_| RadrootsNostrConnectError::InvalidClientTarget { + reason: "remote signer public key is not a valid Nostr key", + })?; + Ok(Self { + keys, + target, + target_nostr_public_key, + }) + } + + /// Returns the public identity of this client session. + pub fn public_key(&self) -> Result<radroots_identity::PublicKey, RadrootsNostrConnectError> { + radroots_nostr::key::public_key_from_nostr(self.keys.public_key()).map_err(|_| { + RadrootsNostrConnectError::InvalidClientState { + reason: "client public key is invalid", + } + }) + } + + #[must_use] + pub fn target(&self) -> &Target { + &self.target + } + + /// Constructs a validated, encrypted request in the prepared state. + pub fn prepare( + &self, + request_id: RequestId, + request: Request, + ) -> Result<Operation<'_>, RadrootsNostrConnectError> { + let method = request.method(); + let message = RequestMessage::try_new(request_id.to_string(), request)?; + let event = build_request_event_for(&self.keys, self.target_nostr_public_key, message)?; + Ok(Operation { + client: self, + request_id: request_id.clone(), + method, + publication: ClientEvent(event), + phase: OperationPhase::Prepared, + validator: ResponseValidator::new(request_id, self.target.remote_signer_public_key), + }) + } + + /// Publishes and drives one request to response or explicit cancellation. + /// + /// Timeout policy belongs to `transport`, which reports [`Receive::TimedOut`]. + /// The transport must observe `cancellation` while waiting and return + /// [`Receive::Cancelled`] promptly when cancellation wins its host-level + /// wait. Cancellation, progress-observer errors, or future drops after + /// publication stop local waiting without retracting signer-side work. + pub async fn execute<T, F>( + &self, + request_id: RequestId, + request: Request, + transport: &mut T, + cancellation: &CancellationToken, + mut on_progress: F, + ) -> Result<Completion, RadrootsNostrConnectError> + where + T: Transport, + F: FnMut(Progress) -> Result<(), RadrootsNostrConnectError>, + { + let mut operation = self.prepare(request_id, request)?; + if cancellation.is_cancelled() { + return operation.cancel().map(Completion::Cancelled); + } + + transport.publish(operation.publication()?.clone()).await?; + operation.mark_published()?; + if cancellation.is_cancelled() { + return operation.cancel().map(Completion::Cancelled); + } + + loop { + match transport.receive(cancellation).await? { + Receive::Event(event) => match operation.select(&event)? { + EventOutcome::Ignore => {} + EventOutcome::Progress(progress) => on_progress(progress)?, + EventOutcome::Complete(response) => { + return Ok(Completion::Response(response)); + } + }, + Receive::TimedOut => return Err(RadrootsNostrConnectError::RequestTimedOut), + Receive::Cancelled => return operation.cancel().map(Completion::Cancelled), + } + } + } +} + +impl std::fmt::Debug for Client { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Client") + .field("key_material", &"<redacted>") + .field("target", &self.target) + .finish() + } +} + +/// A validated remote-signer target independent of relay implementation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Target { + remote_signer_public_key: radroots_identity::PublicKey, + relays: Vec<ConnectRelayUrl>, +} + +impl Target { + pub fn try_new( + remote_signer_public_key: radroots_identity::PublicKey, + relays: Vec<ConnectRelayUrl>, + ) -> Result<Self, RadrootsNostrConnectError> { + if relays.len() > RELAY_COUNT_MAX { + return Err(RadrootsNostrConnectError::InvalidClientTarget { + reason: "relay count exceeds its limit", + }); + } + let mut normalized = Vec::with_capacity(relays.len()); + for relay in relays { + if !normalized.contains(&relay) { + normalized.push(relay); + } + } + Ok(Self { + remote_signer_public_key, + relays: normalized, + }) + } + + #[must_use] + pub const fn remote_signer_public_key(&self) -> radroots_identity::PublicKey { + self.remote_signer_public_key + } + + #[must_use] + pub fn relays(&self) -> &[ConnectRelayUrl] { + &self.relays + } +} + +/// A signed NIP-46 protocol event with a package-owned representation. +#[derive(Clone, PartialEq, Eq)] +pub struct ClientEvent(Event); + +impl ClientEvent { + pub fn from_json(value: &str) -> Result<Self, RadrootsNostrConnectError> { + if value.len() > CLIENT_EVENT_MAX_BYTES { + return Err(RadrootsNostrConnectError::InvalidClientEvent); + } + Event::from_json(value) + .map(Self) + .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent) + } + + #[must_use] + pub fn as_json(&self) -> String { + self.0.as_json() + } +} + +impl std::fmt::Debug for ClientEvent { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("ClientEvent(<redacted>)") + } +} + +#[derive(PartialEq, Eq)] +pub enum Progress { + AuthChallenge { url: String }, +} + +impl std::fmt::Debug for Progress { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Progress::AuthChallenge(<redacted>)") + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum EventOutcome { + Ignore, + Progress(Progress), + Complete(Box<Response>), +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Completion { + Response(Box<Response>), + Cancelled(CancellationPhase), +} + +impl Completion { + #[must_use] + pub fn response(response: Response) -> Self { + Self::Response(Box::new(response)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CancellationPhase { + BeforePublication, + AfterPublication, +} + +/// A cloneable host cancellation signal containing no runtime dependency. +#[derive(Debug, Clone, Default)] +pub struct CancellationToken(Arc<AtomicBool>); + +impl CancellationToken { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.0.store(true, Ordering::Release); + } + + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Receive { + Event(Box<ClientEvent>), + TimedOut, + Cancelled, +} + +impl Receive { + #[must_use] + pub fn event(event: ClientEvent) -> Self { + Self::Event(Box::new(event)) + } +} + +pub type TransportFuture<'a, T> = + Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>; + +/// Host SPI for publishing and receiving package-owned NIP-46 events. +/// +/// External implementations are supported. The trait is dyn-compatible, +/// `Send`, and returns `Send` futures. Implementations normalize backend +/// failures into [`RadrootsNostrConnectError::Transport`]. The host owns +/// deadlines and reports them as [`Receive::TimedOut`]. A successful `publish` +/// may durably expose the request to a remote signer. Once that call returns, +/// cancellation only stops local waiting and cannot retract remote work. +/// `receive` must observe its token and return [`Receive::Cancelled`] when host +/// cancellation wins. +pub trait Transport: Send { + fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()>; + + fn receive<'a>( + &'a mut self, + cancellation: &'a CancellationToken, + ) -> TransportFuture<'a, Receive>; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OperationPhase { + Prepared, + Published, + Completed, + Cancelled, +} + +/// One request's explicit prepared/published/completed state machine. +pub struct Operation<'a> { + client: &'a Client, + request_id: RequestId, + method: Method, + publication: ClientEvent, + phase: OperationPhase, + validator: ResponseValidator, +} + +impl Operation<'_> { + pub fn publication(&self) -> Result<&ClientEvent, RadrootsNostrConnectError> { + if self.phase != OperationPhase::Prepared { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "publication is only available while prepared", + }); + } + Ok(&self.publication) + } + + pub fn mark_published(&mut self) -> Result<(), RadrootsNostrConnectError> { + if self.phase != OperationPhase::Prepared { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "only a prepared request can be marked published", + }); + } + self.phase = OperationPhase::Published; + Ok(()) + } + + pub fn cancel(&mut self) -> Result<CancellationPhase, RadrootsNostrConnectError> { + let cancellation = match self.phase { + OperationPhase::Prepared => CancellationPhase::BeforePublication, + OperationPhase::Published => CancellationPhase::AfterPublication, + OperationPhase::Completed => { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "completed request cannot be cancelled", + }); + } + OperationPhase::Cancelled => { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "request is already cancelled", + }); + } + }; + self.phase = OperationPhase::Cancelled; + Ok(cancellation) + } + + pub fn select( + &mut self, + event: &ClientEvent, + ) -> Result<EventOutcome, RadrootsNostrConnectError> { + if matches!( + self.phase, + OperationPhase::Prepared | OperationPhase::Cancelled + ) { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "responses require a published active request", + }); + } + + let event = &event.0; + if event.kind != Kind::Custom(RPC_KIND) + || event.pubkey != self.client.target_nostr_public_key + || !event + .tags + .public_keys() + .any(|public_key| *public_key == self.client.keys.public_key()) + { + return Ok(EventOutcome::Ignore); + } + event + .verify() + .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)?; + + let decrypted = nip44::decrypt( + self.client.keys.secret_key(), + &self.client.target_nostr_public_key, + &event.content, + ) + .map_err(|error| RadrootsNostrConnectError::Decrypt { + reason: error.to_string(), + })?; + let envelope: ResponseEnvelope = + serde_json::from_str(&decrypted).map_err(RadrootsNostrConnectError::from)?; + if envelope.request_id()? != self.request_id { + return Ok(EventOutcome::Ignore); + } + self.validator.validate( + self.client.target.remote_signer_public_key, + event.id.to_hex(), + &envelope, + )?; + if self.phase == OperationPhase::Completed { + return Err(RadrootsNostrConnectError::InvalidClientState { + reason: "request already completed", + }); + } + + match Response::from_envelope(&self.method, envelope)? { + Response::AuthUrl(url) => Ok(EventOutcome::Progress(Progress::AuthChallenge { url })), + response => { + self.phase = OperationPhase::Completed; + Ok(EventOutcome::Complete(Box::new(response))) + } + } + } +} + +impl std::fmt::Debug for Operation<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("Operation") + .field("request_id", &self.request_id) + .field("method", &self.method) + .field("phase", &self.phase) + .field("publication", &"<redacted>") + .finish() + } +} + +fn build_request_event_for( + client_keys: &Keys, + remote_signer_public_key: PublicKey, + message: RequestMessage, +) -> Result<Event, RadrootsNostrConnectError> { + let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?; + let ciphertext = nip44::encrypt( + client_keys.secret_key(), + &remote_signer_public_key, + payload, + Version::V2, + ) + .map_err(encrypt_error)?; + + EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) + .tag(Tag::public_key(remote_signer_public_key)) + .sign_with_keys(client_keys) + .map_err(sign_error) +} pub type RadrootsNostrConnectClientTransportFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>; @@ -113,6 +562,9 @@ pub fn parse_response_event( { return Ok(RadrootsNostrConnectClientEventOutcome::Ignore); } + event + .verify() + .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)?; let decrypted = nip44::decrypt( client_keys.secret_key(), diff --git a/crates/nostr_connect/src/error.rs b/crates/nostr_connect/src/error.rs @@ -12,6 +12,14 @@ pub enum RadrootsNostrConnectError { Transport { reason: String }, #[error("NIP-46 request timed out")] RequestTimedOut, + #[error("invalid NIP-46 client key")] + InvalidClientKey, + #[error("invalid NIP-46 client target: {reason}")] + InvalidClientTarget { reason: &'static str }, + #[error("invalid NIP-46 client event")] + InvalidClientEvent, + #[error("invalid NIP-46 client state: {reason}")] + InvalidClientState { reason: &'static str }, #[error("invalid NIP-46 request id: {reason}")] InvalidRequestId { reason: &'static str }, #[error("NIP-46 response id does not match the request")] diff --git a/crates/nostr_connect/src/lib.rs b/crates/nostr_connect/src/lib.rs @@ -9,6 +9,7 @@ pub mod permission; pub mod server; pub mod uri; +pub use client::Client; pub use error::RadrootsNostrConnectError as Error; pub use message::{Request, Response}; pub use method::Method; @@ -20,9 +21,11 @@ pub use uri::{BunkerUri, ClientUri}; #[doc(hidden)] pub mod prelude { pub use crate::client::{ - RadrootsNostrConnectClientEventOutcome, RadrootsNostrConnectClientProgress, - RadrootsNostrConnectClientRequest, RadrootsNostrConnectClientTarget, - RadrootsNostrConnectClientTransport, RadrootsNostrConnectClientTransportFuture, + CancellationPhase, CancellationToken, Client, ClientEvent, Completion, EventOutcome, + Operation, Progress, RadrootsNostrConnectClientEventOutcome, + RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest, + RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport, + RadrootsNostrConnectClientTransportFuture, Receive, Target, Transport, TransportFuture, build_request_event, execute_request_with_transport, parse_response_event, }; pub use crate::error::RadrootsNostrConnectError; diff --git a/crates/nostr_connect/tests/client_state_machine.rs b/crates/nostr_connect/tests/client_state_machine.rs @@ -0,0 +1,327 @@ +#[path = "../src/test_fixtures.rs"] +mod test_fixtures; + +use nostr::nips::nip44::{self, Version}; +use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; +use radroots_nostr_connect::client::{ + CLIENT_EVENT_MAX_BYTES, CancellationPhase, CancellationToken, ClientEvent, Completion, + EventOutcome, Progress, Receive, Target, Transport, TransportFuture, +}; +use radroots_nostr_connect::message::{RPC_KIND, RequestId}; +use radroots_nostr_connect::uri::RelayUrl; +use radroots_nostr_connect::{Client, Error, Request, Response}; +use std::collections::VecDeque; +use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, RELAY_PRIMARY_WSS}; + +fn keys(secret_key_hex: &str) -> Keys { + Keys::new(SecretKey::from_hex(secret_key_hex).expect("secret key")) +} + +fn identity_public_key(public_key: PublicKey) -> radroots_identity::PublicKey { + radroots_nostr::key::public_key_from_nostr(public_key).expect("identity public key") +} + +fn client(remote_keys: &Keys) -> Client { + Client::from_secret( + FIXTURE_ALICE.secret_key_hex, + Target::try_new( + identity_public_key(remote_keys.public_key()), + vec![RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")], + ) + .expect("target"), + ) + .expect("client") +} + +fn response_event( + remote_keys: &Keys, + client_public_key: radroots_identity::PublicKey, + request_id: &str, + response: Response, +) -> ClientEvent { + let client_public_key = + radroots_nostr::key::public_key_to_nostr(client_public_key).expect("client public key"); + let envelope = response + .into_envelope(request_id) + .expect("response envelope"); + let payload = serde_json::to_string(&envelope).expect("response JSON"); + let ciphertext = nip44::encrypt( + remote_keys.secret_key(), + &client_public_key, + payload, + Version::V2, + ) + .expect("response encryption"); + let event = EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) + .tag(Tag::public_key(client_public_key)) + .sign_with_keys(remote_keys) + .expect("response event"); + ClientEvent::from_json(&serde_json::to_string(&event).expect("event JSON")) + .expect("client event") +} + +struct MockTransport { + published: Vec<ClientEvent>, + incoming: VecDeque<Receive>, + cancel_on_publish: Option<CancellationToken>, +} + +impl MockTransport { + fn new(incoming: impl IntoIterator<Item = Receive>) -> Self { + Self { + published: Vec::new(), + incoming: incoming.into_iter().collect(), + cancel_on_publish: None, + } + } + + fn cancelling_on_publish(token: CancellationToken) -> Self { + Self { + published: Vec::new(), + incoming: VecDeque::new(), + cancel_on_publish: Some(token), + } + } +} + +impl Transport for MockTransport { + fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()> { + self.published.push(event); + if let Some(token) = self.cancel_on_publish.as_ref() { + token.cancel(); + } + Box::pin(async { Ok(()) }) + } + + fn receive<'a>( + &'a mut self, + cancellation: &'a CancellationToken, + ) -> TransportFuture<'a, Receive> { + let outcome = if cancellation.is_cancelled() { + Receive::Cancelled + } else { + self.incoming.pop_front().unwrap_or(Receive::TimedOut) + }; + Box::pin(async move { Ok(outcome) }) + } +} + +#[tokio::test] +async fn client_completes_happy_path_after_one_publication() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + let response = response_event( + &remote_keys, + client.public_key().expect("client public key"), + "request-happy", + Response::Pong, + ); + let mut transport = MockTransport::new([Receive::event(response)]); + + let completion = client + .execute( + RequestId::parse("request-happy").expect("request id"), + Request::Ping, + &mut transport, + &CancellationToken::new(), + |_| Ok(()), + ) + .await + .expect("completion"); + + assert_eq!(completion, Completion::response(Response::Pong)); + assert_eq!(transport.published.len(), 1); + assert!(!transport.published[0].as_json().contains("request-happy")); +} + +#[tokio::test] +async fn client_reports_auth_progress_before_completion() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + let client_public_key = client.public_key().expect("client public key"); + let mut transport = MockTransport::new([ + Receive::event(response_event( + &remote_keys, + client_public_key, + "request-auth", + Response::AuthUrl("https://auth.example.test/challenge".to_owned()), + )), + Receive::event(response_event( + &remote_keys, + client_public_key, + "request-auth", + Response::Pong, + )), + ]); + let mut progress = Vec::new(); + + let completion = client + .execute( + RequestId::parse("request-auth").expect("request id"), + Request::Ping, + &mut transport, + &CancellationToken::new(), + |event| { + progress.push(event); + Ok(()) + }, + ) + .await + .expect("completion"); + + assert_eq!(completion, Completion::response(Response::Pong)); + assert_eq!( + progress, + [Progress::AuthChallenge { + url: "https://auth.example.test/challenge".to_owned(), + }] + ); +} + +#[tokio::test] +async fn transport_owns_timeout_handoff() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + let mut transport = MockTransport::new([Receive::TimedOut]); + + let error = client + .execute( + RequestId::parse("request-timeout").expect("request id"), + Request::Ping, + &mut transport, + &CancellationToken::new(), + |_| Ok(()), + ) + .await + .expect_err("timeout"); + + assert_eq!(error, Error::RequestTimedOut); + assert_eq!(transport.published.len(), 1); +} + +#[tokio::test] +async fn cancellation_distinguishes_before_and_after_publication() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + + let before = CancellationToken::new(); + before.cancel(); + let mut before_transport = MockTransport::new([]); + assert_eq!( + client + .execute( + RequestId::parse("request-cancel-before").expect("request id"), + Request::Ping, + &mut before_transport, + &before, + |_| Ok(()), + ) + .await + .expect("before-publication cancellation"), + Completion::Cancelled(CancellationPhase::BeforePublication) + ); + assert!(before_transport.published.is_empty()); + + let after = CancellationToken::new(); + let mut after_transport = MockTransport::cancelling_on_publish(after.clone()); + assert_eq!( + client + .execute( + RequestId::parse("request-cancel-after").expect("request id"), + Request::Ping, + &mut after_transport, + &after, + |_| Ok(()), + ) + .await + .expect("after-publication cancellation"), + Completion::Cancelled(CancellationPhase::AfterPublication) + ); + assert_eq!(after_transport.published.len(), 1); +} + +#[test] +fn state_machine_ignores_wrong_response_then_completes() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + let client_public_key = client.public_key().expect("client public key"); + let mut operation = client + .prepare( + RequestId::parse("request-select").expect("request id"), + Request::Ping, + ) + .expect("operation"); + operation.mark_published().expect("published"); + + let wrong = response_event( + &remote_keys, + client_public_key, + "other-request", + Response::Pong, + ); + assert_eq!( + operation.select(&wrong).expect("wrong response"), + EventOutcome::Ignore + ); + let expected = response_event( + &remote_keys, + client_public_key, + "request-select", + Response::Pong, + ); + assert_eq!( + operation.select(&expected).expect("expected response"), + EventOutcome::Complete(Box::new(Response::Pong)) + ); +} + +#[test] +fn state_machine_rejects_duplicate_response_event() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + let client = client(&remote_keys); + let response = response_event( + &remote_keys, + client.public_key().expect("client public key"), + "request-replay", + Response::Pong, + ); + let mut operation = client + .prepare( + RequestId::parse("request-replay").expect("request id"), + Request::Ping, + ) + .expect("operation"); + operation.mark_published().expect("published"); + assert_eq!( + operation.select(&response).expect("first response"), + EventOutcome::Complete(Box::new(Response::Pong)) + ); + assert_eq!( + operation.select(&response).expect_err("duplicate response"), + Error::ReplayedResponse + ); +} + +#[test] +fn client_key_and_event_diagnostics_are_redacted() { + let remote_keys = keys(FIXTURE_BOB.secret_key_hex); + assert_eq!( + Client::from_secret("not-a-secret", client(&remote_keys).target().clone()) + .expect_err("invalid key"), + Error::InvalidClientKey + ); + assert!(!format!("{:?}", client(&remote_keys)).contains(FIXTURE_ALICE.secret_key_hex)); + assert_eq!( + ClientEvent::from_json("not an event").expect_err("invalid event"), + Error::InvalidClientEvent + ); + assert_eq!( + ClientEvent::from_json(&"x".repeat(CLIENT_EVENT_MAX_BYTES + 1)) + .expect_err("oversized event"), + Error::InvalidClientEvent + ); + let progress = Progress::AuthChallenge { + url: "https://auth.example.test/?token=do-not-log".to_owned(), + }; + assert!(!format!("{progress:?}").contains("do-not-log")); +} diff --git a/crates/nostr_connect/tests/package_boundary.rs b/crates/nostr_connect/tests/package_boundary.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; const MANIFEST: &str = include_str!("../Cargo.toml"); +const CLIENT: &str = include_str!("../src/client.rs"); const METHOD: &str = include_str!("../src/method.rs"); const PERMISSION: &str = include_str!("../src/permission.rs"); const ROOT: &str = include_str!("../src/lib.rs"); @@ -98,6 +99,30 @@ fn uri_method_and_permission_types_use_canonical_owners_and_names() { assert!(URI.contains("radroots_nostr::key::parse_public_key")); } +#[test] +fn client_root_and_transport_use_package_owned_state_machine_types() { + assert!(ROOT.contains("pub use client::Client;")); + for required in [ + "pub struct Client {", + "pub struct ClientEvent(Event);", + "pub struct Target {", + "pub trait Transport: Send {", + "pub enum CancellationPhase {", + ] { + assert!(CLIENT.contains(required), "client is missing `{required}`"); + } + for forbidden in [ + "pub struct Client {\n pub ", + "pub struct ClientEvent(pub ", + "pub struct Target {\n pub ", + ] { + assert!( + !CLIENT.contains(forbidden), + "client exposes representation through `{forbidden}`" + ); + } +} + fn table_keys<'a>(source: &'a str, header: &str) -> BTreeSet<&'a str> { let Some((_, tail)) = source.split_once(header) else { panic!("manifest is missing {header}");