lib

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

nip17.rs (9999B)


      1 #![forbid(unsafe_code)]
      2 
      3 extern crate alloc;
      4 
      5 use alloc::{string::String, vec::Vec};
      6 
      7 use nostr::nips::nip59;
      8 use nostr::{
      9     Event, EventBuilder, Kind, NostrSigner, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent,
     10 };
     11 use thiserror::Error;
     12 
     13 use radroots_events::kinds::{KIND_MESSAGE, KIND_MESSAGE_FILE};
     14 use radroots_events::message::RadrootsMessage;
     15 use radroots_events::message_file::RadrootsMessageFile;
     16 use radroots_events_codec::error::{EventEncodeError, EventParseError};
     17 use radroots_events_codec::message::decode as message_decode;
     18 use radroots_events_codec::message::encode as message_encode;
     19 use radroots_events_codec::message_file::decode as message_file_decode;
     20 use radroots_events_codec::message_file::encode as message_file_encode;
     21 use radroots_events_codec::parsed::RadrootsParsedData;
     22 use radroots_events_codec::wire::WireEventParts;
     23 
     24 use crate::util::created_at_u32_saturating;
     25 
     26 #[derive(Debug, Error)]
     27 pub enum RadrootsNip17Error {
     28     #[error("Message encode error: {0}")]
     29     MessageEncode(#[from] EventEncodeError),
     30     #[error("Message decode error: {0}")]
     31     MessageDecode(#[from] EventParseError),
     32     #[error("NIP-59 error: {0}")]
     33     Nip59(#[from] nip59::Error),
     34     #[error("Event builder error: {0}")]
     35     EventBuilder(#[from] nostr::event::builder::Error),
     36     #[error("Signer error: {0}")]
     37     Signer(#[from] nostr::signer::SignerError),
     38     #[error("Key error: {0}")]
     39     Key(#[from] nostr::key::Error),
     40     #[error("Unsupported rumor kind: {0}")]
     41     UnsupportedRumorKind(u32),
     42 }
     43 
     44 #[derive(Clone, Debug)]
     45 pub enum RadrootsNip17Rumor {
     46     Message(RadrootsParsedData<RadrootsMessage>),
     47     MessageFile(Box<RadrootsParsedData<RadrootsMessageFile>>),
     48 }
     49 
     50 #[derive(Clone, Debug)]
     51 pub struct RadrootsNip17WrapOptions {
     52     pub include_sender: bool,
     53     pub rumor_created_at: Option<u32>,
     54     pub gift_wrap_tags: Vec<Vec<String>>,
     55 }
     56 
     57 impl Default for RadrootsNip17WrapOptions {
     58     fn default() -> Self {
     59         Self {
     60             include_sender: true,
     61             rumor_created_at: None,
     62             gift_wrap_tags: Vec::new(),
     63         }
     64     }
     65 }
     66 
     67 fn tags_from_slices(tag_slices: &[Vec<String>]) -> Vec<Tag> {
     68     let mut tags = Vec::with_capacity(tag_slices.len());
     69     for slice in tag_slices {
     70         if slice.is_empty() {
     71             continue;
     72         }
     73         let key = slice[0].clone();
     74         let values = slice[1..].to_vec();
     75         tags.push(Tag::custom(TagKind::Custom(key.into()), values));
     76     }
     77     tags
     78 }
     79 
     80 fn rumor_from_parts(
     81     parts: WireEventParts,
     82     author: PublicKey,
     83     created_at: Option<u32>,
     84 ) -> UnsignedEvent {
     85     let tags = tags_from_slices(&parts.tags);
     86     let timestamp = match created_at {
     87         Some(ts) => Timestamp::from_secs(ts as u64),
     88         None => Timestamp::now(),
     89     };
     90     let mut rumor = UnsignedEvent::new(
     91         author,
     92         timestamp,
     93         Kind::Custom(parts.kind as u16),
     94         tags,
     95         parts.content,
     96     );
     97     rumor.ensure_id();
     98     rumor
     99 }
    100 
    101 fn parse_recipients(
    102     recipients: &[radroots_events::message::RadrootsMessageRecipient],
    103 ) -> Result<Vec<PublicKey>, RadrootsNip17Error> {
    104     let mut out = Vec::with_capacity(recipients.len());
    105     for recipient in recipients {
    106         out.push(recipient.public_key.parse::<PublicKey>()?);
    107     }
    108     Ok(out)
    109 }
    110 
    111 fn push_unique(recipients: &mut Vec<PublicKey>, pubkey: PublicKey) {
    112     if recipients.iter().any(|r| r == &pubkey) {
    113         return;
    114     }
    115     recipients.push(pubkey);
    116 }
    117 
    118 async fn wrap_rumor<T>(
    119     signer: &T,
    120     rumor: UnsignedEvent,
    121     mut recipients: Vec<PublicKey>,
    122     options: &RadrootsNip17WrapOptions,
    123 ) -> Result<Vec<Event>, RadrootsNip17Error>
    124 where
    125     T: NostrSigner,
    126 {
    127     let sender_pubkey = signer.get_public_key().await?;
    128     if options.include_sender {
    129         push_unique(&mut recipients, sender_pubkey);
    130     }
    131     let extra_tags = tags_from_slices(&options.gift_wrap_tags);
    132 
    133     let mut out = Vec::with_capacity(recipients.len());
    134     for recipient in recipients {
    135         let event =
    136             EventBuilder::gift_wrap(signer, &recipient, rumor.clone(), extra_tags.clone()).await?;
    137         out.push(event);
    138     }
    139     Ok(out)
    140 }
    141 
    142 pub async fn radroots_nostr_wrap_message<T>(
    143     signer: &T,
    144     message: &RadrootsMessage,
    145     options: RadrootsNip17WrapOptions,
    146 ) -> Result<Vec<Event>, RadrootsNip17Error>
    147 where
    148     T: NostrSigner,
    149 {
    150     let parts = message_encode::to_wire_parts(message)?;
    151     let author = signer.get_public_key().await?;
    152     let rumor = rumor_from_parts(parts, author, options.rumor_created_at);
    153     let recipients = parse_recipients(&message.recipients)?;
    154     wrap_rumor(signer, rumor, recipients, &options).await
    155 }
    156 
    157 pub async fn radroots_nostr_wrap_message_file<T>(
    158     signer: &T,
    159     message: &RadrootsMessageFile,
    160     options: RadrootsNip17WrapOptions,
    161 ) -> Result<Vec<Event>, RadrootsNip17Error>
    162 where
    163     T: NostrSigner,
    164 {
    165     let parts = message_file_encode::to_wire_parts(message)?;
    166     let author = signer.get_public_key().await?;
    167     let rumor = rumor_from_parts(parts, author, options.rumor_created_at);
    168     let recipients = parse_recipients(&message.recipients)?;
    169     wrap_rumor(signer, rumor, recipients, &options).await
    170 }
    171 
    172 pub async fn radroots_nostr_unwrap_gift_wrap<T>(
    173     signer: &T,
    174     gift_wrap: &Event,
    175 ) -> Result<RadrootsNip17Rumor, RadrootsNip17Error>
    176 where
    177     T: NostrSigner,
    178 {
    179     let unwrapped = nip59::extract_rumor(signer, gift_wrap).await?;
    180     let mut rumor = unwrapped.rumor;
    181     let id = rumor.id().to_string();
    182     let author = rumor.pubkey.to_string();
    183     let published_at = created_at_u32_saturating(rumor.created_at);
    184     let kind = rumor.kind.as_u16() as u32;
    185     let tags: Vec<Vec<String>> = rumor
    186         .tags
    187         .as_slice()
    188         .iter()
    189         .map(|t| t.as_slice().to_vec())
    190         .collect();
    191     let content = rumor.content.clone();
    192 
    193     match kind {
    194         KIND_MESSAGE => {
    195             let metadata =
    196                 message_decode::data_from_event(id, author, published_at, kind, content, tags)?;
    197             Ok(RadrootsNip17Rumor::Message(metadata))
    198         }
    199         KIND_MESSAGE_FILE => {
    200             let metadata = message_file_decode::data_from_event(
    201                 id,
    202                 author,
    203                 published_at,
    204                 kind,
    205                 content,
    206                 tags,
    207             )?;
    208             Ok(RadrootsNip17Rumor::MessageFile(Box::new(metadata)))
    209         }
    210         other => Err(RadrootsNip17Error::UnsupportedRumorKind(other)),
    211     }
    212 }
    213 
    214 #[cfg(all(test, feature = "nip17"))]
    215 mod tests {
    216     use super::*;
    217     use crate::test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB};
    218     use nostr::{Keys, SecretKey};
    219     use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient};
    220     use radroots_events::message_file::{RadrootsMessageFile, RadrootsMessageFileDimensions};
    221 
    222     fn sender_keys() -> Keys {
    223         Keys::new(SecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).unwrap())
    224     }
    225 
    226     fn receiver_keys() -> Keys {
    227         Keys::new(SecretKey::from_hex(FIXTURE_BOB.secret_key_hex).unwrap())
    228     }
    229 
    230     #[tokio::test]
    231     async fn wrap_and_unwrap_message() {
    232         let sender = sender_keys();
    233         let receiver = receiver_keys();
    234         let message = RadrootsMessage {
    235             recipients: vec![RadrootsMessageRecipient {
    236                 public_key: receiver.public_key().to_string(),
    237                 relay_url: None,
    238             }],
    239             content: "hello".to_string(),
    240             reply_to: None,
    241             subject: None,
    242         };
    243         let options = RadrootsNip17WrapOptions {
    244             include_sender: false,
    245             rumor_created_at: Some(1700000000),
    246             gift_wrap_tags: Vec::new(),
    247         };
    248 
    249         let events = radroots_nostr_wrap_message(&sender, &message, options)
    250             .await
    251             .unwrap();
    252         assert_eq!(events.len(), 1);
    253 
    254         let rumor = radroots_nostr_unwrap_gift_wrap(&receiver, &events[0])
    255             .await
    256             .unwrap();
    257         match rumor {
    258             RadrootsNip17Rumor::Message(metadata) => {
    259                 assert_eq!(metadata.data.content, "hello");
    260                 assert_eq!(metadata.data.recipients.len(), 1);
    261             }
    262             other => panic!("expected message rumor, got {other:?}"),
    263         }
    264     }
    265 
    266     #[tokio::test]
    267     async fn wrap_and_unwrap_message_file() {
    268         let sender = sender_keys();
    269         let receiver = receiver_keys();
    270         let message = RadrootsMessageFile {
    271             recipients: vec![RadrootsMessageRecipient {
    272                 public_key: receiver.public_key().to_string(),
    273                 relay_url: None,
    274             }],
    275             file_url: "https://files.example/encrypted.bin".to_string(),
    276             reply_to: None,
    277             subject: None,
    278             file_type: "image/jpeg".to_string(),
    279             encryption_algorithm: "aes-gcm".to_string(),
    280             decryption_key: "key".to_string(),
    281             decryption_nonce: "nonce".to_string(),
    282             encrypted_hash: "hash".to_string(),
    283             original_hash: None,
    284             size: Some(1200),
    285             dimensions: Some(RadrootsMessageFileDimensions { w: 1200, h: 800 }),
    286             blurhash: None,
    287             thumb: None,
    288             fallbacks: Vec::new(),
    289         };
    290         let options = RadrootsNip17WrapOptions {
    291             include_sender: false,
    292             rumor_created_at: Some(1700000001),
    293             gift_wrap_tags: Vec::new(),
    294         };
    295 
    296         let events = radroots_nostr_wrap_message_file(&sender, &message, options)
    297             .await
    298             .unwrap();
    299         assert_eq!(events.len(), 1);
    300 
    301         let rumor = radroots_nostr_unwrap_gift_wrap(&receiver, &events[0])
    302             .await
    303             .unwrap();
    304         match rumor {
    305             RadrootsNip17Rumor::MessageFile(metadata) => {
    306                 assert_eq!(metadata.data.file_url, message.file_url);
    307                 assert_eq!(metadata.data.encrypted_hash, message.encrypted_hash);
    308             }
    309             other => panic!("expected message file rumor, got {other:?}"),
    310         }
    311     }
    312 }