1use crate::event_ext::FinalizeUnsignedWithId;
17use nostr_sdk::prelude::*;
18
19use super::cipher;
20use super::derive::channel_pseudonym;
21use super::{ChannelId, ChannelKey, Epoch};
22use crate::stored_event::event_kind;
23
24const PROTOCOL_VERSION: &str = "1";
27
28const TAG_VERSION: &str = "v";
29const TAG_CHANNEL: &str = "channel";
30const TAG_EPOCH: &str = "epoch";
31const TAG_MS: &str = "ms";
32
33#[derive(Debug)]
35pub enum EnvelopeError {
36 Sign(String),
37 Encrypt(String),
38 Decrypt(String),
39 InnerParse(String),
40 BadVersion(Option<String>),
42 KindMismatch { outer: u16, inner: u16 },
43 ChannelMismatch,
45 EpochMismatch,
47 BadSignature,
49 MissingTag(&'static str),
50 DuplicateTag(&'static str),
54 NoHeldEpoch,
57}
58
59impl std::fmt::Display for EnvelopeError {
60 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61 match self {
62 EnvelopeError::Sign(e) => write!(f, "sign: {e}"),
63 EnvelopeError::Encrypt(e) => write!(f, "encrypt: {e}"),
64 EnvelopeError::Decrypt(e) => write!(f, "decrypt: {e}"),
65 EnvelopeError::InnerParse(e) => write!(f, "inner parse: {e}"),
66 EnvelopeError::BadVersion(v) => write!(f, "unsupported protocol version: {v:?}"),
67 EnvelopeError::KindMismatch { outer, inner } => {
68 write!(f, "kind mismatch: outer {outer} != inner {inner}")
69 }
70 EnvelopeError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"),
71 EnvelopeError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"),
72 EnvelopeError::BadSignature => write!(f, "inner author signature invalid"),
73 EnvelopeError::MissingTag(t) => write!(f, "missing inner tag: {t}"),
74 EnvelopeError::DuplicateTag(t) => write!(f, "duplicate inner tag: {t}"),
75 EnvelopeError::NoHeldEpoch => write!(f, "no held epoch key for this pseudonym"),
76 }
77 }
78}
79
80impl std::error::Error for EnvelopeError {}
81
82#[derive(Debug, Clone)]
84pub struct OpenedMessage {
85 pub message_id: EventId,
87 pub author: PublicKey,
89 pub content: String,
90 pub channel_id: ChannelId,
91 pub epoch: Epoch,
92 pub ms: Option<u64>,
96 pub created_at: Timestamp,
98 pub kind: u16,
100 pub attachments: Vec<crate::types::Attachment>,
103 pub citation: Option<super::edition::AuthorityCitation>,
107 pub wrapper_id: EventId,
111 pub tags: Tags,
114}
115
116pub fn seal_message(
127 author_keys: &Keys,
128 channel_key: &ChannelKey,
129 channel_id: &ChannelId,
130 epoch: Epoch,
131 content: &str,
132 ms: u64,
133) -> Result<Event, EnvelopeError> {
134 seal_message_with_ephemeral(&Keys::generate(), author_keys, channel_key, channel_id, epoch, content, ms)
135}
136
137pub fn seal_message_with_ephemeral(
142 ephemeral: &Keys,
143 author_keys: &Keys,
144 channel_key: &ChannelKey,
145 channel_id: &ChannelId,
146 epoch: Epoch,
147 content: &str,
148 ms: u64,
149) -> Result<Event, EnvelopeError> {
150 let inner = build_inner_event(author_keys.public_key(), channel_id, epoch, content, ms, None)
153 .finalize(author_keys)
154 .map_err(|e| EnvelopeError::Sign(e.to_string()))?;
155 seal_with_signed_inner(ephemeral, &inner, channel_key, channel_id, epoch)
156}
157
158pub fn build_inner_event(
167 author: PublicKey,
168 channel_id: &ChannelId,
169 epoch: Epoch,
170 content: &str,
171 ms: u64,
172 reply_to: Option<&str>,
173) -> UnsignedEvent {
174 build_inner_typed(author, channel_id, epoch, event_kind::COMMUNITY_MESSAGE, content, ms, reply_to, &[])
175}
176
177#[allow(clippy::too_many_arguments)]
183pub fn build_inner_typed(
184 author: PublicKey,
185 channel_id: &ChannelId,
186 epoch: Epoch,
187 kind: u16,
188 content: &str,
189 ms: u64,
190 reference: Option<&str>,
191 emoji_tags: &[crate::types::EmojiTag],
192) -> UnsignedEvent {
193 build_inner_full(author, channel_id, epoch, kind, content, ms, reference, emoji_tags, &[])
194}
195
196#[allow(clippy::too_many_arguments)]
201pub fn build_inner_full(
202 author: PublicKey,
203 channel_id: &ChannelId,
204 epoch: Epoch,
205 kind: u16,
206 content: &str,
207 ms: u64,
208 reference: Option<&str>,
209 emoji_tags: &[crate::types::EmojiTag],
210 extra_tags: &[Tag],
211) -> UnsignedEvent {
212 let created_secs = ms / 1000;
213 let ms_offset = ms % 1000;
214 let mut tags = vec![
215 Tag::custom(TAG_CHANNEL, [channel_id.to_hex()]),
216 Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
217 Tag::custom(TAG_MS, [ms_offset.to_string()]),
218 ];
219 if let Some(target) = reference.filter(|t| !t.is_empty()) {
222 tags.push(Tag::custom("e", [target.to_string(), String::new(), "reply".to_string()]));
223 }
224 for et in emoji_tags {
227 tags.push(Tag::custom("emoji", [et.shortcode.clone(), et.url.clone()]));
228 }
229 tags.extend(extra_tags.iter().cloned());
231 EventBuilder::new(Kind::Custom(kind), content)
232 .tags(tags)
233 .custom_created_at(Timestamp::from_secs(created_secs))
234 .finalize_unsigned_with_id(author)
235}
236
237pub fn seal_with_signed_inner(
242 ephemeral: &Keys,
243 inner: &Event,
244 channel_key: &ChannelKey,
245 channel_id: &ChannelId,
246 epoch: Epoch,
247) -> Result<Event, EnvelopeError> {
248 let inner_kind = inner.kind.as_u16();
253 let allowed = (event_kind::COMMUNITY_MESSAGE..=event_kind::COMMUNITY_EDIT).contains(&inner_kind)
254 || inner_kind == event_kind::COMMUNITY_DELETE
255 || inner_kind == event_kind::COMMUNITY_PRESENCE
256 || inner_kind == event_kind::COMMUNITY_KICK
257 || inner_kind == event_kind::COMMUNITY_WEBXDC
258 || inner_kind == event_kind::COMMUNITY_TYPING;
259 if !allowed {
260 return Err(EnvelopeError::KindMismatch {
261 outer: event_kind::COMMUNITY_MESSAGE,
262 inner: inner_kind,
263 });
264 }
265 match unique_tag(inner, TAG_CHANNEL)? {
266 Some(c) if c == channel_id.to_hex() => {}
267 _ => return Err(EnvelopeError::ChannelMismatch),
268 }
269 match unique_tag(inner, TAG_EPOCH)? {
270 Some(e) if e == epoch.0.to_string() => {}
271 _ => return Err(EnvelopeError::EpochMismatch),
272 }
273
274 let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes())
276 .map_err(EnvelopeError::Encrypt)?;
277
278 let pseudonym = channel_pseudonym(channel_key, channel_id, epoch);
282 EventBuilder::new(Kind::Custom(inner_kind), content_b64)
283 .tags([
284 Tag::custom(
285 "z",
286 [pseudonym.to_hex()],
287 ),
288 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
289 ])
290 .finalize(ephemeral)
291 .map_err(|e| EnvelopeError::Sign(e.to_string()))
292}
293
294pub fn open_message(
297 outer: &Event,
298 channel_key: &ChannelKey,
299 channel_id: &ChannelId,
300 epoch: Epoch,
301) -> Result<OpenedMessage, EnvelopeError> {
302 match find_tag(outer, TAG_VERSION).as_deref() {
304 Some(PROTOCOL_VERSION) => {}
305 other => return Err(EnvelopeError::BadVersion(other.map(str::to_string))),
306 }
307
308 let plaintext = cipher::open(channel_key.as_bytes(), &outer.content)
309 .map_err(EnvelopeError::Decrypt)?;
310 let json = String::from_utf8(plaintext).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
311 let inner = Event::from_json(&json).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
312
313 inner.verify().map_err(|_| EnvelopeError::BadSignature)?;
315
316 if inner.kind.as_u16() != outer.kind.as_u16() {
318 return Err(EnvelopeError::KindMismatch {
319 outer: outer.kind.as_u16(),
320 inner: inner.kind.as_u16(),
321 });
322 }
323 let inner_channel = unique_tag(&inner, TAG_CHANNEL)?.ok_or(EnvelopeError::MissingTag(TAG_CHANNEL))?;
324 if inner_channel != channel_id.to_hex() {
325 return Err(EnvelopeError::ChannelMismatch);
326 }
327 let inner_epoch = unique_tag(&inner, TAG_EPOCH)?.ok_or(EnvelopeError::MissingTag(TAG_EPOCH))?;
328 if inner_epoch != epoch.0.to_string() {
329 return Err(EnvelopeError::EpochMismatch);
330 }
331
332 let ms = Some(crate::rumor::resolve_message_timestamp(
337 inner.created_at.as_secs(),
338 unique_tag(&inner, TAG_MS)?.as_deref(),
339 ));
340 let attachments = super::attachments::attachments_from_tags(
341 inner.tags.iter(),
342 &crate::db::get_download_dir(),
343 );
344 Ok(OpenedMessage {
345 message_id: inner.id,
346 author: inner.pubkey,
347 content: inner.content.clone(),
348 channel_id: *channel_id,
349 epoch,
350 ms,
351 created_at: inner.created_at,
352 kind: inner.kind.as_u16(),
353 attachments,
354 citation: super::edition::AuthorityCitation::from_tags(&inner.tags),
355 wrapper_id: outer.id,
356 tags: inner.tags.clone(),
357 })
358}
359
360pub fn open_message_multi(
366 outer: &Event,
367 channel_id: &ChannelId,
368 epoch_keys: &[(Epoch, ChannelKey)],
369) -> Result<OpenedMessage, EnvelopeError> {
370 let z = find_tag(outer, "z").ok_or(EnvelopeError::MissingTag("z"))?;
371 for (epoch, key) in epoch_keys {
372 if channel_pseudonym(key, channel_id, *epoch).to_hex() == z {
373 return open_message(outer, key, channel_id, *epoch);
374 }
375 }
376 Err(EnvelopeError::NoHeldEpoch)
377}
378
379fn find_tag(event: &Event, name: &str) -> Option<String> {
382 event.tags.iter().find_map(|t| {
383 let s = t.as_slice();
384 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
385 })
386}
387
388fn unique_tag(event: &Event, name: &'static str) -> Result<Option<String>, EnvelopeError> {
392 let mut found: Option<String> = None;
393 for t in event.tags.iter() {
394 let s = t.as_slice();
395 if s.len() >= 2 && s[0] == name {
396 if found.is_some() {
397 return Err(EnvelopeError::DuplicateTag(name));
398 }
399 found = Some(s[1].clone());
400 }
401 }
402 Ok(found)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn key() -> ChannelKey {
410 ChannelKey([0x11u8; 32])
411 }
412 fn chan() -> ChannelId {
413 ChannelId([0xaau8; 32])
414 }
415
416 fn channel_tag() -> Tag {
417 Tag::custom(TAG_CHANNEL, [chan().to_hex()])
418 }
419 fn epoch0_tag() -> Tag {
420 Tag::custom(TAG_EPOCH, ["0".to_string()])
421 }
422
423 fn wrap_inner(inner: &Event) -> Event {
425 let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
426 EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
427 .tags([
428 Tag::custom(
429 "z",
430 [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
431 ),
432 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
433 ])
434 .finalize(&Keys::generate())
435 .unwrap()
436 }
437
438 #[test]
439 fn round_trip() {
440 let author = Keys::generate();
441 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "gm fren", 1_700_000_000_000)
442 .expect("seal");
443 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
444 assert_eq!(opened.content, "gm fren");
445 assert_eq!(opened.author, author.public_key());
446 assert_eq!(opened.ms, Some(1_700_000_000_000));
447 assert_eq!(opened.channel_id, chan());
448 assert_eq!(opened.epoch, Epoch(0));
449 }
450
451 #[tokio::test]
452 async fn signer_path_matches_local_keys_path() {
453 let author = Keys::generate();
458 let ephemeral = Keys::generate();
459
460 let unsigned = build_inner_event(author.public_key(), &chan(), Epoch(0), "via signer", 1_700_000_000_777, None);
461 let inner: Event = unsigned.finalize_async(&author).await.expect("remote-style sign");
462 let outer = seal_with_signed_inner(&ephemeral, &inner, &key(), &chan(), Epoch(0)).expect("seal");
463
464 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
465 assert_eq!(opened.content, "via signer");
466 assert_eq!(opened.author, author.public_key(), "authorship is the identity key, not ephemeral");
467 assert_eq!(opened.ms, Some(1_700_000_000_777));
468 assert_eq!(outer.pubkey, ephemeral.public_key());
470 }
471
472 #[test]
473 fn reply_reference_round_trips() {
474 let author = Keys::generate();
477 let target = "a".repeat(64);
478 let inner = build_inner_event(author.public_key(), &chan(), Epoch(0), "re: hi", 5, Some(&target))
479 .finalize(&author)
480 .unwrap();
481 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
482 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
483 let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
484 assert_eq!(msg.replied_to, target);
485
486 let plain = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 6).unwrap();
488 let opened2 = open_message(&plain, &key(), &chan(), Epoch(0)).unwrap();
489 assert!(crate::community::inbound::build_message(&opened2, &Keys::generate().public_key()).replied_to.is_empty());
490 }
491
492 #[test]
493 fn custom_emoji_tags_round_trip() {
494 let author = Keys::generate();
497 let tags = vec![crate::types::EmojiTag {
498 shortcode: "fire".into(),
499 url: "https://blossom/fire.png".into(),
500 }];
501 let inner = build_inner_typed(
502 author.public_key(), &chan(), Epoch(0),
503 crate::stored_event::event_kind::COMMUNITY_MESSAGE, "gm :fire:", 1, None, &tags,
504 )
505 .finalize(&author)
506 .unwrap();
507 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
508 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
509 let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
510 assert_eq!(msg.emoji_tags.len(), 1);
511 assert_eq!(msg.emoji_tags[0].shortcode, "fire");
512 assert_eq!(msg.emoji_tags[0].url, "https://blossom/fire.png");
513 }
514
515 #[test]
516 fn far_future_inner_timestamp_is_clamped() {
517 let author = Keys::generate();
521 let far_future = 99_999_999_999_999u64; let outer = seal_message(&author, &key(), &chan(), Epoch(0), "from the future", far_future).unwrap();
523 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
524 assert!(opened.ms.unwrap() < far_future, "far-future ordering ms must be clamped to ~now");
525 }
526
527 #[test]
528 fn duplicate_reply_tag_on_a_message_is_tolerated() {
529 let author = Keys::generate();
534 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
535 .tags([
536 Tag::custom(TAG_CHANNEL, [chan().to_hex()]),
537 Tag::custom(TAG_EPOCH, ["0".to_string()]),
538 Tag::custom(TAG_MS, ["1".to_string()]),
539 Tag::custom("e", ["aa".repeat(32), String::new(), "reply".to_string()]),
540 Tag::custom("e", ["bb".repeat(32), String::new(), "reply".to_string()]),
541 ])
542 .finalize(&author)
543 .unwrap();
544 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
545 assert!(open_message(&outer, &key(), &chan(), Epoch(0)).is_ok(),
546 "a message must not be dropped over an ambiguous (cosmetic) reply pointer");
547 }
548
549 #[test]
550 fn multi_attachment_message_round_trips_caption_and_imeta() {
551 use crate::types::{Attachment, ImageMetadata};
556 let mk = |name: &str, ext: &str, img: bool| Attachment {
557 id: "x".into(),
558 key: "0".repeat(64),
559 nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(name.as_bytes())),
560 extension: ext.into(),
561 name: name.into(),
562 url: format!("https://blossom.example/{name}"),
563 path: String::new(),
564 size: 1234,
565 img_meta: img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 64, height: 48 }),
566 downloading: false,
567 downloaded: false,
568 webxdc_topic: None,
569 group_id: None,
570 original_hash: Some("a".repeat(64)),
571 fallback_urls: Vec::new(),
572 };
573 let imetas = vec![
574 super::super::attachments::attachment_to_imeta(&mk("photo.png", "png", true)),
575 super::super::attachments::attachment_to_imeta(&mk("notes.pdf", "pdf", false)),
576 ];
577 let author = Keys::generate();
578 let reply_target = "bb".repeat(32);
579 let inner = build_inner_full(
580 author.public_key(), &chan(), Epoch(0),
581 event_kind::COMMUNITY_MESSAGE, "look at these", 1_700_000_000_123,
582 Some(&reply_target), &[], &imetas,
583 ).finalize(&author).unwrap();
584 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
585
586 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
587 assert_eq!(opened.content, "look at these");
588 let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
591 assert_eq!(msg.replied_to, reply_target);
592 assert_eq!(opened.attachments.len(), 2, "both attachments parse");
593 assert_eq!(opened.attachments[0].name, "photo.png");
594 assert_eq!(opened.attachments[0].key, "0".repeat(64));
595 assert_eq!(opened.attachments[0].extension, "png");
596 assert!(opened.attachments[0].img_meta.is_some(), "image carries thumbhash/dim");
597 assert!(opened.attachments[0].group_id.is_none(), "Community attachment uses explicit key/nonce");
598 assert_eq!(opened.attachments[1].name, "notes.pdf");
599 assert_eq!(opened.attachments[1].extension, "pdf");
600 assert!(opened.attachments[1].img_meta.is_none());
601 let plain = seal_message(&author, &key(), &chan(), Epoch(0), "just text", 1).unwrap();
603 assert!(open_message(&plain, &key(), &chan(), Epoch(0)).unwrap().attachments.is_empty());
604 }
605
606 #[test]
607 fn seal_rejects_inner_bound_to_wrong_channel() {
608 let author = Keys::generate();
611 let other = ChannelId([0xbbu8; 32]);
612 let inner = build_inner_event(author.public_key(), &other, Epoch(0), "x", 1, None)
613 .finalize(&author)
614 .unwrap();
615 let err = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0));
616 assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}");
617 }
618
619 #[test]
620 fn ms_splits_to_created_at_and_offset_and_reconstructs() {
621 let author = Keys::generate();
624 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap();
625 assert_eq!(outer_inner_created_at_secs(&outer), 1234);
627 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
628 assert_eq!(opened.ms, Some(1_234_567), "full ms reconstructed from secs*1000 + offset");
629 assert_eq!(opened.created_at.as_secs(), 1234);
630 }
631
632 fn outer_inner_created_at_secs(outer: &Event) -> u64 {
634 let pt = cipher::open(key().as_bytes(), &outer.content).unwrap();
635 let inner = Event::from_json(&String::from_utf8(pt).unwrap()).unwrap();
636 inner.created_at.as_secs()
637 }
638
639 #[test]
640 fn outer_signer_is_ephemeral_not_author() {
641 let author = Keys::generate();
643 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 1).unwrap();
644 assert_ne!(
645 outer.pubkey,
646 author.public_key(),
647 "outer event must be ephemeral-signed, not author-signed"
648 );
649 let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
651 assert_eq!(opened.author, author.public_key());
652 }
653
654 #[test]
655 fn identical_plaintext_yields_distinct_ciphertext() {
656 let author = Keys::generate();
657 let a = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap();
658 let b = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap();
659 assert_ne!(a.content, b.content, "per-message nonce must randomize ciphertext");
660 }
661
662 #[test]
663 fn wrong_key_is_rejected() {
664 let author = Keys::generate();
665 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "secret", 1).unwrap();
666 let wrong = ChannelKey([0x22u8; 32]);
667 let err = open_message(&outer, &wrong, &chan(), Epoch(0));
668 assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}");
669 }
670
671 #[test]
672 fn cross_channel_splice_is_rejected() {
673 let author = Keys::generate();
676 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap();
678 let chan_b = ChannelId([0xbbu8; 32]);
681 let err = open_message(&outer, &key(), &chan_b, Epoch(0));
682 assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}");
683 }
684
685 #[test]
686 fn cross_epoch_splice_is_rejected() {
687 let author = Keys::generate();
688 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "epoch 0", 1).unwrap();
689 let err = open_message(&outer, &key(), &chan(), Epoch(1));
690 assert!(matches!(err, Err(EnvelopeError::EpochMismatch)), "got {err:?}");
691 }
692
693 #[test]
694 fn tampered_ciphertext_is_rejected() {
695 let author = Keys::generate();
697 let mut outer =
698 seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap();
699 let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap();
702 let mid = bytes.len() / 2;
703 bytes[mid] ^= 0xff;
704 let corrupted = base64_simd::STANDARD.encode_to_string(&bytes);
705 let ephemeral = Keys::generate();
706 outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), corrupted)
707 .tags([
708 Tag::custom(
709 "z",
710 [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
711 ),
712 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
713 ])
714 .finalize(&ephemeral)
715 .unwrap();
716 let err = open_message(&outer, &key(), &chan(), Epoch(0));
717 assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}");
718 }
719
720 #[test]
721 fn missing_version_tag_is_rejected() {
722 let author = Keys::generate();
723 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
724 .finalize(&author)
725 .unwrap();
726 let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
727 let ephemeral = Keys::generate();
728 let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
729 .finalize(&ephemeral)
730 .unwrap();
731 assert!(matches!(
732 open_message(&outer, &key(), &chan(), Epoch(0)),
733 Err(EnvelopeError::BadVersion(None))
734 ));
735 }
736
737 #[test]
738 fn two_members_exchange() {
739 let alice = Keys::generate();
742 let bob = Keys::generate();
743 let shared = key();
744
745 let from_alice = seal_message(&alice, &shared, &chan(), Epoch(0), "yo bob", 10).unwrap();
746 let seen_by_bob = open_message(&from_alice, &shared, &chan(), Epoch(0)).unwrap();
747 assert_eq!(seen_by_bob.author, alice.public_key());
748 assert_eq!(seen_by_bob.content, "yo bob");
749
750 let from_bob = seal_message(&bob, &shared, &chan(), Epoch(0), "hey alice", 11).unwrap();
751 let seen_by_alice = open_message(&from_bob, &shared, &chan(), Epoch(0)).unwrap();
752 assert_eq!(seen_by_alice.author, bob.public_key());
753 assert_eq!(seen_by_alice.content, "hey alice");
754
755 assert_ne!(seen_by_bob.message_id, seen_by_alice.message_id);
757 }
758
759 #[test]
760 fn unknown_version_is_rejected_before_decrypt() {
761 let author = Keys::generate();
764 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
765 .finalize(&author)
766 .unwrap();
767 let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
768 let ephemeral = Keys::generate();
769 let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
770 .tags([Tag::custom(TAG_VERSION, ["999".to_string()])])
771 .finalize(&ephemeral)
772 .unwrap();
773 let err = open_message(&outer, &key(), &chan(), Epoch(0));
774 assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "999"), "got {err:?}");
775 }
776
777 #[test]
778 fn inner_kind_mismatch_is_rejected() {
779 let author = Keys::generate();
781 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REACTION), "+")
782 .tags([channel_tag(), epoch0_tag()])
783 .finalize(&author)
784 .unwrap();
785 let outer = wrap_inner(&inner);
786 let err = open_message(&outer, &key(), &chan(), Epoch(0));
787 assert!(
788 matches!(err, Err(EnvelopeError::KindMismatch { outer: 3300, inner: 3301 })),
789 "got {err:?}"
790 );
791 }
792
793 #[test]
794 fn forged_inner_signature_is_rejected() {
795 let author = Keys::generate();
797 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "real")
798 .tags([channel_tag(), epoch0_tag()])
799 .finalize(&author)
800 .unwrap();
801 let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
802 v["content"] = serde_json::Value::String("forged".into());
803 let tampered = serde_json::to_string(&v).unwrap();
804 let content = cipher::seal(key().as_bytes(), tampered.as_bytes()).unwrap();
805 let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
806 .tags([
807 Tag::custom(
808 "z",
809 [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
810 ),
811 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
812 ])
813 .finalize(&Keys::generate())
814 .unwrap();
815 let err = open_message(&outer, &key(), &chan(), Epoch(0));
816 assert!(matches!(err, Err(EnvelopeError::BadSignature)), "got {err:?}");
817 }
818
819 #[test]
820 fn missing_channel_tag_is_rejected() {
821 let author = Keys::generate();
823 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi")
824 .tags([epoch0_tag()])
825 .finalize(&author)
826 .unwrap();
827 let outer = wrap_inner(&inner);
828 let err = open_message(&outer, &key(), &chan(), Epoch(0));
829 assert!(matches!(err, Err(EnvelopeError::MissingTag(t)) if t == TAG_CHANNEL), "got {err:?}");
830 }
831
832 #[test]
833 fn duplicate_channel_tag_is_rejected() {
834 let author = Keys::generate();
836 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi")
837 .tags([channel_tag(), channel_tag(), epoch0_tag()])
838 .finalize(&author)
839 .unwrap();
840 let outer = wrap_inner(&inner);
841 let err = open_message(&outer, &key(), &chan(), Epoch(0));
842 assert!(matches!(err, Err(EnvelopeError::DuplicateTag(t)) if t == TAG_CHANNEL), "got {err:?}");
843 }
844
845 #[test]
846 fn version_is_checked_before_decryption() {
847 let author = Keys::generate();
852 let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
853 .tags([channel_tag(), epoch0_tag()])
854 .finalize(&author)
855 .unwrap();
856 let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
857 let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
858 .tags([Tag::custom(TAG_VERSION, ["7".to_string()])])
859 .finalize(&Keys::generate())
860 .unwrap();
861 let wrong_key = ChannelKey([0x99u8; 32]);
862 let err = open_message(&outer, &wrong_key, &chan(), Epoch(0));
863 assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "7"), "got {err:?}");
864 }
865
866 #[test]
867 fn truncated_ciphertext_is_rejected() {
868 let author = Keys::generate();
870 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "intact", 1).unwrap();
871 let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap();
872 bytes.truncate(bytes.len().saturating_sub(5));
873 let truncated = base64_simd::STANDARD.encode_to_string(&bytes);
874 let mangled = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), truncated)
875 .tags([
876 Tag::custom(
877 "z",
878 [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
879 ),
880 Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
881 ])
882 .finalize(&Keys::generate())
883 .unwrap();
884 assert!(matches!(open_message(&mangled, &key(), &chan(), Epoch(0)), Err(EnvelopeError::Decrypt(_))));
885 }
886
887 #[test]
888 fn seal_uses_matching_inner_and_outer_kind() {
889 let author = Keys::generate();
891 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap();
892 assert_eq!(outer.kind.as_u16(), event_kind::COMMUNITY_MESSAGE);
893 let plaintext = cipher::open(key().as_bytes(), &outer.content).unwrap();
895 let inner = Event::from_json(&String::from_utf8(plaintext).unwrap()).unwrap();
896 assert_eq!(inner.kind.as_u16(), outer.kind.as_u16());
897 }
898
899 #[test]
900 fn seal_tags_outer_with_correct_pseudonym_and_version() {
901 let author = Keys::generate();
904 let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap();
905 let expected = super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex();
906 assert_eq!(find_tag(&outer, "z").as_deref(), Some(expected.as_str()));
907 assert_eq!(find_tag(&outer, TAG_VERSION).as_deref(), Some("1"));
908 }
909}