lib

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

uri.rs (20978B)


      1 //! Validated `nostrconnect://` and `bunker://` URI models.
      2 
      3 use crate::error::RadrootsNostrConnectError;
      4 use crate::permission::Permissions;
      5 use radroots_identity::PublicKey;
      6 use serde::{Deserialize, Serialize};
      7 use std::fmt;
      8 use std::str::FromStr;
      9 use url::Url;
     10 
     11 pub const URI_SCHEME: &str = "nostrconnect";
     12 pub const BUNKER_URI_SCHEME: &str = "bunker";
     13 pub const CLIENT_NAME_MAX_BYTES: usize = 128;
     14 pub const CLIENT_URL_MAX_BYTES: usize = 2_048;
     15 pub const CLIENT_METADATA_JSON_MAX_BYTES: usize = 4_352;
     16 pub const URI_MAX_BYTES: usize = 16_384;
     17 pub const RELAY_COUNT_MAX: usize = 32;
     18 pub const SECRET_MAX_BYTES: usize = 1_024;
     19 
     20 /// A validated WebSocket relay URL owned by the NIP-46 protocol package.
     21 #[derive(Clone, PartialEq, Eq, Hash)]
     22 pub struct RelayUrl(nostr::RelayUrl);
     23 
     24 impl RelayUrl {
     25     /// Parses a relay URL accepted by the underlying Nostr wire protocol.
     26     pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> {
     27         nostr::RelayUrl::parse(value).map(Self).map_err(|error| {
     28             RadrootsNostrConnectError::InvalidRelayUrl {
     29                 value: value.to_owned(),
     30                 reason: error.to_string(),
     31             }
     32         })
     33     }
     34 }
     35 
     36 impl fmt::Debug for RelayUrl {
     37     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     38         formatter.debug_tuple("RelayUrl").field(&self.0).finish()
     39     }
     40 }
     41 
     42 impl fmt::Display for RelayUrl {
     43     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     44         self.0.fmt(formatter)
     45     }
     46 }
     47 
     48 impl FromStr for RelayUrl {
     49     type Err = RadrootsNostrConnectError;
     50 
     51     fn from_str(value: &str) -> Result<Self, Self::Err> {
     52         Self::parse(value)
     53     }
     54 }
     55 
     56 impl Serialize for RelayUrl {
     57     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     58     where
     59         S: serde::Serializer,
     60     {
     61         serializer.serialize_str(&self.to_string())
     62     }
     63 }
     64 
     65 impl<'de> Deserialize<'de> for RelayUrl {
     66     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     67     where
     68         D: serde::Deserializer<'de>,
     69     {
     70         let value = String::deserialize(deserializer)?;
     71         Self::parse(&value).map_err(serde::de::Error::custom)
     72     }
     73 }
     74 
     75 #[derive(Clone, PartialEq, Eq)]
     76 pub struct BunkerUri {
     77     remote_signer_public_key: PublicKey,
     78     relays: Vec<RelayUrl>,
     79     secret: Option<String>,
     80 }
     81 
     82 impl BunkerUri {
     83     #[must_use]
     84     pub const fn remote_signer_public_key(&self) -> PublicKey {
     85         self.remote_signer_public_key
     86     }
     87 
     88     #[must_use]
     89     pub fn relays(&self) -> &[RelayUrl] {
     90         &self.relays
     91     }
     92 
     93     #[must_use]
     94     pub fn secret(&self) -> Option<&str> {
     95         self.secret.as_deref()
     96     }
     97 }
     98 
     99 impl fmt::Debug for BunkerUri {
    100     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    101         formatter
    102             .debug_struct("BunkerUri")
    103             .field("remote_signer_public_key", &self.remote_signer_public_key)
    104             .field("relays", &self.relays)
    105             .field("secret", &self.secret.as_ref().map(|_| "[redacted]"))
    106             .finish()
    107     }
    108 }
    109 
    110 #[derive(Debug, Clone, Default, PartialEq, Eq)]
    111 pub struct ClientMetadata {
    112     #[doc(hidden)]
    113     pub requested_permissions: Permissions,
    114     #[doc(hidden)]
    115     pub name: Option<String>,
    116     #[doc(hidden)]
    117     pub url: Option<String>,
    118     #[doc(hidden)]
    119     pub image: Option<String>,
    120 }
    121 
    122 #[derive(Debug, Serialize, Deserialize)]
    123 struct ClientMetadataSerde {
    124     #[serde(default, skip_serializing_if = "Permissions::is_empty")]
    125     requested_permissions: Permissions,
    126     #[serde(default, skip_serializing_if = "Option::is_none")]
    127     name: Option<String>,
    128     #[serde(default, skip_serializing_if = "Option::is_none")]
    129     url: Option<String>,
    130     #[serde(default, skip_serializing_if = "Option::is_none")]
    131     image: Option<String>,
    132 }
    133 
    134 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
    135 struct ClientMetadataWire {
    136     #[serde(default, skip_serializing_if = "Option::is_none")]
    137     name: Option<String>,
    138     #[serde(default, skip_serializing_if = "Option::is_none")]
    139     url: Option<String>,
    140     #[serde(default, skip_serializing_if = "Option::is_none")]
    141     image: Option<String>,
    142 }
    143 
    144 impl ClientMetadata {
    145     #[must_use]
    146     pub fn new() -> Self {
    147         Self::default()
    148     }
    149 
    150     #[must_use]
    151     pub fn requested_permissions(&self) -> &Permissions {
    152         &self.requested_permissions
    153     }
    154 
    155     #[must_use]
    156     pub fn name(&self) -> Option<&str> {
    157         self.name.as_deref()
    158     }
    159 
    160     #[must_use]
    161     pub fn url(&self) -> Option<&str> {
    162         self.url.as_deref()
    163     }
    164 
    165     #[must_use]
    166     pub fn image(&self) -> Option<&str> {
    167         self.image.as_deref()
    168     }
    169 
    170     #[must_use]
    171     pub fn with_requested_permissions(mut self, permissions: Permissions) -> Self {
    172         self.requested_permissions = permissions;
    173         self
    174     }
    175 
    176     pub fn with_name(
    177         mut self,
    178         value: impl Into<String>,
    179     ) -> Result<Self, RadrootsNostrConnectError> {
    180         self.name = Some(normalize_client_name(&value.into())?);
    181         Ok(self)
    182     }
    183 
    184     pub fn with_url(mut self, value: impl Into<String>) -> Result<Self, RadrootsNostrConnectError> {
    185         self.url = Some(normalize_client_url("url", &value.into())?);
    186         Ok(self)
    187     }
    188 
    189     pub fn with_image(
    190         mut self,
    191         value: impl Into<String>,
    192     ) -> Result<Self, RadrootsNostrConnectError> {
    193         self.image = Some(normalize_client_url("image", &value.into())?);
    194         Ok(self)
    195     }
    196 
    197     pub fn normalized(self) -> Result<Self, RadrootsNostrConnectError> {
    198         Ok(Self {
    199             requested_permissions: self.requested_permissions,
    200             name: self
    201                 .name
    202                 .map(|value| normalize_client_name(&value))
    203                 .transpose()?,
    204             url: self
    205                 .url
    206                 .map(|value| normalize_client_url("url", &value))
    207                 .transpose()?,
    208             image: self
    209                 .image
    210                 .map(|value| normalize_client_url("image", &value))
    211                 .transpose()?,
    212         })
    213     }
    214 
    215     pub fn to_connect_param(&self) -> Result<String, RadrootsNostrConnectError> {
    216         let normalized = self.clone().normalized()?;
    217         let wire = ClientMetadataWire {
    218             name: normalized.name,
    219             url: normalized.url,
    220             image: normalized.image,
    221         };
    222         let value = serde_json::to_string(&wire)?;
    223         validate_metadata_size(&value)?;
    224         Ok(value)
    225     }
    226 
    227     pub fn from_connect_param(value: &str) -> Result<Self, RadrootsNostrConnectError> {
    228         validate_metadata_size(value)?;
    229         let wire: ClientMetadataWire = serde_json::from_str(value).map_err(|error| {
    230             RadrootsNostrConnectError::InvalidClientMetadata {
    231                 field: "payload",
    232                 reason: error.to_string(),
    233             }
    234         })?;
    235         Self {
    236             requested_permissions: Permissions::default(),
    237             name: wire.name,
    238             url: wire.url,
    239             image: wire.image,
    240         }
    241         .normalized()
    242     }
    243 
    244     pub fn is_display_empty(&self) -> bool {
    245         self.name.is_none() && self.url.is_none() && self.image.is_none()
    246     }
    247 }
    248 
    249 impl Serialize for ClientMetadata {
    250     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    251     where
    252         S: serde::Serializer,
    253     {
    254         let normalized = self
    255             .clone()
    256             .normalized()
    257             .map_err(serde::ser::Error::custom)?;
    258         ClientMetadataSerde {
    259             requested_permissions: normalized.requested_permissions,
    260             name: normalized.name,
    261             url: normalized.url,
    262             image: normalized.image,
    263         }
    264         .serialize(serializer)
    265     }
    266 }
    267 
    268 impl<'de> Deserialize<'de> for ClientMetadata {
    269     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    270     where
    271         D: serde::Deserializer<'de>,
    272     {
    273         let metadata = ClientMetadataSerde::deserialize(deserializer)?;
    274         Self {
    275             requested_permissions: metadata.requested_permissions,
    276             name: metadata.name,
    277             url: metadata.url,
    278             image: metadata.image,
    279         }
    280         .normalized()
    281         .map_err(serde::de::Error::custom)
    282     }
    283 }
    284 
    285 #[derive(Clone, PartialEq, Eq)]
    286 pub struct ClientUri {
    287     client_public_key: PublicKey,
    288     relays: Vec<RelayUrl>,
    289     secret: String,
    290     metadata: ClientMetadata,
    291 }
    292 
    293 impl ClientUri {
    294     /// Constructs a validated client-origin URI from canonical protocol values.
    295     pub fn try_new(
    296         client_public_key: PublicKey,
    297         relays: impl IntoIterator<Item = RelayUrl>,
    298         secret: impl Into<String>,
    299         metadata: ClientMetadata,
    300     ) -> Result<Self, RadrootsNostrConnectError> {
    301         let mut normalized_relays = Vec::new();
    302         for relay in relays {
    303             if normalized_relays.contains(&relay) {
    304                 continue;
    305             }
    306             if normalized_relays.len() == RELAY_COUNT_MAX {
    307                 return Err(RadrootsNostrConnectError::InvalidUri);
    308             }
    309             normalized_relays.push(relay);
    310         }
    311         if normalized_relays.is_empty() {
    312             return Err(RadrootsNostrConnectError::MissingRelay);
    313         }
    314         let secret = secret.into();
    315         if secret.is_empty() {
    316             return Err(RadrootsNostrConnectError::MissingSecret);
    317         }
    318         validate_secret(&secret)?;
    319         Ok(Self {
    320             client_public_key,
    321             relays: normalized_relays,
    322             secret,
    323             metadata: metadata.normalized()?,
    324         })
    325     }
    326 
    327     #[must_use]
    328     pub const fn client_public_key(&self) -> PublicKey {
    329         self.client_public_key
    330     }
    331 
    332     #[must_use]
    333     pub fn relays(&self) -> &[RelayUrl] {
    334         &self.relays
    335     }
    336 
    337     #[must_use]
    338     pub fn secret(&self) -> &str {
    339         &self.secret
    340     }
    341 
    342     #[must_use]
    343     pub fn metadata(&self) -> &ClientMetadata {
    344         &self.metadata
    345     }
    346 }
    347 
    348 impl fmt::Debug for ClientUri {
    349     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    350         formatter
    351             .debug_struct("ClientUri")
    352             .field("client_public_key", &self.client_public_key)
    353             .field("relays", &self.relays)
    354             .field("secret", &"[redacted]")
    355             .field("metadata", &self.metadata)
    356             .finish()
    357     }
    358 }
    359 
    360 #[derive(Debug, Clone, PartialEq, Eq)]
    361 pub enum Uri {
    362     Bunker(BunkerUri),
    363     Client(ClientUri),
    364 }
    365 
    366 impl Uri {
    367     pub fn parse(value: &str) -> Result<Self, RadrootsNostrConnectError> {
    368         if value.len() > URI_MAX_BYTES {
    369             return Err(RadrootsNostrConnectError::InvalidUri);
    370         }
    371         let url = Url::parse(value).map_err(|error| RadrootsNostrConnectError::InvalidUrl {
    372             value: "[redacted NIP-46 URI]".to_owned(),
    373             reason: error.to_string(),
    374         })?;
    375         let host = url
    376             .host_str()
    377             .ok_or(RadrootsNostrConnectError::MissingPublicKey)?;
    378 
    379         match url.scheme() {
    380             BUNKER_URI_SCHEME => {
    381                 let remote_signer_public_key = parse_public_key(host)?;
    382                 let mut relays = Vec::new();
    383                 let mut secret = None;
    384 
    385                 for (key, value) in url.query_pairs() {
    386                     match key.as_ref() {
    387                         "relay" => push_relay(&mut relays, value.as_ref())?,
    388                         "secret" => set_once(&mut secret, value.into_owned())?,
    389                         _ => {}
    390                     }
    391                 }
    392 
    393                 if relays.is_empty() {
    394                     return Err(RadrootsNostrConnectError::MissingRelay);
    395                 }
    396 
    397                 validate_optional_secret(secret.as_deref())?;
    398                 Ok(Self::Bunker(BunkerUri {
    399                     remote_signer_public_key,
    400                     relays,
    401                     secret,
    402                 }))
    403             }
    404             URI_SCHEME => {
    405                 let client_public_key = parse_public_key(host)?;
    406                 let mut relays = Vec::new();
    407                 let mut secret = None;
    408                 let mut metadata = ClientMetadata::default();
    409                 let mut permissions_seen = false;
    410 
    411                 for (key, value) in url.query_pairs() {
    412                     match key.as_ref() {
    413                         "relay" => push_relay(&mut relays, value.as_ref())?,
    414                         "secret" => set_once(&mut secret, value.into_owned())?,
    415                         "perms" => {
    416                             if permissions_seen {
    417                                 return Err(RadrootsNostrConnectError::InvalidUri);
    418                             }
    419                             permissions_seen = true;
    420                             metadata.requested_permissions = Permissions::from_str(value.as_ref())?;
    421                         }
    422                         "name" => set_once(&mut metadata.name, value.into_owned())?,
    423                         "url" => set_once(&mut metadata.url, value.into_owned())?,
    424                         "image" => set_once(&mut metadata.image, value.into_owned())?,
    425                         _ => {}
    426                     }
    427                 }
    428 
    429                 if relays.is_empty() {
    430                     return Err(RadrootsNostrConnectError::MissingRelay);
    431                 }
    432 
    433                 let secret = secret.ok_or(RadrootsNostrConnectError::MissingSecret)?;
    434                 if secret.is_empty() {
    435                     return Err(RadrootsNostrConnectError::MissingSecret);
    436                 }
    437                 validate_secret(&secret)?;
    438                 let metadata = metadata.normalized()?;
    439 
    440                 Ok(Self::Client(ClientUri {
    441                     client_public_key,
    442                     relays,
    443                     secret,
    444                     metadata,
    445                 }))
    446             }
    447             scheme => Err(RadrootsNostrConnectError::InvalidUriScheme(
    448                 scheme.to_owned(),
    449             )),
    450         }
    451     }
    452 }
    453 
    454 impl FromStr for Uri {
    455     type Err = RadrootsNostrConnectError;
    456 
    457     fn from_str(value: &str) -> Result<Self, Self::Err> {
    458         Self::parse(value)
    459     }
    460 }
    461 
    462 impl fmt::Display for Uri {
    463     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    464         match self {
    465             Self::Bunker(uri) => uri.fmt(f),
    466             Self::Client(uri) => uri.fmt(f),
    467         }
    468     }
    469 }
    470 
    471 impl fmt::Display for BunkerUri {
    472     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    473         let mut serializer = url::form_urlencoded::Serializer::new(String::new());
    474         for relay in &self.relays {
    475             serializer.append_pair("relay", &relay.to_string());
    476         }
    477         if let Some(secret) = &self.secret {
    478             serializer.append_pair("secret", secret);
    479         }
    480         let query = serializer.finish();
    481         write!(
    482             formatter,
    483             "{BUNKER_URI_SCHEME}://{}?{query}",
    484             self.remote_signer_public_key
    485         )
    486     }
    487 }
    488 
    489 impl fmt::Display for ClientUri {
    490     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    491         let mut serializer = url::form_urlencoded::Serializer::new(String::new());
    492         for relay in &self.relays {
    493             serializer.append_pair("relay", &relay.to_string());
    494         }
    495         serializer.append_pair("secret", &self.secret);
    496         if !self.metadata.requested_permissions.is_empty() {
    497             serializer.append_pair("perms", &self.metadata.requested_permissions.to_string());
    498         }
    499         if let Some(name) = &self.metadata.name {
    500             serializer.append_pair("name", name);
    501         }
    502         if let Some(url) = &self.metadata.url {
    503             serializer.append_pair("url", url);
    504         }
    505         if let Some(image) = &self.metadata.image {
    506             serializer.append_pair("image", image);
    507         }
    508         let query = serializer.finish();
    509         write!(
    510             formatter,
    511             "{URI_SCHEME}://{}?{query}",
    512             self.client_public_key
    513         )
    514     }
    515 }
    516 
    517 macro_rules! impl_uri_serde {
    518     ($type:ty, $variant:path) => {
    519         impl Serialize for $type {
    520             fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    521             where
    522                 S: serde::Serializer,
    523             {
    524                 serializer.serialize_str(&self.to_string())
    525             }
    526         }
    527 
    528         impl<'de> Deserialize<'de> for $type {
    529             fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    530             where
    531                 D: serde::Deserializer<'de>,
    532             {
    533                 let value = String::deserialize(deserializer)?;
    534                 match Uri::parse(&value).map_err(serde::de::Error::custom)? {
    535                     $variant(uri) => Ok(uri),
    536                     _ => Err(serde::de::Error::custom("unexpected NIP-46 URI scheme")),
    537                 }
    538             }
    539         }
    540     };
    541 }
    542 
    543 impl_uri_serde!(BunkerUri, Uri::Bunker);
    544 impl_uri_serde!(ClientUri, Uri::Client);
    545 
    546 impl Serialize for Uri {
    547     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    548     where
    549         S: serde::Serializer,
    550     {
    551         serializer.serialize_str(&self.to_string())
    552     }
    553 }
    554 
    555 impl<'de> Deserialize<'de> for Uri {
    556     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    557     where
    558         D: serde::Deserializer<'de>,
    559     {
    560         let value = String::deserialize(deserializer)?;
    561         Self::parse(&value).map_err(serde::de::Error::custom)
    562     }
    563 }
    564 
    565 fn parse_public_key(value: &str) -> Result<PublicKey, RadrootsNostrConnectError> {
    566     radroots_nostr::key::parse_public_key(value).map_err(|error| {
    567         RadrootsNostrConnectError::InvalidPublicKey {
    568             value: value.to_owned(),
    569             reason: error.to_string(),
    570         }
    571     })
    572 }
    573 
    574 fn parse_relay_url(value: &str) -> Result<RelayUrl, RadrootsNostrConnectError> {
    575     RelayUrl::parse(value)
    576 }
    577 
    578 fn push_relay(relays: &mut Vec<RelayUrl>, value: &str) -> Result<(), RadrootsNostrConnectError> {
    579     let relay = parse_relay_url(value)?;
    580     if relays.contains(&relay) {
    581         return Ok(());
    582     }
    583     if relays.len() == RELAY_COUNT_MAX {
    584         return Err(RadrootsNostrConnectError::InvalidUri);
    585     }
    586     relays.push(relay);
    587     Ok(())
    588 }
    589 
    590 fn set_once(slot: &mut Option<String>, value: String) -> Result<(), RadrootsNostrConnectError> {
    591     if slot.replace(value).is_some() {
    592         return Err(RadrootsNostrConnectError::InvalidUri);
    593     }
    594     Ok(())
    595 }
    596 
    597 fn validate_optional_secret(secret: Option<&str>) -> Result<(), RadrootsNostrConnectError> {
    598     match secret {
    599         Some("") => Err(RadrootsNostrConnectError::InvalidUri),
    600         Some(secret) => validate_secret(secret),
    601         None => Ok(()),
    602     }
    603 }
    604 
    605 fn validate_secret(secret: &str) -> Result<(), RadrootsNostrConnectError> {
    606     if secret.len() > SECRET_MAX_BYTES || secret.chars().any(char::is_control) {
    607         return Err(RadrootsNostrConnectError::InvalidUri);
    608     }
    609     Ok(())
    610 }
    611 
    612 fn normalize_client_name(value: &str) -> Result<String, RadrootsNostrConnectError> {
    613     let normalized = value.trim();
    614     if normalized.is_empty() {
    615         return Err(invalid_client_metadata("name", "must not be empty"));
    616     }
    617     if normalized.len() > CLIENT_NAME_MAX_BYTES {
    618         return Err(invalid_client_metadata(
    619             "name",
    620             format!("must not exceed {CLIENT_NAME_MAX_BYTES} UTF-8 bytes"),
    621         ));
    622     }
    623     if normalized.chars().any(char::is_control) {
    624         return Err(invalid_client_metadata(
    625             "name",
    626             "must not contain control characters",
    627         ));
    628     }
    629     Ok(normalized.to_owned())
    630 }
    631 
    632 fn normalize_client_url(
    633     field: &'static str,
    634     value: &str,
    635 ) -> Result<String, RadrootsNostrConnectError> {
    636     if value.len() > CLIENT_URL_MAX_BYTES {
    637         return Err(invalid_client_metadata(
    638             field,
    639             format!("must not exceed {CLIENT_URL_MAX_BYTES} UTF-8 bytes"),
    640         ));
    641     }
    642     if value.chars().any(char::is_control) {
    643         return Err(invalid_client_metadata(
    644             field,
    645             "must not contain control characters",
    646         ));
    647     }
    648     let parsed =
    649         Url::parse(value).map_err(|error| invalid_client_metadata(field, error.to_string()))?;
    650     if !matches!(parsed.scheme(), "http" | "https") {
    651         return Err(invalid_client_metadata(
    652             field,
    653             "must use the http or https scheme",
    654         ));
    655     }
    656     if !parsed.username().is_empty() || parsed.password().is_some() {
    657         return Err(invalid_client_metadata(
    658             field,
    659             "must not contain credentials",
    660         ));
    661     }
    662     Ok(parsed.to_string())
    663 }
    664 
    665 fn validate_metadata_size(value: &str) -> Result<(), RadrootsNostrConnectError> {
    666     let received = value.len();
    667     if received > CLIENT_METADATA_JSON_MAX_BYTES {
    668         return Err(RadrootsNostrConnectError::ClientMetadataTooLarge {
    669             max: CLIENT_METADATA_JSON_MAX_BYTES,
    670             received,
    671         });
    672     }
    673     Ok(())
    674 }
    675 
    676 fn invalid_client_metadata(
    677     field: &'static str,
    678     reason: impl Into<String>,
    679 ) -> RadrootsNostrConnectError {
    680     RadrootsNostrConnectError::InvalidClientMetadata {
    681         field,
    682         reason: reason.into(),
    683     }
    684 }