lib

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

client.rs (13511B)


      1 #[path = "../src/test_fixtures.rs"]
      2 mod test_fixtures;
      3 
      4 use nostr::nips::nip44::{self, Version};
      5 use nostr::{
      6     Event, EventBuilder, Keys, Kind, PublicKey, RelayUrl, SecretKey, Tag, Timestamp, UnsignedEvent,
      7 };
      8 use radroots_nostr_connect::prelude::{
      9     RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientEventOutcome,
     10     RadrootsNostrConnectClientProgress, RadrootsNostrConnectClientRequest,
     11     RadrootsNostrConnectClientTarget, RadrootsNostrConnectClientTransport,
     12     RadrootsNostrConnectClientTransportFuture, RadrootsNostrConnectError,
     13     RadrootsNostrConnectMethod, RadrootsNostrConnectRemoteSessionCapability,
     14     RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
     15     build_request_event, execute_request_with_transport, parse_response_event,
     16 };
     17 use std::collections::VecDeque;
     18 use test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, RELAY_PRIMARY_WSS};
     19 
     20 fn keys(secret_key_hex: &str) -> Keys {
     21     let secret_key = SecretKey::from_hex(secret_key_hex).expect("secret key");
     22     Keys::new(secret_key)
     23 }
     24 
     25 fn client_keys() -> Keys {
     26     keys(FIXTURE_ALICE.secret_key_hex)
     27 }
     28 
     29 fn remote_signer_keys() -> Keys {
     30     keys(FIXTURE_BOB.secret_key_hex)
     31 }
     32 
     33 fn other_keys() -> Keys {
     34     keys(FIXTURE_CAROL.secret_key_hex)
     35 }
     36 
     37 fn relay() -> RelayUrl {
     38     RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")
     39 }
     40 
     41 fn target(remote_keys: &Keys) -> RadrootsNostrConnectClientTarget {
     42     RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), vec![relay()])
     43 }
     44 
     45 fn unsigned_event(pubkey: PublicKey) -> UnsignedEvent {
     46     EventBuilder::text_note("remote signing")
     47         .custom_created_at(Timestamp::from(1_714_078_911))
     48         .build(pubkey)
     49 }
     50 
     51 fn signed_event(keys: &Keys) -> Event {
     52     EventBuilder::text_note("signed remotely")
     53         .custom_created_at(Timestamp::from(1_714_078_911))
     54         .sign_with_keys(keys)
     55         .expect("signed event")
     56 }
     57 
     58 fn response_event(
     59     remote_keys: &Keys,
     60     client_public_key: PublicKey,
     61     request_id: &str,
     62     response: RadrootsNostrConnectResponse,
     63 ) -> Event {
     64     let envelope = response
     65         .into_envelope(request_id)
     66         .expect("response envelope");
     67     let payload = serde_json::to_string(&envelope).expect("response payload");
     68     let ciphertext = nip44::encrypt(
     69         remote_keys.secret_key(),
     70         &client_public_key,
     71         payload,
     72         Version::V2,
     73     )
     74     .expect("response ciphertext");
     75 
     76     EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
     77         .tag(Tag::public_key(client_public_key))
     78         .sign_with_keys(remote_keys)
     79         .expect("response event")
     80 }
     81 
     82 fn untagged_response_event(
     83     remote_keys: &Keys,
     84     client_public_key: PublicKey,
     85     request_id: &str,
     86     response: RadrootsNostrConnectResponse,
     87 ) -> Event {
     88     let envelope = response
     89         .into_envelope(request_id)
     90         .expect("response envelope");
     91     let payload = serde_json::to_string(&envelope).expect("response payload");
     92     let ciphertext = nip44::encrypt(
     93         remote_keys.secret_key(),
     94         &client_public_key,
     95         payload,
     96         Version::V2,
     97     )
     98     .expect("response ciphertext");
     99 
    100     EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext)
    101         .sign_with_keys(remote_keys)
    102         .expect("response event")
    103 }
    104 
    105 fn remote_session_capability(remote_keys: &Keys) -> RadrootsNostrConnectRemoteSessionCapability {
    106     RadrootsNostrConnectRemoteSessionCapability {
    107         user_public_key: remote_keys.public_key(),
    108         relays: vec![relay()],
    109         permissions: Vec::new().into(),
    110     }
    111 }
    112 
    113 struct MockTransport {
    114     published: Vec<Event>,
    115     inbound: VecDeque<Event>,
    116 }
    117 
    118 impl MockTransport {
    119     fn new(inbound: Vec<Event>) -> Self {
    120         Self {
    121             published: Vec::new(),
    122             inbound: inbound.into(),
    123         }
    124     }
    125 }
    126 
    127 impl RadrootsNostrConnectClientTransport for MockTransport {
    128     fn publish_request_event<'a>(
    129         &'a mut self,
    130         event: Event,
    131     ) -> RadrootsNostrConnectClientTransportFuture<'a, ()> {
    132         self.published.push(event);
    133         Box::pin(async { Ok(()) })
    134     }
    135 
    136     fn next_response_event<'a>(
    137         &'a mut self,
    138     ) -> RadrootsNostrConnectClientTransportFuture<'a, Event> {
    139         let next = self.inbound.pop_front();
    140         Box::pin(async move { next.ok_or(RadrootsNostrConnectError::RequestTimedOut) })
    141     }
    142 }
    143 
    144 #[tokio::test]
    145 async fn executes_connect_request_and_secret_echo_response() {
    146     let client_keys = client_keys();
    147     let remote_keys = remote_signer_keys();
    148     let target = target(&remote_keys);
    149     let mut transport = MockTransport::new(vec![response_event(
    150         &remote_keys,
    151         client_keys.public_key(),
    152         "req-connect",
    153         RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned()),
    154     )]);
    155 
    156     let response = execute_request_with_transport(
    157         &client_keys,
    158         &target,
    159         RadrootsNostrConnectClientRequest::new(
    160             "req-connect",
    161             RadrootsNostrConnectRequest::Connect {
    162                 remote_signer_public_key: remote_keys.public_key(),
    163                 secret: Some("connect-secret".to_owned()),
    164                 requested_permissions: Vec::new().into(),
    165             },
    166         ),
    167         &mut transport,
    168         |_| Ok(()),
    169     )
    170     .await
    171     .expect("connect response");
    172 
    173     assert_eq!(
    174         response,
    175         RadrootsNostrConnectResponse::ConnectSecretEcho("connect-secret".to_owned())
    176     );
    177     assert_eq!(transport.published.len(), 1);
    178 }
    179 
    180 #[tokio::test]
    181 async fn executes_capability_request_and_typed_response() {
    182     let client_keys = client_keys();
    183     let remote_keys = remote_signer_keys();
    184     let target = target(&remote_keys);
    185     let capability = remote_session_capability(&remote_keys);
    186     let mut transport = MockTransport::new(vec![response_event(
    187         &remote_keys,
    188         client_keys.public_key(),
    189         "req-capability",
    190         RadrootsNostrConnectResponse::RemoteSessionCapability(capability.clone()),
    191     )]);
    192 
    193     let response = execute_request_with_transport(
    194         &client_keys,
    195         &target,
    196         RadrootsNostrConnectClientRequest::new(
    197             "req-capability",
    198             RadrootsNostrConnectRequest::GetSessionCapability,
    199         ),
    200         &mut transport,
    201         |_| Ok(()),
    202     )
    203     .await
    204     .expect("capability response");
    205 
    206     assert_eq!(
    207         response,
    208         RadrootsNostrConnectResponse::RemoteSessionCapability(capability)
    209     );
    210     assert_eq!(transport.published.len(), 1);
    211 }
    212 
    213 #[tokio::test]
    214 async fn reports_timeout_when_transport_has_no_matching_response() {
    215     let client_keys = client_keys();
    216     let remote_keys = remote_signer_keys();
    217     let target = target(&remote_keys);
    218     let mut transport = MockTransport::new(Vec::new());
    219 
    220     let error = execute_request_with_transport(
    221         &client_keys,
    222         &target,
    223         RadrootsNostrConnectClientRequest::new("req-timeout", RadrootsNostrConnectRequest::Ping),
    224         &mut transport,
    225         |_| Ok(()),
    226     )
    227     .await
    228     .expect_err("timeout");
    229 
    230     assert_eq!(error, RadrootsNostrConnectError::RequestTimedOut);
    231     assert_eq!(transport.published.len(), 1);
    232 }
    233 
    234 #[test]
    235 fn builds_encrypted_request_event_for_remote_signer() {
    236     let client_keys = client_keys();
    237     let remote_keys = remote_signer_keys();
    238     let target = target(&remote_keys);
    239     let message =
    240         RadrootsNostrConnectRequestMessage::new("req-ping", RadrootsNostrConnectRequest::Ping);
    241 
    242     let event = build_request_event(&client_keys, &target, message.clone()).expect("event");
    243 
    244     assert_eq!(event.kind, Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND));
    245     assert_eq!(event.pubkey, client_keys.public_key());
    246     assert!(
    247         event
    248             .tags
    249             .public_keys()
    250             .any(|public_key| *public_key == remote_keys.public_key())
    251     );
    252     assert!(!event.content.contains("ping"));
    253 
    254     let decrypted = nip44::decrypt(
    255         remote_keys.secret_key(),
    256         &client_keys.public_key(),
    257         &event.content,
    258     )
    259     .expect("decrypt request");
    260     let decoded: RadrootsNostrConnectRequestMessage =
    261         serde_json::from_str(&decrypted).expect("decode request");
    262     assert_eq!(decoded, message);
    263 }
    264 
    265 #[test]
    266 fn ignores_response_from_unexpected_signer_identity() {
    267     let client_keys = client_keys();
    268     let remote_keys = remote_signer_keys();
    269     let other_keys = other_keys();
    270     let target = target(&remote_keys);
    271     let response = response_event(
    272         &other_keys,
    273         client_keys.public_key(),
    274         "req-ping",
    275         RadrootsNostrConnectResponse::Pong,
    276     );
    277 
    278     let outcome = parse_response_event(
    279         &client_keys,
    280         &target,
    281         "req-ping",
    282         &RadrootsNostrConnectMethod::Ping,
    283         &response,
    284     )
    285     .expect("parse response");
    286 
    287     assert_eq!(outcome, RadrootsNostrConnectClientEventOutcome::Ignore);
    288 }
    289 
    290 #[tokio::test]
    291 async fn executes_request_through_transport_with_auth_progress() {
    292     let client_keys = client_keys();
    293     let remote_keys = remote_signer_keys();
    294     let target = target(&remote_keys);
    295     let signed = signed_event(&remote_keys);
    296     let inbound = vec![
    297         response_event(
    298             &remote_keys,
    299             client_keys.public_key(),
    300             "other-request",
    301             RadrootsNostrConnectResponse::Pong,
    302         ),
    303         response_event(
    304             &remote_keys,
    305             client_keys.public_key(),
    306             "req-sign",
    307             RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()),
    308         ),
    309         response_event(
    310             &remote_keys,
    311             client_keys.public_key(),
    312             "req-sign",
    313             RadrootsNostrConnectResponse::SignedEvent(signed.clone()),
    314         ),
    315     ];
    316     let mut transport = MockTransport::new(inbound);
    317     let mut progress = Vec::new();
    318 
    319     let response = execute_request_with_transport(
    320         &client_keys,
    321         &target,
    322         RadrootsNostrConnectClientRequest::new(
    323             "req-sign",
    324             RadrootsNostrConnectRequest::SignEvent(unsigned_event(remote_keys.public_key())),
    325         ),
    326         &mut transport,
    327         |event| {
    328             progress.push(event);
    329             Ok(())
    330         },
    331     )
    332     .await
    333     .expect("response");
    334 
    335     assert_eq!(transport.published.len(), 1);
    336     assert_eq!(
    337         progress,
    338         vec![RadrootsNostrConnectClientProgress::AuthChallenge {
    339             url: "https://auth.example.com/challenge".to_owned()
    340         }]
    341     );
    342     assert_eq!(response, RadrootsNostrConnectResponse::SignedEvent(signed));
    343 }
    344 
    345 #[tokio::test]
    346 async fn ignores_events_not_addressed_by_expected_signer_and_client() {
    347     let client_keys = client_keys();
    348     let remote_keys = remote_signer_keys();
    349     let other_keys = other_keys();
    350     let target = target(&remote_keys);
    351     let wrong_author = EventBuilder::new(
    352         Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND),
    353         "not encrypted for this client",
    354     )
    355     .tag(Tag::public_key(client_keys.public_key()))
    356     .sign_with_keys(&other_keys)
    357     .expect("wrong author event");
    358     let missing_client_tag = untagged_response_event(
    359         &remote_keys,
    360         client_keys.public_key(),
    361         "req-ping",
    362         RadrootsNostrConnectResponse::Pong,
    363     );
    364     let valid = response_event(
    365         &remote_keys,
    366         client_keys.public_key(),
    367         "req-ping",
    368         RadrootsNostrConnectResponse::Pong,
    369     );
    370     let mut transport = MockTransport::new(vec![wrong_author, missing_client_tag, valid]);
    371 
    372     let response = execute_request_with_transport(
    373         &client_keys,
    374         &target,
    375         RadrootsNostrConnectClientRequest::new("req-ping", RadrootsNostrConnectRequest::Ping),
    376         &mut transport,
    377         |_| Ok(()),
    378     )
    379     .await
    380     .expect("response");
    381 
    382     assert_eq!(response, RadrootsNostrConnectResponse::Pong);
    383     assert_eq!(transport.published.len(), 1);
    384 }
    385 
    386 #[test]
    387 fn reports_decryption_failure_from_expected_signer() {
    388     let client_keys = client_keys();
    389     let remote_keys = remote_signer_keys();
    390     let target = target(&remote_keys);
    391     let malformed = EventBuilder::new(
    392         Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND),
    393         "not nip44 ciphertext",
    394     )
    395     .tag(Tag::public_key(client_keys.public_key()))
    396     .sign_with_keys(&remote_keys)
    397     .expect("malformed response");
    398 
    399     let error = parse_response_event(
    400         &client_keys,
    401         &target,
    402         "req-ping",
    403         &RadrootsNostrConnectMethod::Ping,
    404         &malformed,
    405     )
    406     .expect_err("decrypt failure");
    407 
    408     assert!(matches!(
    409         error,
    410         RadrootsNostrConnectError::Decrypt { reason } if !reason.is_empty()
    411     ));
    412 }
    413 
    414 #[test]
    415 fn parses_auth_challenge_as_progress_without_consuming_final_response() {
    416     let client_keys = client_keys();
    417     let remote_keys = remote_signer_keys();
    418     let target = target(&remote_keys);
    419     let auth = response_event(
    420         &remote_keys,
    421         client_keys.public_key(),
    422         "req-sign",
    423         RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/continue".to_owned()),
    424     );
    425 
    426     let outcome = parse_response_event(
    427         &client_keys,
    428         &target,
    429         "req-sign",
    430         &RadrootsNostrConnectMethod::SignEvent,
    431         &auth,
    432     )
    433     .expect("parse auth");
    434 
    435     assert_eq!(
    436         outcome,
    437         RadrootsNostrConnectClientEventOutcome::Progress(
    438             RadrootsNostrConnectClientProgress::AuthChallenge {
    439                 url: "https://auth.example.com/continue".to_owned()
    440             }
    441         )
    442     );
    443 }