lib

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

nip17.rs (14473B)


      1 //! Focused NIP-17/NIP-59 message wrapping and unwrapping.
      2 //!
      3 //! This module creates and opens protocol events only. Relay selection,
      4 //! delivery, retries, persistence, and runtime ownership remain outside this
      5 //! crate.
      6 
      7 #![forbid(unsafe_code)]
      8 
      9 extern crate alloc;
     10 
     11 use alloc::{string::String, vec::Vec};
     12 
     13 use nostr::nips::nip59;
     14 use nostr::{
     15     Event, EventBuilder, Kind, NostrSigner, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent,
     16 };
     17 use radroots_event::envelope::kind::{KIND_MESSAGE, KIND_MESSAGE_FILE};
     18 use radroots_event::social::message::Message;
     19 use radroots_event::social::message_file::MessageFile;
     20 use radroots_event::wire::Nip01EventWireParts;
     21 use radroots_event_codec::decode::RadrootsParsedData;
     22 use radroots_event_codec::decode::message_file as message_file_decode;
     23 use radroots_event_codec::decode::{EventParseError, message as message_decode};
     24 use radroots_event_codec::encode::message_file as message_file_encode;
     25 use radroots_event_codec::encode::{EventEncodeError, message as message_encode};
     26 
     27 /// Stable, source-redacted failures from the focused NIP-17 adapter.
     28 #[derive(Clone, Debug, PartialEq, Eq)]
     29 #[non_exhaustive]
     30 pub enum Error {
     31     MessageEncode,
     32     MessageDecode,
     33     GiftWrap,
     34     Signer,
     35     InvalidRecipient,
     36     UnsupportedRumorKind { kind: u32 },
     37 }
     38 
     39 impl Error {
     40     /// Returns a stable machine-readable failure code.
     41     #[must_use]
     42     pub const fn code(&self) -> &'static str {
     43         match self {
     44             Self::MessageEncode => "message_encode",
     45             Self::MessageDecode => "message_decode",
     46             Self::GiftWrap => "gift_wrap",
     47             Self::Signer => "signer",
     48             Self::InvalidRecipient => "invalid_recipient",
     49             Self::UnsupportedRumorKind { .. } => "unsupported_rumor_kind",
     50         }
     51     }
     52 }
     53 
     54 impl core::fmt::Display for Error {
     55     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
     56         match self {
     57             Self::MessageEncode => formatter.write_str("failed to encode NIP-17 message"),
     58             Self::MessageDecode => formatter.write_str("failed to decode NIP-17 message"),
     59             Self::GiftWrap => formatter.write_str("failed to process NIP-59 gift wrap"),
     60             Self::Signer => formatter.write_str("NIP-17 signer failed"),
     61             Self::InvalidRecipient => formatter.write_str("NIP-17 recipient is invalid"),
     62             Self::UnsupportedRumorKind { kind } => {
     63                 write!(formatter, "unsupported NIP-17 rumor kind {kind}")
     64             }
     65         }
     66     }
     67 }
     68 
     69 impl core::error::Error for Error {}
     70 
     71 impl From<EventEncodeError> for Error {
     72     fn from(_: EventEncodeError) -> Self {
     73         Self::MessageEncode
     74     }
     75 }
     76 
     77 impl From<EventParseError> for Error {
     78     fn from(_: EventParseError) -> Self {
     79         Self::MessageDecode
     80     }
     81 }
     82 
     83 impl From<nip59::Error> for Error {
     84     fn from(_: nip59::Error) -> Self {
     85         Self::GiftWrap
     86     }
     87 }
     88 
     89 impl From<nostr::event::builder::Error> for Error {
     90     fn from(_: nostr::event::builder::Error) -> Self {
     91         Self::GiftWrap
     92     }
     93 }
     94 
     95 impl From<nostr::signer::SignerError> for Error {
     96     fn from(_: nostr::signer::SignerError) -> Self {
     97         Self::Signer
     98     }
     99 }
    100 
    101 impl From<nostr::key::Error> for Error {
    102     fn from(_: nostr::key::Error) -> Self {
    103         Self::InvalidRecipient
    104     }
    105 }
    106 
    107 #[derive(Clone)]
    108 pub enum Rumor {
    109     Message(RadrootsParsedData<Message>),
    110     MessageFile(Box<RadrootsParsedData<MessageFile>>),
    111 }
    112 
    113 impl core::fmt::Debug for Rumor {
    114     fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
    115         match self {
    116             Self::Message(_) => formatter
    117                 .debug_struct("Rumor::Message")
    118                 .finish_non_exhaustive(),
    119             Self::MessageFile(_) => formatter
    120                 .debug_struct("Rumor::MessageFile")
    121                 .finish_non_exhaustive(),
    122         }
    123     }
    124 }
    125 
    126 #[derive(Clone, Debug)]
    127 pub struct WrapOptions {
    128     include_sender: bool,
    129     rumor_created_at: Option<u64>,
    130     gift_wrap_tags: Vec<Vec<String>>,
    131 }
    132 
    133 impl Default for WrapOptions {
    134     fn default() -> Self {
    135         Self {
    136             include_sender: true,
    137             rumor_created_at: None,
    138             gift_wrap_tags: Vec::new(),
    139         }
    140     }
    141 }
    142 
    143 impl WrapOptions {
    144     #[must_use]
    145     pub fn include_sender(mut self, include_sender: bool) -> Self {
    146         self.include_sender = include_sender;
    147         self
    148     }
    149 
    150     #[must_use]
    151     pub fn with_rumor_created_at(mut self, created_at: u64) -> Self {
    152         self.rumor_created_at = Some(created_at);
    153         self
    154     }
    155 
    156     #[must_use]
    157     pub fn with_gift_wrap_tags(mut self, tags: Vec<Vec<String>>) -> Self {
    158         self.gift_wrap_tags = tags;
    159         self
    160     }
    161 
    162     pub const fn includes_sender(&self) -> bool {
    163         self.include_sender
    164     }
    165 
    166     pub const fn rumor_created_at(&self) -> Option<u64> {
    167         self.rumor_created_at
    168     }
    169 
    170     pub fn gift_wrap_tags(&self) -> &[Vec<String>] {
    171         &self.gift_wrap_tags
    172     }
    173 }
    174 
    175 fn tags_from_slices(tag_slices: &[Vec<String>]) -> Vec<Tag> {
    176     let mut tags = Vec::with_capacity(tag_slices.len());
    177     for slice in tag_slices {
    178         if slice.is_empty() {
    179             continue;
    180         }
    181         let key = slice[0].clone();
    182         let values = slice[1..].to_vec();
    183         tags.push(Tag::custom(TagKind::Custom(key.into()), values));
    184     }
    185     tags
    186 }
    187 
    188 fn rumor_from_parts(
    189     parts: Nip01EventWireParts,
    190     author: PublicKey,
    191     created_at: Option<u64>,
    192 ) -> Result<UnsignedEvent, Error> {
    193     let kind =
    194         u16::try_from(parts.kind).map_err(|_| Error::UnsupportedRumorKind { kind: parts.kind })?;
    195     let tags = tags_from_slices(&parts.tags);
    196     let timestamp = match created_at {
    197         Some(ts) => Timestamp::from_secs(ts),
    198         None => Timestamp::now(),
    199     };
    200     let mut rumor = UnsignedEvent::new(author, timestamp, Kind::Custom(kind), tags, parts.content);
    201     rumor.ensure_id();
    202     Ok(rumor)
    203 }
    204 
    205 fn parse_recipients(
    206     recipients: &[radroots_event::social::message::MessageRecipient],
    207 ) -> Result<Vec<PublicKey>, Error> {
    208     let mut out = Vec::with_capacity(recipients.len());
    209     for recipient in recipients {
    210         out.push(recipient.public_key.parse::<PublicKey>()?);
    211     }
    212     Ok(out)
    213 }
    214 
    215 fn push_unique(recipients: &mut Vec<PublicKey>, pubkey: PublicKey) {
    216     if recipients.iter().any(|r| r == &pubkey) {
    217         return;
    218     }
    219     recipients.push(pubkey);
    220 }
    221 
    222 async fn wrap_rumor<T>(
    223     signer: &T,
    224     rumor: UnsignedEvent,
    225     mut recipients: Vec<PublicKey>,
    226     options: &WrapOptions,
    227 ) -> Result<Vec<Event>, Error>
    228 where
    229     T: NostrSigner,
    230 {
    231     let sender_pubkey = signer.get_public_key().await?;
    232     if options.include_sender {
    233         push_unique(&mut recipients, sender_pubkey);
    234     }
    235     let extra_tags = tags_from_slices(&options.gift_wrap_tags);
    236 
    237     let mut out = Vec::with_capacity(recipients.len());
    238     for recipient in recipients {
    239         let event =
    240             EventBuilder::gift_wrap(signer, &recipient, rumor.clone(), extra_tags.clone()).await?;
    241         out.push(event);
    242     }
    243     Ok(out)
    244 }
    245 
    246 pub async fn wrap_message<T>(
    247     signer: &T,
    248     message: &Message,
    249     options: WrapOptions,
    250 ) -> Result<Vec<Event>, Error>
    251 where
    252     T: NostrSigner,
    253 {
    254     let parts = message_encode::to_wire_parts(message)?;
    255     let author = signer.get_public_key().await?;
    256     let rumor = rumor_from_parts(parts, author, options.rumor_created_at)?;
    257     let recipients = parse_recipients(&message.recipients)?;
    258     wrap_rumor(signer, rumor, recipients, &options).await
    259 }
    260 
    261 pub async fn wrap_message_file<T>(
    262     signer: &T,
    263     message: &MessageFile,
    264     options: WrapOptions,
    265 ) -> Result<Vec<Event>, Error>
    266 where
    267     T: NostrSigner,
    268 {
    269     let parts = message_file_encode::to_wire_parts(message)?;
    270     let author = signer.get_public_key().await?;
    271     let rumor = rumor_from_parts(parts, author, options.rumor_created_at)?;
    272     let recipients = parse_recipients(&message.recipients)?;
    273     wrap_rumor(signer, rumor, recipients, &options).await
    274 }
    275 
    276 pub async fn unwrap_gift_wrap<T>(signer: &T, gift_wrap: &Event) -> Result<Rumor, Error>
    277 where
    278     T: NostrSigner,
    279 {
    280     let unwrapped = nip59::extract_rumor(signer, gift_wrap).await?;
    281     let mut rumor = unwrapped.rumor;
    282     let id = rumor.id().to_string();
    283     let author = rumor.pubkey.to_string();
    284     let published_at = rumor.created_at.as_secs();
    285     let kind = rumor.kind.as_u16() as u32;
    286     let tags: Vec<Vec<String>> = rumor
    287         .tags
    288         .as_slice()
    289         .iter()
    290         .map(|t| t.as_slice().to_vec())
    291         .collect();
    292     let content = rumor.content.clone();
    293 
    294     match kind {
    295         KIND_MESSAGE => {
    296             let metadata =
    297                 message_decode::data_from_event(id, author, published_at, kind, content, tags)?;
    298             Ok(Rumor::Message(metadata))
    299         }
    300         KIND_MESSAGE_FILE => {
    301             let metadata = message_file_decode::data_from_event(
    302                 id,
    303                 author,
    304                 published_at,
    305                 kind,
    306                 content,
    307                 tags,
    308             )?;
    309             Ok(Rumor::MessageFile(Box::new(metadata)))
    310         }
    311         other => Err(Error::UnsupportedRumorKind { kind: other }),
    312     }
    313 }
    314 
    315 #[cfg(all(test, feature = "nip17"))]
    316 mod tests {
    317     use super::*;
    318     use crate::test_fixtures::{FIXTURE_ALICE, FIXTURE_BOB};
    319     use nostr::{Keys, SecretKey};
    320     use radroots_event::social::message::{Message, MessageRecipient};
    321     use radroots_event::social::message_file::{MessageFile, MessageFileDimensions};
    322 
    323     fn sender_keys() -> Keys {
    324         Keys::new(SecretKey::from_hex(FIXTURE_ALICE.secret_key_hex).unwrap())
    325     }
    326 
    327     fn receiver_keys() -> Keys {
    328         Keys::new(SecretKey::from_hex(FIXTURE_BOB.secret_key_hex).unwrap())
    329     }
    330 
    331     #[test]
    332     fn rumor_kind_conversion_is_range_checked() {
    333         let author = sender_keys().public_key();
    334         let max = rumor_from_parts(
    335             Nip01EventWireParts {
    336                 kind: u32::from(u16::MAX),
    337                 content: String::new(),
    338                 tags: Vec::new(),
    339             },
    340             author,
    341             Some(1_700_000_000),
    342         )
    343         .expect("maximum NIP-01 kind");
    344         assert_eq!(max.kind.as_u16(), u16::MAX);
    345 
    346         let overflow = u32::from(u16::MAX) + 1;
    347         assert!(matches!(
    348             rumor_from_parts(
    349                 Nip01EventWireParts {
    350                     kind: overflow,
    351                     content: String::new(),
    352                     tags: Vec::new(),
    353                 },
    354                 author,
    355                 Some(1_700_000_000),
    356             ),
    357             Err(Error::UnsupportedRumorKind { kind }) if kind == overflow
    358         ));
    359     }
    360 
    361     #[test]
    362     fn adapter_error_codes_are_stable_and_source_redacted() {
    363         let errors = [
    364             Error::MessageEncode,
    365             Error::MessageDecode,
    366             Error::GiftWrap,
    367             Error::Signer,
    368             Error::InvalidRecipient,
    369             Error::UnsupportedRumorKind { kind: 70_000 },
    370         ];
    371         assert_eq!(
    372             errors.iter().map(Error::code).collect::<Vec<_>>(),
    373             vec![
    374                 "message_encode",
    375                 "message_decode",
    376                 "gift_wrap",
    377                 "signer",
    378                 "invalid_recipient",
    379                 "unsupported_rumor_kind",
    380             ]
    381         );
    382         for error in &errors {
    383             let rendered = format!("{error:?} {error}");
    384             assert!(!rendered.contains("nsec"));
    385             assert!(!rendered.contains("private"));
    386         }
    387     }
    388 
    389     #[tokio::test]
    390     async fn wrap_and_unwrap_message() {
    391         let sender = sender_keys();
    392         let receiver = receiver_keys();
    393         let message = Message {
    394             recipients: vec![MessageRecipient {
    395                 public_key: receiver.public_key().to_string(),
    396                 relay_url: None,
    397             }],
    398             content: "hello".to_string(),
    399             reply_to: None,
    400             subject: None,
    401         };
    402         let options = WrapOptions::default()
    403             .include_sender(false)
    404             .with_rumor_created_at(1_700_000_000);
    405 
    406         let events = wrap_message(&sender, &message, options).await.unwrap();
    407         assert_eq!(events.len(), 1);
    408 
    409         let rumor = unwrap_gift_wrap(&receiver, &events[0]).await.unwrap();
    410         match rumor {
    411             Rumor::Message(metadata) => {
    412                 assert_eq!(metadata.data.content, "hello");
    413                 assert_eq!(metadata.data.recipients.len(), 1);
    414             }
    415             other => panic!("expected message rumor, got {other:?}"),
    416         }
    417     }
    418 
    419     #[tokio::test]
    420     async fn wrap_and_unwrap_message_file() {
    421         let sender = sender_keys();
    422         let receiver = receiver_keys();
    423         let message = MessageFile {
    424             recipients: vec![MessageRecipient {
    425                 public_key: receiver.public_key().to_string(),
    426                 relay_url: None,
    427             }],
    428             file_url: "https://files.example/encrypted.bin".to_string(),
    429             reply_to: None,
    430             subject: None,
    431             file_type: "image/jpeg".to_string(),
    432             encryption_algorithm: "aes-gcm".to_string(),
    433             decryption_key: "key".to_string(),
    434             decryption_nonce: "nonce".to_string(),
    435             encrypted_hash: "hash".to_string(),
    436             original_hash: None,
    437             size: Some(1200),
    438             dimensions: Some(MessageFileDimensions { w: 1200, h: 800 }),
    439             blurhash: None,
    440             thumb: None,
    441             fallbacks: Vec::new(),
    442         };
    443         let options = WrapOptions::default()
    444             .include_sender(false)
    445             .with_rumor_created_at(1_700_000_001);
    446 
    447         let events = wrap_message_file(&sender, &message, options).await.unwrap();
    448         assert_eq!(events.len(), 1);
    449 
    450         let rumor = unwrap_gift_wrap(&receiver, &events[0]).await.unwrap();
    451         let rendered = format!("{rumor:?}");
    452         for private_value in [
    453             message.file_url.as_str(),
    454             message.decryption_key.as_str(),
    455             message.decryption_nonce.as_str(),
    456             message.encrypted_hash.as_str(),
    457         ] {
    458             assert!(!rendered.contains(private_value));
    459         }
    460         match rumor {
    461             Rumor::MessageFile(metadata) => {
    462                 assert_eq!(metadata.data.file_url, message.file_url);
    463                 assert_eq!(metadata.data.encrypted_hash, message.encrypted_hash);
    464             }
    465             other => panic!("expected message file rumor, got {other:?}"),
    466         }
    467     }
    468 }