client.rs (15623B)
1 //! Relay-independent client state and the host transport SPI. 2 3 use crate::error::RadrootsNostrConnectError; 4 use crate::message::{ 5 RPC_KIND, Request, RequestId, RequestMessage, Response, ResponseEnvelope, ResponseValidator, 6 }; 7 use crate::method::Method; 8 use crate::uri::{RELAY_COUNT_MAX, RelayUrl as ConnectRelayUrl}; 9 use nostr::nips::nip44::{self, Version}; 10 use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, PublicKey, SecretKey, Tag}; 11 use std::future::Future; 12 use std::pin::Pin; 13 use std::sync::{ 14 Arc, 15 atomic::{AtomicBool, Ordering}, 16 }; 17 18 pub const CLIENT_EVENT_MAX_BYTES: usize = 524_288; 19 20 /// A relay-independent NIP-46 client with single-owner session key material. 21 /// 22 /// The client owns request encryption, event signing, response selection, and 23 /// protocol state. A host-provided [`Transport`] owns publication, waiting, 24 /// timeout policy, and cancellation wakeups. Dropping an execution future after 25 /// publication does not retract the remote request; use [`CancellationToken`] 26 /// so the resulting [`CancellationPhase`] is observed explicitly. 27 pub struct Client { 28 keys: Keys, 29 target: Target, 30 target_nostr_public_key: PublicKey, 31 } 32 33 impl Client { 34 /// Generates fresh single-owner client key material for this session. 35 pub fn generate(target: Target) -> Result<Self, RadrootsNostrConnectError> { 36 Self::from_keys(Keys::generate(), target) 37 } 38 39 /// Builds a client from a persisted hexadecimal or NIP-19 secret. 40 /// 41 /// Invalid inputs are normalized and never retained in diagnostics. 42 pub fn from_secret(secret: &str, target: Target) -> Result<Self, RadrootsNostrConnectError> { 43 let secret = 44 SecretKey::parse(secret).map_err(|_| RadrootsNostrConnectError::InvalidClientKey)?; 45 Self::from_keys(Keys::new(secret), target) 46 } 47 48 fn from_keys(keys: Keys, target: Target) -> Result<Self, RadrootsNostrConnectError> { 49 let target_nostr_public_key = radroots_nostr::key::public_key_to_nostr( 50 target.remote_signer_public_key, 51 ) 52 .map_err(|_| RadrootsNostrConnectError::InvalidClientTarget { 53 reason: "remote signer public key is not a valid Nostr key", 54 })?; 55 Ok(Self { 56 keys, 57 target, 58 target_nostr_public_key, 59 }) 60 } 61 62 /// Returns the public identity of this client session. 63 pub fn public_key(&self) -> Result<radroots_identity::PublicKey, RadrootsNostrConnectError> { 64 radroots_nostr::key::public_key_from_nostr(self.keys.public_key()).map_err(|_| { 65 RadrootsNostrConnectError::InvalidClientState { 66 reason: "client public key is invalid", 67 } 68 }) 69 } 70 71 #[must_use] 72 pub fn target(&self) -> &Target { 73 &self.target 74 } 75 76 /// Constructs a validated, encrypted request in the prepared state. 77 pub fn prepare( 78 &self, 79 request_id: RequestId, 80 request: Request, 81 ) -> Result<Operation<'_>, RadrootsNostrConnectError> { 82 let method = request.method(); 83 let message = RequestMessage::try_new(request_id.to_string(), request)?; 84 let event = build_request_event_for(&self.keys, self.target_nostr_public_key, message)?; 85 Ok(Operation { 86 client: self, 87 request_id: request_id.clone(), 88 method, 89 publication: ClientEvent(event), 90 phase: OperationPhase::Prepared, 91 validator: ResponseValidator::new(request_id, self.target.remote_signer_public_key), 92 }) 93 } 94 95 /// Publishes and drives one request to response or explicit cancellation. 96 /// 97 /// Timeout policy belongs to `transport`, which reports [`Receive::TimedOut`]. 98 /// The transport must observe `cancellation` while waiting and return 99 /// [`Receive::Cancelled`] promptly when cancellation wins its host-level 100 /// wait. Cancellation, progress-observer errors, or future drops after 101 /// publication stop local waiting without retracting signer-side work. 102 pub async fn execute<T, F>( 103 &self, 104 request_id: RequestId, 105 request: Request, 106 transport: &mut T, 107 cancellation: &CancellationToken, 108 mut on_progress: F, 109 ) -> Result<Completion, RadrootsNostrConnectError> 110 where 111 T: Transport + ?Sized, 112 F: FnMut(Progress) -> Result<(), RadrootsNostrConnectError>, 113 { 114 let mut operation = self.prepare(request_id, request)?; 115 if cancellation.is_cancelled() { 116 return operation.cancel().map(Completion::Cancelled); 117 } 118 119 transport.publish(operation.publication()?.clone()).await?; 120 operation.mark_published()?; 121 if cancellation.is_cancelled() { 122 return operation.cancel().map(Completion::Cancelled); 123 } 124 125 loop { 126 match transport.receive(cancellation).await? { 127 Receive::Event(event) => match operation.select(&event)? { 128 EventOutcome::Ignore => {} 129 EventOutcome::Progress(progress) => on_progress(progress)?, 130 EventOutcome::Complete(response) => { 131 return Ok(Completion::Response(response)); 132 } 133 }, 134 Receive::TimedOut => return Err(RadrootsNostrConnectError::RequestTimedOut), 135 Receive::Cancelled => return operation.cancel().map(Completion::Cancelled), 136 } 137 } 138 } 139 } 140 141 impl std::fmt::Debug for Client { 142 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 143 formatter 144 .debug_struct("Client") 145 .field("key_material", &"<redacted>") 146 .field("target", &self.target) 147 .finish() 148 } 149 } 150 151 /// A validated remote-signer target independent of relay implementation. 152 #[derive(Debug, Clone, PartialEq, Eq)] 153 pub struct Target { 154 remote_signer_public_key: radroots_identity::PublicKey, 155 relays: Vec<ConnectRelayUrl>, 156 } 157 158 impl Target { 159 pub fn try_new( 160 remote_signer_public_key: radroots_identity::PublicKey, 161 relays: Vec<ConnectRelayUrl>, 162 ) -> Result<Self, RadrootsNostrConnectError> { 163 if relays.len() > RELAY_COUNT_MAX { 164 return Err(RadrootsNostrConnectError::InvalidClientTarget { 165 reason: "relay count exceeds its limit", 166 }); 167 } 168 let mut normalized = Vec::with_capacity(relays.len()); 169 for relay in relays { 170 if !normalized.contains(&relay) { 171 normalized.push(relay); 172 } 173 } 174 Ok(Self { 175 remote_signer_public_key, 176 relays: normalized, 177 }) 178 } 179 180 #[must_use] 181 pub const fn remote_signer_public_key(&self) -> radroots_identity::PublicKey { 182 self.remote_signer_public_key 183 } 184 185 #[must_use] 186 pub fn relays(&self) -> &[ConnectRelayUrl] { 187 &self.relays 188 } 189 } 190 191 /// A signed NIP-46 protocol event with a package-owned representation. 192 #[derive(Clone, PartialEq, Eq)] 193 pub struct ClientEvent(Event); 194 195 impl ClientEvent { 196 pub fn from_json(value: &str) -> Result<Self, RadrootsNostrConnectError> { 197 if value.len() > CLIENT_EVENT_MAX_BYTES { 198 return Err(RadrootsNostrConnectError::InvalidClientEvent); 199 } 200 Event::from_json(value) 201 .map(Self) 202 .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent) 203 } 204 205 #[must_use] 206 pub fn as_json(&self) -> String { 207 self.0.as_json() 208 } 209 } 210 211 impl std::fmt::Debug for ClientEvent { 212 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 213 formatter.write_str("ClientEvent(<redacted>)") 214 } 215 } 216 217 #[derive(PartialEq, Eq)] 218 pub enum Progress { 219 AuthChallenge { url: String }, 220 } 221 222 impl std::fmt::Debug for Progress { 223 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 224 formatter.write_str("Progress::AuthChallenge(<redacted>)") 225 } 226 } 227 228 #[derive(Debug, PartialEq, Eq)] 229 pub enum EventOutcome { 230 Ignore, 231 Progress(Progress), 232 Complete(Box<Response>), 233 } 234 235 #[derive(Debug, PartialEq, Eq)] 236 pub enum Completion { 237 Response(Box<Response>), 238 Cancelled(CancellationPhase), 239 } 240 241 impl Completion { 242 #[must_use] 243 pub fn response(response: Response) -> Self { 244 Self::Response(Box::new(response)) 245 } 246 } 247 248 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 249 pub enum CancellationPhase { 250 BeforePublication, 251 AfterPublication, 252 } 253 254 /// A cloneable host cancellation signal containing no runtime dependency. 255 #[derive(Debug, Clone, Default)] 256 pub struct CancellationToken(Arc<AtomicBool>); 257 258 impl CancellationToken { 259 #[must_use] 260 pub fn new() -> Self { 261 Self::default() 262 } 263 264 pub fn cancel(&self) { 265 self.0.store(true, Ordering::Release); 266 } 267 268 #[must_use] 269 pub fn is_cancelled(&self) -> bool { 270 self.0.load(Ordering::Acquire) 271 } 272 } 273 274 #[derive(Debug, Clone, PartialEq, Eq)] 275 pub enum Receive { 276 Event(Box<ClientEvent>), 277 TimedOut, 278 Cancelled, 279 } 280 281 impl Receive { 282 #[must_use] 283 pub fn event(event: ClientEvent) -> Self { 284 Self::Event(Box::new(event)) 285 } 286 } 287 288 pub type TransportFuture<'a, T> = 289 Pin<Box<dyn Future<Output = Result<T, RadrootsNostrConnectError>> + Send + 'a>>; 290 291 /// Host SPI for publishing and receiving package-owned NIP-46 events. 292 /// 293 /// External implementations are supported. The trait is dyn-compatible, 294 /// `Send`, and returns `Send` futures. Implementations normalize backend 295 /// failures into [`RadrootsNostrConnectError::Transport`]. The host owns 296 /// deadlines and reports them as [`Receive::TimedOut`]. A successful `publish` 297 /// may durably expose the request to a remote signer. Once that call returns, 298 /// cancellation only stops local waiting and cannot retract remote work. 299 /// `receive` must observe its token and return [`Receive::Cancelled`] when host 300 /// cancellation wins. 301 pub trait Transport: Send { 302 fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()>; 303 304 fn receive<'a>( 305 &'a mut self, 306 cancellation: &'a CancellationToken, 307 ) -> TransportFuture<'a, Receive>; 308 } 309 310 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 311 enum OperationPhase { 312 Prepared, 313 Published, 314 Completed, 315 Cancelled, 316 } 317 318 /// One request's explicit prepared/published/completed state machine. 319 pub struct Operation<'a> { 320 client: &'a Client, 321 request_id: RequestId, 322 method: Method, 323 publication: ClientEvent, 324 phase: OperationPhase, 325 validator: ResponseValidator, 326 } 327 328 impl Operation<'_> { 329 pub fn publication(&self) -> Result<&ClientEvent, RadrootsNostrConnectError> { 330 if self.phase != OperationPhase::Prepared { 331 return Err(RadrootsNostrConnectError::InvalidClientState { 332 reason: "publication is only available while prepared", 333 }); 334 } 335 Ok(&self.publication) 336 } 337 338 pub fn mark_published(&mut self) -> Result<(), RadrootsNostrConnectError> { 339 if self.phase != OperationPhase::Prepared { 340 return Err(RadrootsNostrConnectError::InvalidClientState { 341 reason: "only a prepared request can be marked published", 342 }); 343 } 344 self.phase = OperationPhase::Published; 345 Ok(()) 346 } 347 348 pub fn cancel(&mut self) -> Result<CancellationPhase, RadrootsNostrConnectError> { 349 let cancellation = match self.phase { 350 OperationPhase::Prepared => CancellationPhase::BeforePublication, 351 OperationPhase::Published => CancellationPhase::AfterPublication, 352 OperationPhase::Completed => { 353 return Err(RadrootsNostrConnectError::InvalidClientState { 354 reason: "completed request cannot be cancelled", 355 }); 356 } 357 OperationPhase::Cancelled => { 358 return Err(RadrootsNostrConnectError::InvalidClientState { 359 reason: "request is already cancelled", 360 }); 361 } 362 }; 363 self.phase = OperationPhase::Cancelled; 364 Ok(cancellation) 365 } 366 367 pub fn select( 368 &mut self, 369 event: &ClientEvent, 370 ) -> Result<EventOutcome, RadrootsNostrConnectError> { 371 if matches!( 372 self.phase, 373 OperationPhase::Prepared | OperationPhase::Cancelled 374 ) { 375 return Err(RadrootsNostrConnectError::InvalidClientState { 376 reason: "responses require a published active request", 377 }); 378 } 379 380 let event = &event.0; 381 if event.kind != Kind::Custom(RPC_KIND) 382 || event.pubkey != self.client.target_nostr_public_key 383 || !event 384 .tags 385 .public_keys() 386 .any(|public_key| *public_key == self.client.keys.public_key()) 387 { 388 return Ok(EventOutcome::Ignore); 389 } 390 event 391 .verify() 392 .map_err(|_| RadrootsNostrConnectError::InvalidClientEvent)?; 393 394 let decrypted = nip44::decrypt( 395 self.client.keys.secret_key(), 396 &self.client.target_nostr_public_key, 397 &event.content, 398 ) 399 .map_err(|error| RadrootsNostrConnectError::Decrypt { 400 reason: error.to_string(), 401 })?; 402 let envelope: ResponseEnvelope = 403 serde_json::from_str(&decrypted).map_err(RadrootsNostrConnectError::from)?; 404 if envelope.request_id()? != self.request_id { 405 return Ok(EventOutcome::Ignore); 406 } 407 self.validator.validate( 408 self.client.target.remote_signer_public_key, 409 event.id.to_hex(), 410 &envelope, 411 )?; 412 if self.phase == OperationPhase::Completed { 413 return Err(RadrootsNostrConnectError::InvalidClientState { 414 reason: "request already completed", 415 }); 416 } 417 418 match Response::from_envelope(&self.method, envelope)? { 419 Response::AuthUrl(url) => Ok(EventOutcome::Progress(Progress::AuthChallenge { url })), 420 response => { 421 self.phase = OperationPhase::Completed; 422 Ok(EventOutcome::Complete(Box::new(response))) 423 } 424 } 425 } 426 } 427 428 impl std::fmt::Debug for Operation<'_> { 429 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 430 formatter 431 .debug_struct("Operation") 432 .field("request_id", &self.request_id) 433 .field("method", &self.method) 434 .field("phase", &self.phase) 435 .field("publication", &"<redacted>") 436 .finish() 437 } 438 } 439 440 fn build_request_event_for( 441 client_keys: &Keys, 442 remote_signer_public_key: PublicKey, 443 message: RequestMessage, 444 ) -> Result<Event, RadrootsNostrConnectError> { 445 let payload = serde_json::to_string(&message).map_err(RadrootsNostrConnectError::from)?; 446 let ciphertext = nip44::encrypt( 447 client_keys.secret_key(), 448 &remote_signer_public_key, 449 payload, 450 Version::V2, 451 ) 452 .map_err(encrypt_error)?; 453 454 EventBuilder::new(Kind::Custom(RPC_KIND), ciphertext) 455 .tag(Tag::public_key(remote_signer_public_key)) 456 .sign_with_keys(client_keys) 457 .map_err(sign_error) 458 } 459 460 #[cfg_attr(coverage_nightly, coverage(off))] 461 fn encrypt_error(error: impl ToString) -> RadrootsNostrConnectError { 462 RadrootsNostrConnectError::Encrypt { 463 reason: error.to_string(), 464 } 465 } 466 467 #[cfg_attr(coverage_nightly, coverage(off))] 468 fn sign_error(error: impl ToString) -> RadrootsNostrConnectError { 469 RadrootsNostrConnectError::Sign { 470 reason: error.to_string(), 471 } 472 }