lib

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

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 }