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