lib

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

protocol.rs (22004B)


      1 #[path = "../src/test_fixtures.rs"]
      2 mod test_fixtures;
      3 
      4 use nostr::{EventBuilder, JsonUtil, Keys, PublicKey, SecretKey, Timestamp, UnsignedEvent};
      5 use radroots_nostr_connect::message::{
      6     PENDING_CONNECTION_ERROR, RemoteSessionCapability, RequestMessage, ResponseEnvelope,
      7     SignedEvent as ConnectSignedEvent, UnsignedEvent as ConnectUnsignedEvent,
      8 };
      9 use radroots_nostr_connect::permission::Permissions;
     10 use radroots_nostr_connect::uri::{
     11     CLIENT_METADATA_JSON_MAX_BYTES, CLIENT_NAME_MAX_BYTES, ClientMetadata, ClientUri,
     12     RelayUrl as ConnectRelayUrl, Uri,
     13 };
     14 use radroots_nostr_connect::{Error, Method, Permission, Request, Response};
     15 use serde_json::{Value, json};
     16 use test_fixtures::{
     17     APP_PRIMARY_HTTPS, CDN_PRIMARY_HTTPS, FIXTURE_ALICE, RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS,
     18     RELAY_TERTIARY_WSS,
     19 };
     20 
     21 fn test_public_key() -> PublicKey {
     22     PublicKey::parse(FIXTURE_ALICE.public_key_hex).expect("public key")
     23 }
     24 
     25 #[test]
     26 fn constructs_client_uri_from_validated_values() {
     27     let relay = ConnectRelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay");
     28     let metadata = ClientMetadata::new()
     29         .with_name("Radroots")
     30         .expect("metadata");
     31     let client = ClientUri::try_new(
     32         test_identity_public_key(),
     33         [relay.clone(), relay],
     34         "shared-secret",
     35         metadata,
     36     )
     37     .expect("client URI");
     38 
     39     assert_eq!(client.relays().len(), 1);
     40     assert_eq!(client.secret(), "shared-secret");
     41     assert_eq!(client.metadata().name(), Some("Radroots"));
     42     assert!(
     43         ClientUri::try_new(
     44             test_identity_public_key(),
     45             Vec::<ConnectRelayUrl>::new(),
     46             "shared-secret",
     47             ClientMetadata::new(),
     48         )
     49         .is_err()
     50     );
     51 }
     52 
     53 fn test_identity_public_key() -> radroots_identity::PublicKey {
     54     radroots_identity::PublicKey::from_hex(FIXTURE_ALICE.public_key_hex)
     55         .expect("identity public key")
     56 }
     57 
     58 fn test_keys() -> Keys {
     59     let secret_key = SecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).expect("secret key");
     60     Keys::new(secret_key)
     61 }
     62 
     63 fn encode_uri_component(value: &str) -> String {
     64     url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
     65 }
     66 
     67 fn logo_url() -> String {
     68     format!("{CDN_PRIMARY_HTTPS}/logo.png")
     69 }
     70 
     71 fn remote_session_capability() -> RemoteSessionCapability {
     72     RemoteSessionCapability {
     73         user_public_key: test_identity_public_key(),
     74         relays: vec![
     75             ConnectRelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay 1"),
     76             ConnectRelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 2"),
     77         ],
     78         permissions: Permissions::from(vec![
     79             Permission::new(Method::Ping),
     80             Permission::with_parameter(Method::SignEvent, "kind:1"),
     81         ]),
     82     }
     83 }
     84 
     85 #[test]
     86 fn parses_client_uri_with_current_spec_query_fields() {
     87     let uri = format!(
     88         "nostrconnect://{}?relay={}&relay={}&secret=0s8j2djs&perms=nip44_encrypt%2Csign_event%3A1059&name=My+Client&url={}&image={}",
     89         FIXTURE_ALICE.public_key_hex,
     90         encode_uri_component(RELAY_SECONDARY_WSS),
     91         encode_uri_component(RELAY_TERTIARY_WSS),
     92         encode_uri_component(APP_PRIMARY_HTTPS),
     93         encode_uri_component(&logo_url()),
     94     );
     95     let parsed = Uri::parse(&uri).expect("parse client uri");
     96 
     97     match parsed {
     98         Uri::Client(client) => {
     99             assert_eq!(client.client_public_key(), test_identity_public_key());
    100             assert_eq!(client.relays().len(), 2);
    101             assert_eq!(client.secret(), "0s8j2djs");
    102             assert_eq!(client.metadata().name.as_deref(), Some("My Client"));
    103             assert_eq!(
    104                 client.metadata().requested_permissions,
    105                 Permissions::from(vec![
    106                     Permission::new(Method::Nip44Encrypt,),
    107                     Permission::with_parameter(Method::SignEvent, "1059",),
    108                 ])
    109             );
    110             assert_eq!(
    111                 client.metadata().url.as_deref(),
    112                 Some(format!("{APP_PRIMARY_HTTPS}/").as_str())
    113             );
    114             assert_eq!(
    115                 client.metadata().image.as_deref(),
    116                 Some(logo_url().as_str())
    117             );
    118         }
    119         other => panic!("expected client uri, got {other:?}"),
    120     }
    121 }
    122 
    123 #[test]
    124 fn parses_bunker_uri_and_roundtrips() {
    125     let source = format!(
    126         "bunker://{}?relay={}&secret=abcd",
    127         FIXTURE_ALICE.public_key_hex,
    128         encode_uri_component(RELAY_PRIMARY_WSS),
    129     );
    130     let parsed = Uri::parse(&source).expect("parse bunker uri");
    131     let rendered = parsed.to_string();
    132     let reparsed = Uri::parse(&rendered).expect("reparse bunker uri");
    133     assert_eq!(parsed, reparsed);
    134 }
    135 
    136 #[test]
    137 fn rejects_client_uri_without_required_secret() {
    138     let source = format!(
    139         "nostrconnect://{}?relay={}",
    140         FIXTURE_ALICE.public_key_hex,
    141         encode_uri_component(RELAY_PRIMARY_WSS),
    142     );
    143     assert!(Uri::parse(&source).is_err());
    144 }
    145 
    146 #[test]
    147 fn requested_permissions_roundtrip_as_csv() {
    148     let permissions = Permissions::from(vec![
    149         Permission::new(Method::Nip44Encrypt),
    150         Permission::with_parameter(Method::SignEvent, "13"),
    151     ]);
    152 
    153     let rendered = permissions.to_string();
    154     assert_eq!(rendered, "nip44_encrypt,sign_event:13");
    155     let reparsed: Permissions = rendered.parse().expect("parse permissions");
    156     assert_eq!(permissions, reparsed);
    157 }
    158 
    159 #[test]
    160 fn connect_request_roundtrips_requested_permissions() {
    161     let request = Request::Connect {
    162         remote_signer_public_key: test_identity_public_key(),
    163         secret: Some("abcd".to_owned()),
    164         requested_permissions: Permissions::from(vec![
    165             Permission::new(Method::Nip44Encrypt),
    166             Permission::with_parameter(Method::SignEvent, "1059"),
    167         ]),
    168         client_metadata: None,
    169     };
    170     let message = RequestMessage::new("req-1", request);
    171     let encoded = serde_json::to_value(&message).expect("serialize request");
    172     assert_eq!(
    173         encoded,
    174         json!({
    175             "id": "req-1",
    176             "method": "connect",
    177             "params": [
    178                 FIXTURE_ALICE.public_key_hex,
    179                 "abcd",
    180                 "nip44_encrypt,sign_event:1059"
    181             ]
    182         })
    183     );
    184 
    185     let decoded: RequestMessage = serde_json::from_value(encoded).expect("deserialize request");
    186     assert_eq!(decoded, message);
    187 }
    188 
    189 #[test]
    190 fn connect_request_roundtrips_client_metadata_in_fourth_parameter() {
    191     let request = Request::Connect {
    192         remote_signer_public_key: test_identity_public_key(),
    193         secret: None,
    194         requested_permissions: Permissions::default(),
    195         client_metadata: Some(ClientMetadata {
    196             requested_permissions: Permissions::default(),
    197             name: Some(" My Client ".to_owned()),
    198             url: Some(APP_PRIMARY_HTTPS.to_owned()),
    199             image: Some(logo_url()),
    200         }),
    201     };
    202     let message = RequestMessage::new("req-metadata", request);
    203     let encoded = serde_json::to_value(&message).expect("serialize metadata request");
    204     assert_eq!(encoded["params"][1], "");
    205     assert_eq!(encoded["params"][2], "");
    206     let encoded_metadata: Value = serde_json::from_str(
    207         encoded["params"][3]
    208             .as_str()
    209             .expect("metadata parameter string"),
    210     )
    211     .expect("metadata parameter json");
    212     assert_eq!(
    213         encoded_metadata,
    214         json!({
    215             "name": "My Client",
    216             "url": format!("{APP_PRIMARY_HTTPS}/"),
    217             "image": logo_url(),
    218         })
    219     );
    220 
    221     let decoded: RequestMessage =
    222         serde_json::from_value(encoded.clone()).expect("deserialize metadata request");
    223     match &decoded.request {
    224         Request::Connect {
    225             client_metadata: Some(metadata),
    226             ..
    227         } => {
    228             assert_eq!(metadata.name.as_deref(), Some("My Client"));
    229             assert!(metadata.requested_permissions.is_empty());
    230         }
    231         other => panic!("expected connect metadata, got {other:?}"),
    232     }
    233     assert_eq!(
    234         serde_json::to_value(&decoded).expect("re-encode normalized metadata"),
    235         encoded
    236     );
    237 }
    238 
    239 #[test]
    240 fn logout_request_and_acknowledgement_roundtrip() {
    241     let message = RequestMessage::new("req-logout", Request::Logout);
    242     assert_eq!(
    243         serde_json::to_value(&message).expect("serialize logout"),
    244         json!({"id": "req-logout", "method": "logout", "params": []})
    245     );
    246 
    247     let response = Response::from_envelope(
    248         &Method::Logout,
    249         ResponseEnvelope {
    250             id: "req-logout".to_owned(),
    251             result: Some(Value::String("ack".to_owned())),
    252             error: None,
    253         },
    254     )
    255     .expect("parse logout acknowledgement");
    256     assert_eq!(response, Response::LogoutAcknowledged);
    257     assert_eq!(
    258         response
    259             .into_envelope("req-logout")
    260             .expect("encode logout acknowledgement")
    261             .result,
    262         Some(Value::String("ack".to_owned()))
    263     );
    264 }
    265 
    266 #[test]
    267 fn rejects_invalid_client_metadata() {
    268     let invalid_name = json!({
    269         "id": "req-invalid-name",
    270         "method": "connect",
    271         "params": [
    272             FIXTURE_ALICE.public_key_hex,
    273             "",
    274             "",
    275             serde_json::to_string(&json!({"name": "line\nbreak"})).expect("metadata")
    276         ]
    277     });
    278     assert!(serde_json::from_value::<RequestMessage>(invalid_name).is_err());
    279 
    280     let invalid_scheme = format!(
    281         "nostrconnect://{}?relay={}&secret=secret&url={}",
    282         FIXTURE_ALICE.public_key_hex,
    283         encode_uri_component(RELAY_PRIMARY_WSS),
    284         encode_uri_component("file:///tmp/client"),
    285     );
    286     assert!(Uri::parse(&invalid_scheme).is_err());
    287 
    288     let oversized_name = ClientMetadata {
    289         requested_permissions: Permissions::default(),
    290         name: Some("a".repeat(CLIENT_NAME_MAX_BYTES + 1)),
    291         url: None,
    292         image: None,
    293     };
    294     assert!(matches!(
    295         oversized_name.to_connect_param(),
    296         Err(Error::InvalidClientMetadata { field: "name", .. })
    297     ));
    298 
    299     let oversized_payload = "x".repeat(CLIENT_METADATA_JSON_MAX_BYTES + 1);
    300     assert!(matches!(
    301         Request::from_parts(
    302             Method::Connect,
    303             vec![
    304                 test_public_key().to_hex(),
    305                 String::new(),
    306                 String::new(),
    307                 oversized_payload,
    308             ],
    309         ),
    310         Err(Error::ClientMetadataTooLarge { .. })
    311     ));
    312 }
    313 
    314 #[test]
    315 fn sign_event_request_roundtrips_unsigned_event_payload() {
    316     let unsigned_event: UnsignedEvent = serde_json::from_value(json!({
    317         "pubkey": test_public_key().to_hex(),
    318         "created_at": 1714078911u64,
    319         "kind": 1u16,
    320         "tags": [],
    321         "content": "Hello, I'm signing remotely"
    322     }))
    323     .expect("unsigned event");
    324 
    325     let message = RequestMessage::new(
    326         "req-sign",
    327         Request::SignEvent(
    328             ConnectUnsignedEvent::from_json(&unsigned_event.as_json())
    329                 .expect("unsigned event payload"),
    330         ),
    331     );
    332     let encoded = serde_json::to_value(&message).expect("serialize sign request");
    333     assert_eq!(encoded["method"], "sign_event");
    334 
    335     let decoded: RequestMessage =
    336         serde_json::from_value(encoded).expect("deserialize sign request");
    337     assert_eq!(decoded, message);
    338     assert_eq!(
    339         decoded.request,
    340         Request::SignEvent(
    341             ConnectUnsignedEvent::from_json(&unsigned_event.as_json())
    342                 .expect("unsigned event payload"),
    343         )
    344     );
    345 }
    346 
    347 #[test]
    348 fn switch_relays_response_accepts_array_or_null() {
    349     let relays_response = ResponseEnvelope {
    350         id: "req-switch".to_owned(),
    351         result: Some(json!([RELAY_SECONDARY_WSS, RELAY_TERTIARY_WSS])),
    352         error: None,
    353     };
    354     let parsed =
    355         Response::from_envelope(&Method::SwitchRelays, relays_response).expect("parse relay list");
    356     assert_eq!(
    357         parsed,
    358         Response::RelayList(vec![
    359             ConnectRelayUrl::parse(RELAY_SECONDARY_WSS).expect("relay 1"),
    360             ConnectRelayUrl::parse(RELAY_TERTIARY_WSS).expect("relay 2"),
    361         ])
    362     );
    363 
    364     let unchanged = Response::from_envelope(
    365         &Method::SwitchRelays,
    366         ResponseEnvelope {
    367             id: "req-switch".to_owned(),
    368             result: Some(Value::Null),
    369             error: None,
    370         },
    371     )
    372     .expect("parse null relay result");
    373     assert_eq!(unchanged, Response::RelayListUnchanged);
    374 }
    375 
    376 #[test]
    377 fn get_session_capability_request_and_response_roundtrip() {
    378     let request_message = RequestMessage::new("req-cap", Request::GetSessionCapability);
    379     let encoded_request = serde_json::to_value(&request_message).expect("serialize request");
    380     let decoded_request: RequestMessage =
    381         serde_json::from_value(encoded_request).expect("deserialize request");
    382     assert_eq!(decoded_request, request_message);
    383 
    384     let capability = remote_session_capability();
    385     let response_envelope = Response::RemoteSessionCapability(capability.clone())
    386         .into_envelope("resp-cap")
    387         .expect("serialize response");
    388     let decoded_response =
    389         Response::from_envelope(&Method::GetSessionCapability, response_envelope)
    390             .expect("deserialize response");
    391     assert_eq!(
    392         decoded_response,
    393         Response::RemoteSessionCapability(capability)
    394     );
    395 }
    396 
    397 #[test]
    398 fn auth_url_response_parses_from_result_and_error_fields() {
    399     let response = Response::from_envelope(
    400         &Method::SignEvent,
    401         ResponseEnvelope {
    402             id: "req-auth".to_owned(),
    403             result: Some(json!("auth_url")),
    404             error: Some("https://auth.example.com/challenge".to_owned()),
    405         },
    406     )
    407     .expect("parse auth challenge");
    408 
    409     assert_eq!(
    410         response,
    411         Response::AuthUrl("https://auth.example.com/challenge".to_owned())
    412     );
    413 }
    414 
    415 #[test]
    416 fn connect_and_poll_pending_responses_parse_as_typed_pending_connection() {
    417     for method in [
    418         Method::Connect,
    419         Method::GetPublicKey,
    420         Method::GetSessionCapability,
    421     ] {
    422         let response = Response::from_envelope(
    423             &method,
    424             ResponseEnvelope {
    425                 id: "req-pending".to_owned(),
    426                 result: None,
    427                 error: Some(PENDING_CONNECTION_ERROR.to_owned()),
    428             },
    429         )
    430         .expect("parse pending connection response");
    431 
    432         assert_eq!(response, Response::PendingConnection);
    433     }
    434 }
    435 
    436 #[test]
    437 fn sign_event_response_roundtrips_signed_event_json_string() {
    438     let keys = test_keys();
    439     let event = EventBuilder::text_note("hello world")
    440         .custom_created_at(Timestamp::from(1_714_078_911))
    441         .sign_with_keys(&keys)
    442         .expect("sign event");
    443 
    444     let envelope = Response::SignedEvent(
    445         ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload"),
    446     )
    447     .into_envelope("req-sign")
    448     .expect("serialize response");
    449     let parsed =
    450         Response::from_envelope(&Method::SignEvent, envelope).expect("parse signed event response");
    451 
    452     assert_eq!(
    453         parsed,
    454         Response::SignedEvent(
    455             ConnectSignedEvent::from_json(&event.as_json()).expect("signed event payload")
    456         )
    457     );
    458 }
    459 
    460 #[test]
    461 fn checked_in_current_session_vectors_match_protocol_behavior() {
    462     let vectors = include_str!("fixtures/current_session.v1.json");
    463     let document: Value = serde_json::from_str(vectors).expect("NIP-46 vector JSON");
    464     assert_eq!(document["suite"], "nip46_current_session");
    465     assert_eq!(document["contract_version"], "1.0.0");
    466     let entries = document["vectors"].as_array().expect("NIP-46 vectors");
    467 
    468     for entry in entries {
    469         let id = entry["id"].as_str().expect("vector id");
    470         let kind = entry["kind"].as_str().expect("vector kind");
    471         let input = &entry["input"];
    472         let expected = &entry["expected"];
    473 
    474         match kind {
    475             "nip46.request.valid" => {
    476                 let message: RequestMessage = serde_json::from_value(input["message"].clone())
    477                     .unwrap_or_else(|error| panic!("{id}: parse request: {error}"));
    478                 let normalized = serde_json::to_value(message)
    479                     .unwrap_or_else(|error| panic!("{id}: serialize request: {error}"));
    480                 assert_eq!(normalized, expected["normalized_message"], "{id}");
    481             }
    482             "nip46.request.invalid" => {
    483                 let error = serde_json::from_value::<RequestMessage>(input["message"].clone())
    484                     .expect_err("invalid request vector");
    485                 assert_vector_error(id, expected, error);
    486             }
    487             "nip46.metadata.invalid" => {
    488                 let count = input["count"].as_u64().expect("metadata repeat count") as usize;
    489                 let repeat = input["repeat"].as_str().expect("metadata repeat value");
    490                 let metadata = ClientMetadata {
    491                     name: Some(repeat.repeat(count)),
    492                     ..ClientMetadata::default()
    493                 };
    494                 let error = metadata.normalized().expect_err("invalid metadata vector");
    495                 assert_vector_error(id, expected, error);
    496             }
    497             "nip46.uri.valid" => {
    498                 let uri = input["uri"].as_str().expect("NIP-46 URI");
    499                 let parsed =
    500                     Uri::parse(uri).unwrap_or_else(|error| panic!("{id}: parse URI: {error}"));
    501                 assert_uri_vector(id, parsed, expected);
    502             }
    503             "nip46.uri.invalid" => {
    504                 let uri = input["uri"].as_str().expect("NIP-46 URI");
    505                 let error = Uri::parse(uri).expect_err("invalid URI vector");
    506                 assert_vector_error(id, expected, error);
    507             }
    508             "nip46.response.valid" => {
    509                 let method = input["method"]
    510                     .as_str()
    511                     .expect("response method")
    512                     .parse::<Method>()
    513                     .expect("typed response method");
    514                 let envelope: ResponseEnvelope = serde_json::from_value(input["envelope"].clone())
    515                     .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}"));
    516                 let request_id = envelope.id.clone();
    517                 let response = Response::from_envelope(&method, envelope)
    518                     .unwrap_or_else(|error| panic!("{id}: parse response: {error}"));
    519                 let normalized = response
    520                     .into_envelope(request_id)
    521                     .unwrap_or_else(|error| panic!("{id}: serialize response: {error}"));
    522                 let normalized = serde_json::to_value(normalized)
    523                     .unwrap_or_else(|error| panic!("{id}: serialize envelope: {error}"));
    524                 assert_eq!(normalized, expected["normalized_envelope"], "{id}");
    525             }
    526             "nip46.response.invalid" => {
    527                 let method = input["method"]
    528                     .as_str()
    529                     .expect("response method")
    530                     .parse::<Method>()
    531                     .expect("typed response method");
    532                 let envelope: ResponseEnvelope = serde_json::from_value(input["envelope"].clone())
    533                     .unwrap_or_else(|error| panic!("{id}: parse envelope: {error}"));
    534                 let error = Response::from_envelope(&method, envelope)
    535                     .expect_err("invalid response vector");
    536                 assert_vector_error(id, expected, error);
    537             }
    538             other => panic!("{id}: unknown NIP-46 vector kind {other}"),
    539         }
    540     }
    541 }
    542 
    543 fn assert_uri_vector(id: &str, parsed: Uri, expected: &Value) {
    544     let expected_relays = expected["relays"]
    545         .as_array()
    546         .expect("expected relays")
    547         .iter()
    548         .map(|relay| relay.as_str().expect("expected relay"))
    549         .collect::<Vec<_>>();
    550 
    551     match parsed {
    552         Uri::Bunker(uri) => {
    553             assert_eq!(expected["variant"], "bunker", "{id}");
    554             let relays = uri
    555                 .relays()
    556                 .iter()
    557                 .map(ToString::to_string)
    558                 .collect::<Vec<_>>();
    559             assert_eq!(relays, expected_relays, "{id}");
    560             assert_eq!(uri.secret(), expected["secret"].as_str(), "{id}");
    561         }
    562         Uri::Client(uri) => {
    563             assert_eq!(expected["variant"], "nostrconnect", "{id}");
    564             let relays = uri
    565                 .relays()
    566                 .iter()
    567                 .map(ToString::to_string)
    568                 .collect::<Vec<_>>();
    569             assert_eq!(relays, expected_relays, "{id}");
    570             assert_eq!(uri.secret(), expected["secret"].as_str().expect("secret"));
    571             assert_eq!(
    572                 uri.metadata().name.as_deref(),
    573                 expected["metadata"]["name"].as_str(),
    574                 "{id}"
    575             );
    576             assert_eq!(
    577                 uri.metadata().url.as_deref(),
    578                 expected["metadata"]["url"].as_str(),
    579                 "{id}"
    580             );
    581             assert_eq!(
    582                 uri.metadata().image.as_deref(),
    583                 expected["metadata"]["image"].as_str(),
    584                 "{id}"
    585             );
    586             assert_eq!(
    587                 uri.metadata().requested_permissions.to_string(),
    588                 expected["metadata"]["permissions"]
    589                     .as_str()
    590                     .expect("permissions"),
    591                 "{id}"
    592             );
    593         }
    594     }
    595 }
    596 
    597 fn assert_vector_error(id: &str, expected: &Value, error: impl ToString) {
    598     let class = expected["error"].as_str().expect("expected error class");
    599     let needle = match class {
    600         "invalid_client_metadata" => "invalid NIP-46 client metadata",
    601         "invalid_params" => "invalid parameter count",
    602         "invalid_response_payload" => "invalid response payload",
    603         "missing_result" => "missing response result",
    604         "missing_secret" => "missing secret",
    605         other => panic!("{id}: unknown expected error class {other}"),
    606     };
    607     let message = error.to_string();
    608     assert!(
    609         message.contains(needle),
    610         "{id}: expected {class}, got {message}"
    611     );
    612 }