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 }