Skip to main content

vector_core/community/
envelope.rs

1//! Message envelope (GROUP_PROTOCOL.md).
2//!
3//! A Community message is an inner Nostr event signed by the author's real key
4//! (the intra-group authorship proof), NIP-44-v2-encrypted under the shared channel
5//! key, and wrapped in an ephemeral-signed outer event tagged with the per-epoch
6//! pseudonym. Single NIP-44 pass, O(1) broadcast — not gift wrap's per-recipient
7//! double-wrap.
8//!
9//! `open_message` enforces the binding triad: inner Schnorr signature valid, and
10//! inner `kind`/`channel`/`epoch` equal to the outer kind and to the *specific*
11//! channel/epoch whose key decrypted the payload (strict equality, never a
12//! membership test). That defeats insider replay/splice across type, channel, or
13//! epoch — the threat that any member, holding the channel key, could otherwise lift
14//! another member's signed content into a different context.
15
16use nostr_sdk::prelude::*;
17
18use super::cipher;
19use super::derive::channel_pseudonym;
20use super::{ChannelId, ChannelKey, Epoch};
21use crate::stored_event::event_kind;
22
23/// Outer protocol-version tag value (forward-compat hook #2). Checked before any
24/// decryption so an unknown version is rejected gracefully.
25const 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/// Errors from sealing or opening a Community message envelope.
33#[derive(Debug)]
34pub enum EnvelopeError {
35    Sign(String),
36    Encrypt(String),
37    Decrypt(String),
38    InnerParse(String),
39    /// Outer `v` tag is absent or names a version we don't speak.
40    BadVersion(Option<String>),
41    KindMismatch { outer: u16, inner: u16 },
42    /// Inner channel id ≠ the channel whose key decrypted this (cross-channel splice).
43    ChannelMismatch,
44    /// Inner epoch ≠ the epoch whose key decrypted this (cross-epoch splice/replay).
45    EpochMismatch,
46    /// Inner author signature failed to verify.
47    BadSignature,
48    MissingTag(&'static str),
49    /// A binding tag appears more than once — the inner event is ambiguous, so the
50    /// wire form isn't deterministic. Any channel-key holder can craft the inner
51    /// event, so we reject rather than trust first-match.
52    DuplicateTag(&'static str),
53    /// The outer `z` pseudonym matches none of the member's held epoch keys (an epoch we were never a
54    /// recipient of, or a foreign channel). Not ours to read — dropped, not an error condition.
55    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/// A successfully opened and fully-verified Community message.
82#[derive(Debug, Clone)]
83pub struct OpenedMessage {
84    /// Inner event id — the `message_id`, the dedup/display key.
85    pub message_id: EventId,
86    /// Verified real author.
87    pub author: PublicKey,
88    pub content: String,
89    pub channel_id: ChannelId,
90    pub epoch: Epoch,
91    /// Ordering timestamp (epoch ms) via the SHARED `rumor::resolve_message_timestamp` (ms convention +
92    /// clamp). Kept here because the transport sorts fetched events by it before they become
93    /// `Message`s; reply-ref + emoji parsing, by contrast, is solely `process_rumor`'s job off `tags`.
94    pub ms: Option<u64>,
95    /// Inner event's real send time (NOT randomized).
96    pub created_at: Timestamp,
97    /// Append-plane sub-kind: 3300 message, 3301 reaction, 3302 edit.
98    pub kind: u16,
99    /// File attachments (one per NIP-92 `imeta` tag). A Community message can carry a
100    /// caption (`content`) plus N attachments in the same event — see `attachments` module.
101    pub attachments: Vec<crate::types::Attachment>,
102    /// The authority citation (`vac` tag), if the inner carried one — present on a non-owner
103    /// moderation-hide (3305) naming the grant the hider claims authority under. The hide consumer
104    /// (`apply_delete`) resolves it against the persisted grant head (version-pinned authority).
105    pub citation: Option<super::edition::AuthorityCitation>,
106    /// The OUTER wire event id (the relay-addressable event that carried this inner). The
107    /// transport's dedup key — persisted as the inner's `wrapper_event_id` so a re-fetched
108    /// channel page skips it pre-decryption, exactly as a DM gift-wrap id does.
109    pub wrapper_id: EventId,
110    /// The verified inner event's raw tags — handed to the SHARED `process_rumor` content parser so
111    /// reply/emoji/ms parsing is identical across transports (the binding tags were already checked).
112    pub tags: Tags,
113}
114
115/// Seal a plaintext message into an outer wire event. The outer event is signed by
116/// a fresh one-time key (no persistent author↔channel linkage on the wire).
117///
118/// This convenience form discards that key, so the resulting message is NOT
119/// self-deletable. **The product send path should use
120/// [`seal_message_with_ephemeral`] and RETAIN the key** (like Vector's
121/// `nip17_wrap_keys` for DMs) so the sender can later NIP-09-delete their own
122/// message. Ephemeral-on-the-wire and retained-locally are complementary: the
123/// relay still sees only one-time keys (no corpus-wide deletion authority), while
124/// the sender keeps a per-message key to delete just their own.
125pub 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
136/// Like [`seal_message`] but the caller supplies (and may retain) the ephemeral
137/// outer-signing key. Retaining it enables a later NIP-09 deletion of this exact
138/// outer event (the deletion must be signed by the same key — deletable
139/// messages), which is also how on-relay tests clean up after themselves.
140pub 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    // Local-keys convenience: build the inner event, sign it with the author's keys, and
150    // seal. Identical wire output to the signer path (golden-vector stable).
151    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
157/// Build the inner authorship-proof event UNSIGNED, so the caller can sign it with
158/// whatever the account uses — local keys OR a NIP-46 remote bunker (parity with the
159/// DM send path, which signs through the active `NostrSigner`). `author` is the
160/// identity pubkey the signer will sign as.
161///
162/// `ms` is the full send time in epoch-milliseconds, split the way Vector's DMs do:
163/// `created_at` carries the seconds, the `ms` tag carries only the sub-second offset
164/// (0..999). The open side reconstructs `created_at*1000 + ms`.
165pub 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
176/// Like [`build_inner_event`] but for any append-plane sub-type — a reaction (3301) or
177/// edit (3302) as well as a message (3300). The `reference` `e` tag points at the target:
178/// the replied-to message (3300), the reacted-to message (3301), or the edited message
179/// (3302). The inner kind is mirrored to the outer on seal, and the receiver enforces the
180/// binding triad (kind/channel/epoch).
181pub 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
194/// Like [`build_inner_typed`] but also carries a slice of EXTRA inner tags appended verbatim —
195/// used for NIP-92 `imeta` attachment tags (a 3300 message mixing a caption with N files, via
196/// `attachments::attachment_to_imeta`). They are added before signing, so the inner signature
197/// covers them; readers pick out what they need by exact tag name.
198pub 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    // Target reference: an `e` tag marked "reply" (Vector's DM convention) — the
217    // replied-to / reacted-to / edited message's inner id.
218    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    // NIP-30 custom emoji: ["emoji", shortcode, url] for each `:shortcode:` used in the
222    // content (so custom-emoji messages + reactions render the image, parity with DMs).
223    for et in emoji_tags {
224        tags.push(Tag::custom(TagKind::Custom("emoji".into()), [et.shortcode.clone(), et.url.clone()]));
225    }
226    // Extra inner tags appended verbatim (NIP-92 `imeta` attachment tags).
227    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
234/// Seal an already-signed inner authorship event into the outer wire event. The inner
235/// may have been signed by local keys or a remote bunker — this stage is signer-agnostic.
236/// Defensively re-checks the binding (kind/channel/epoch) so a caller can never seal
237/// an inner that the receiver would then reject as a splice.
238pub 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    // Only the community append-plane sub-kinds (message/reaction/edit + cooperative delete +
246    // presence + cooperative kick + webxdc peer signal + typing) may be sealed. Rekey (3303) and
247    // InviteBundle (3304) have their own carriers, so the contiguous 3300..=3302 range is admitted
248    // alongside the explicit 3305/3306/3309/3310/3311.
249    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    // Single NIP-44 v2 pass under the raw channel key.
272    let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes())
273        .map_err(EnvelopeError::Encrypt)?;
274
275    // Outer event: ephemeral signer (no author↔channel linkage on the wire), tagged
276    // with the per-epoch pseudonym (relay-filterable `z`) and the version. Outer kind
277    // mirrors the inner (the binding the receiver enforces).
278    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
291/// Open and fully verify an outer wire event, given the channel key + the exact
292/// channel/epoch coordinate that key belongs to.
293pub fn open_message(
294    outer: &Event,
295    channel_key: &ChannelKey,
296    channel_id: &ChannelId,
297    epoch: Epoch,
298) -> Result<OpenedMessage, EnvelopeError> {
299    // Version-check BEFORE attempting decryption (hook #2).
300    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 author signature (the authorship proof).
311    inner.verify().map_err(|_| EnvelopeError::BadSignature)?;
312
313    // Binding triad — type, channel, epoch.
314    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    // Reply-ref + emoji parsing is the SHARED parser's job (runs off `tags` in `process_rumor`), so it
330    // lives in ONE place. The transport keeps only: the ordering `ms` (used to sort fetched events
331    // before they're parsed — via the SAME shared resolver), NIP-92 `imeta` attachments, and the 
332    // authority citation.
333    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
357/// Open an outer wire event when the member may hold MULTIPLE epoch keys (post-rekey catch-up): select
358/// the decryption key by the outer's `z` pseudonym tag — each epoch addresses a distinct pseudonym we
359/// can recompute — then open under that exact epoch. `epoch_keys` is the member's retained `(epoch, key)`
360/// set for this channel. A `z` matching no held epoch yields [`EnvelopeError::NoHeldEpoch`] (not ours to
361/// read), keeping the per-event cost one pseudonym derivation per held epoch (a handful), no trial-decrypt.
362pub 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
376/// First value of the first tag named `name` (for outer routing tags like `v`,
377/// which the ephemeral signer controls and which carry no binding weight).
378fn 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
385/// Value of the tag named `name`, requiring it to appear AT MOST ONCE. The inner
386/// event is constructable by any channel-key holder, so a duplicated binding tag
387/// would make first-match nondeterministic — reject it.
388fn 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    /// Seal an arbitrary inner event into a correctly-tagged outer (epoch 0).
421    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        // Parity with DMs: the inner event can be signed through the async
451        // `NostrSigner` (the same path a NIP-46 bunker uses) instead of local keys.
452        // `Keys` implements `NostrSigner`, so signing the unsigned inner via `.sign()`
453        // exercises that path; the sealed result must open identically.
454        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        // Retained ephemeral is the outer signer (so it's still self-deletable).
466        assert_eq!(outer.pubkey, ephemeral.public_key());
467    }
468
469    #[test]
470    fn reply_reference_round_trips() {
471        // A reply target (inner `e` tag) survives seal→open and surfaces on the parsed Message via the
472        // shared parser (reply parsing now lives in `process_rumor`, exercised end-to-end via build_message).
473        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        // No reply target → replied_to is empty.
484        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        // NIP-30 `["emoji", shortcode, url]` tags survive seal→open and surface on the parsed Message
492        // (shared parser), so custom-emoji messages render the image — parity with DMs.
493        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        // W3: the inner created_at isn't relay-clamped (only the outer is published), so a
515        // hostile member could stamp it absurdly far in the future to pin the message to
516        // the top forever. open_message clamps an implausible ms back to receipt time.
517        let author = Keys::generate();
518        let far_future = 99_999_999_999_999u64; // ~year 5138 in epoch-ms
519        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        // A message's reply pointer is cosmetic, so a duplicate `e` no longer drops the whole message —
527        // the content is preserved (open succeeds). The security-critical disambiguation moved to the
528        // SHARED parser, which rejects an ambiguous TARGET for reactions/edits/deletes
529        // (see `rumor::unique_event_ref`); that's covered by the rumor-level tests.
530        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        // The protocol's multi-attachment capability: ONE event carries a caption
549        // (content) plus N attachments (one imeta each), optionally replying. Verify the
550        // full seal → open path reconstructs the caption, the reply target, and every
551        // attachment's crypto + metadata in order.
552        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        // Reply ref is parsed by the shared parser; the built Message carries it. Attachments are the
587        // transport-specific imeta, still parsed at open.
588        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        // A caption-only message (no imeta) opens with zero attachments.
600        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        // seal_with_signed_inner must refuse an inner whose binding doesn't match the
607        // coordinate being sealed under (can't produce an unopenable/spliced message).
608        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        // Full epoch-ms in → created_at(secs) + ms-offset(0..999) on the wire →
620        // exact full ms back out (lossless, and matches Vector's DM convention).
621        let author = Keys::generate();
622        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap();
623        // On the wire: created_at is seconds, the ms tag is only the 0..999 offset.
624        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    /// Decrypt + read the inner event's created_at (test helper).
631    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        // The wire event must NOT be signed by the author's real key (no linkage).
640        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        // ...but the recovered author (inner) IS the real key.
648        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        // Decrypt succeeds under a key we hold, but the inner channel tag names a
672        // different channel than the key's channel → strict-equality check fires.
673        let author = Keys::generate();
674        // Seal for channel A.
675        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap();
676        // Attempt to open as if it belonged to a DIFFERENT channel B, using the SAME
677        // key (simulating a member who re-published it under B's coordinate).
678        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        // Flip a byte of the base64 payload → NIP-44 MAC must fail on decrypt.
694        let author = Keys::generate();
695        let mut outer =
696            seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap();
697        // Rebuild an outer event with a corrupted content (events are immutable, so
698        // re-sign a mutated copy with a fresh ephemeral key).
699        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        // The e2e seed: Alice and Bob hold the same channel key; each can open the
738        // other's sealed message and recover the correct real author.
739        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        // message_ids differ (distinct inner events).
754        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        // Hand-build an outer event with a bogus version tag; must reject on version,
760        // never reaching decryption.
761        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        // A signed REACTION inner re-wrapped inside a MESSAGE outer → type splice.
778        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        // Tamper the inner content after signing: id/sig no longer verify.
794        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        // Inner has a valid sig + matching kind + epoch, but no channel tag.
820        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        // Two channel tags → ambiguous inner; must reject (don't trust first-match).
833        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        // Bogus version AND wrong key: must fail on version, NOT decrypt. This proves
846        // the ordering — a regression moving the version check after decrypt would
847        // instead surface a Decrypt error here (wrong key), so this catches it where
848        // the correct-key version test cannot.
849        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        // Distinct from the bit-flip test: chop bytes off the payload → length/MAC fail.
867        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        // The binding triad requires inner.kind == outer.kind; seal must produce that.
888        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        // Decrypt the inner and confirm its kind mirrors the outer.
892        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        // the relay-filterable `z` tag must carry the correct epoch pseudonym,
900        // and `v` must be "1" — a regression here silently breaks relay querying.
901        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}