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 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
24/// Outer protocol-version tag value (forward-compat hook #2). Checked before any
25/// decryption so an unknown version is rejected gracefully.
26const 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/// Errors from sealing or opening a Community message envelope.
34#[derive(Debug)]
35pub enum EnvelopeError {
36    Sign(String),
37    Encrypt(String),
38    Decrypt(String),
39    InnerParse(String),
40    /// Outer `v` tag is absent or names a version we don't speak.
41    BadVersion(Option<String>),
42    KindMismatch { outer: u16, inner: u16 },
43    /// Inner channel id ≠ the channel whose key decrypted this (cross-channel splice).
44    ChannelMismatch,
45    /// Inner epoch ≠ the epoch whose key decrypted this (cross-epoch splice/replay).
46    EpochMismatch,
47    /// Inner author signature failed to verify.
48    BadSignature,
49    MissingTag(&'static str),
50    /// A binding tag appears more than once — the inner event is ambiguous, so the
51    /// wire form isn't deterministic. Any channel-key holder can craft the inner
52    /// event, so we reject rather than trust first-match.
53    DuplicateTag(&'static str),
54    /// The outer `z` pseudonym matches none of the member's held epoch keys (an epoch we were never a
55    /// recipient of, or a foreign channel). Not ours to read — dropped, not an error condition.
56    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/// A successfully opened and fully-verified Community message.
83#[derive(Debug, Clone)]
84pub struct OpenedMessage {
85    /// Inner event id — the `message_id`, the dedup/display key.
86    pub message_id: EventId,
87    /// Verified real author.
88    pub author: PublicKey,
89    pub content: String,
90    pub channel_id: ChannelId,
91    pub epoch: Epoch,
92    /// Ordering timestamp (epoch ms) via the SHARED `rumor::resolve_message_timestamp` (ms convention +
93    /// clamp). Kept here because the transport sorts fetched events by it before they become
94    /// `Message`s; reply-ref + emoji parsing, by contrast, is solely `process_rumor`'s job off `tags`.
95    pub ms: Option<u64>,
96    /// Inner event's real send time (NOT randomized).
97    pub created_at: Timestamp,
98    /// Append-plane sub-kind: 3300 message, 3301 reaction, 3302 edit.
99    pub kind: u16,
100    /// File attachments (one per NIP-92 `imeta` tag). A Community message can carry a
101    /// caption (`content`) plus N attachments in the same event — see `attachments` module.
102    pub attachments: Vec<crate::types::Attachment>,
103    /// The authority citation (`vac` tag), if the inner carried one — present on a non-owner
104    /// moderation-hide (3305) naming the grant the hider claims authority under. The hide consumer
105    /// (`apply_delete`) resolves it against the persisted grant head (version-pinned authority).
106    pub citation: Option<super::edition::AuthorityCitation>,
107    /// The OUTER wire event id (the relay-addressable event that carried this inner). The
108    /// transport's dedup key — persisted as the inner's `wrapper_event_id` so a re-fetched
109    /// channel page skips it pre-decryption, exactly as a DM gift-wrap id does.
110    pub wrapper_id: EventId,
111    /// The verified inner event's raw tags — handed to the SHARED `process_rumor` content parser so
112    /// reply/emoji/ms parsing is identical across transports (the binding tags were already checked).
113    pub tags: Tags,
114}
115
116/// Seal a plaintext message into an outer wire event. The outer event is signed by
117/// a fresh one-time key (no persistent author↔channel linkage on the wire).
118///
119/// This convenience form discards that key, so the resulting message is NOT
120/// self-deletable. **The product send path should use
121/// [`seal_message_with_ephemeral`] and RETAIN the key** (like Vector's
122/// `nip17_wrap_keys` for DMs) so the sender can later NIP-09-delete their own
123/// message. Ephemeral-on-the-wire and retained-locally are complementary: the
124/// relay still sees only one-time keys (no corpus-wide deletion authority), while
125/// the sender keeps a per-message key to delete just their own.
126pub 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
137/// Like [`seal_message`] but the caller supplies (and may retain) the ephemeral
138/// outer-signing key. Retaining it enables a later NIP-09 deletion of this exact
139/// outer event (the deletion must be signed by the same key — deletable
140/// messages), which is also how on-relay tests clean up after themselves.
141pub 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    // Local-keys convenience: build the inner event, sign it with the author's keys, and
151    // seal. Identical wire output to the signer path (golden-vector stable).
152    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
158/// Build the inner authorship-proof event UNSIGNED, so the caller can sign it with
159/// whatever the account uses — local keys OR a NIP-46 remote bunker (parity with the
160/// DM send path, which signs through the active `VectorSigner`). `author` is the
161/// identity pubkey the signer will sign as.
162///
163/// `ms` is the full send time in epoch-milliseconds, split the way Vector's DMs do:
164/// `created_at` carries the seconds, the `ms` tag carries only the sub-second offset
165/// (0..999). The open side reconstructs `created_at*1000 + ms`.
166pub 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/// Like [`build_inner_event`] but for any append-plane sub-type — a reaction (3301) or
178/// edit (3302) as well as a message (3300). The `reference` `e` tag points at the target:
179/// the replied-to message (3300), the reacted-to message (3301), or the edited message
180/// (3302). The inner kind is mirrored to the outer on seal, and the receiver enforces the
181/// binding triad (kind/channel/epoch).
182#[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/// Like [`build_inner_typed`] but also carries a slice of EXTRA inner tags appended verbatim —
197/// used for NIP-92 `imeta` attachment tags (a 3300 message mixing a caption with N files, via
198/// `attachments::attachment_to_imeta`). They are added before signing, so the inner signature
199/// covers them; readers pick out what they need by exact tag name.
200#[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    // Target reference: an `e` tag marked "reply" (Vector's DM convention) — the
220    // replied-to / reacted-to / edited message's inner id.
221    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    // NIP-30 custom emoji: ["emoji", shortcode, url] for each `:shortcode:` used in the
225    // content (so custom-emoji messages + reactions render the image, parity with DMs).
226    for et in emoji_tags {
227        tags.push(Tag::custom("emoji", [et.shortcode.clone(), et.url.clone()]));
228    }
229    // Extra inner tags appended verbatim (NIP-92 `imeta` attachment tags).
230    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
237/// Seal an already-signed inner authorship event into the outer wire event. The inner
238/// may have been signed by local keys or a remote bunker — this stage is signer-agnostic.
239/// Defensively re-checks the binding (kind/channel/epoch) so a caller can never seal
240/// an inner that the receiver would then reject as a splice.
241pub 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    // Only the community append-plane sub-kinds (message/reaction/edit + cooperative delete +
249    // presence + cooperative kick + webxdc peer signal + typing) may be sealed. Rekey (3303) and
250    // InviteBundle (3304) have their own carriers, so the contiguous 3300..=3302 range is admitted
251    // alongside the explicit 3305/3306/3309/3310/3311.
252    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    // Single NIP-44 v2 pass under the raw channel key.
275    let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes())
276        .map_err(EnvelopeError::Encrypt)?;
277
278    // Outer event: ephemeral signer (no author↔channel linkage on the wire), tagged
279    // with the per-epoch pseudonym (relay-filterable `z`) and the version. Outer kind
280    // mirrors the inner (the binding the receiver enforces).
281    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
294/// Open and fully verify an outer wire event, given the channel key + the exact
295/// channel/epoch coordinate that key belongs to.
296pub fn open_message(
297    outer: &Event,
298    channel_key: &ChannelKey,
299    channel_id: &ChannelId,
300    epoch: Epoch,
301) -> Result<OpenedMessage, EnvelopeError> {
302    // Version-check BEFORE attempting decryption (hook #2).
303    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 author signature (the authorship proof).
314    inner.verify().map_err(|_| EnvelopeError::BadSignature)?;
315
316    // Binding triad — type, channel, epoch.
317    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    // Reply-ref + emoji parsing is the SHARED parser's job (runs off `tags` in `process_rumor`), so it
333    // lives in ONE place. The transport keeps only: the ordering `ms` (used to sort fetched events
334    // before they're parsed — via the SAME shared resolver), NIP-92 `imeta` attachments, and the 
335    // authority citation.
336    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
360/// Open an outer wire event when the member may hold MULTIPLE epoch keys (post-rekey catch-up): select
361/// the decryption key by the outer's `z` pseudonym tag — each epoch addresses a distinct pseudonym we
362/// can recompute — then open under that exact epoch. `epoch_keys` is the member's retained `(epoch, key)`
363/// set for this channel. A `z` matching no held epoch yields [`EnvelopeError::NoHeldEpoch`] (not ours to
364/// read), keeping the per-event cost one pseudonym derivation per held epoch (a handful), no trial-decrypt.
365pub 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
379/// First value of the first tag named `name` (for outer routing tags like `v`,
380/// which the ephemeral signer controls and which carry no binding weight).
381fn 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
388/// Value of the tag named `name`, requiring it to appear AT MOST ONCE. The inner
389/// event is constructable by any channel-key holder, so a duplicated binding tag
390/// would make first-match nondeterministic — reject it.
391fn 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    /// Seal an arbitrary inner event into a correctly-tagged outer (epoch 0).
424    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        // Parity with DMs: the inner event can be signed through the async signing
454        // path (the same one a NIP-46 bunker uses) instead of local keys. `Keys`
455        // implements the async capability traits, so finalizing the unsigned inner
456        // asynchronously exercises that path; the sealed result must open identically.
457        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        // Retained ephemeral is the outer signer (so it's still self-deletable).
469        assert_eq!(outer.pubkey, ephemeral.public_key());
470    }
471
472    #[test]
473    fn reply_reference_round_trips() {
474        // A reply target (inner `e` tag) survives seal→open and surfaces on the parsed Message via the
475        // shared parser (reply parsing now lives in `process_rumor`, exercised end-to-end via build_message).
476        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        // No reply target → replied_to is empty.
487        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        // NIP-30 `["emoji", shortcode, url]` tags survive seal→open and surface on the parsed Message
495        // (shared parser), so custom-emoji messages render the image — parity with DMs.
496        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        // W3: the inner created_at isn't relay-clamped (only the outer is published), so a
518        // hostile member could stamp it absurdly far in the future to pin the message to
519        // the top forever. open_message clamps an implausible ms back to receipt time.
520        let author = Keys::generate();
521        let far_future = 99_999_999_999_999u64; // ~year 5138 in epoch-ms
522        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        // A message's reply pointer is cosmetic, so a duplicate `e` no longer drops the whole message —
530        // the content is preserved (open succeeds). The security-critical disambiguation moved to the
531        // SHARED parser, which rejects an ambiguous TARGET for reactions/edits/deletes
532        // (see `rumor::unique_event_ref`); that's covered by the rumor-level tests.
533        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        // The protocol's multi-attachment capability: ONE event carries a caption
552        // (content) plus N attachments (one imeta each), optionally replying. Verify the
553        // full seal → open path reconstructs the caption, the reply target, and every
554        // attachment's crypto + metadata in order.
555        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        // Reply ref is parsed by the shared parser; the built Message carries it. Attachments are the
589        // transport-specific imeta, still parsed at open.
590        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        // A caption-only message (no imeta) opens with zero attachments.
602        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        // seal_with_signed_inner must refuse an inner whose binding doesn't match the
609        // coordinate being sealed under (can't produce an unopenable/spliced message).
610        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        // Full epoch-ms in → created_at(secs) + ms-offset(0..999) on the wire →
622        // exact full ms back out (lossless, and matches Vector's DM convention).
623        let author = Keys::generate();
624        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap();
625        // On the wire: created_at is seconds, the ms tag is only the 0..999 offset.
626        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    /// Decrypt + read the inner event's created_at (test helper).
633    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        // The wire event must NOT be signed by the author's real key (no linkage).
642        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        // ...but the recovered author (inner) IS the real key.
650        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        // Decrypt succeeds under a key we hold, but the inner channel tag names a
674        // different channel than the key's channel → strict-equality check fires.
675        let author = Keys::generate();
676        // Seal for channel A.
677        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap();
678        // Attempt to open as if it belonged to a DIFFERENT channel B, using the SAME
679        // key (simulating a member who re-published it under B's coordinate).
680        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        // Flip a byte of the base64 payload → NIP-44 MAC must fail on decrypt.
696        let author = Keys::generate();
697        let mut outer =
698            seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap();
699        // Rebuild an outer event with a corrupted content (events are immutable, so
700        // re-sign a mutated copy with a fresh ephemeral key).
701        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        // The e2e seed: Alice and Bob hold the same channel key; each can open the
740        // other's sealed message and recover the correct real author.
741        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        // message_ids differ (distinct inner events).
756        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        // Hand-build an outer event with a bogus version tag; must reject on version,
762        // never reaching decryption.
763        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        // A signed REACTION inner re-wrapped inside a MESSAGE outer → type splice.
780        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        // Tamper the inner content after signing: id/sig no longer verify.
796        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        // Inner has a valid sig + matching kind + epoch, but no channel tag.
822        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        // Two channel tags → ambiguous inner; must reject (don't trust first-match).
835        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        // Bogus version AND wrong key: must fail on version, NOT decrypt. This proves
848        // the ordering — a regression moving the version check after decrypt would
849        // instead surface a Decrypt error here (wrong key), so this catches it where
850        // the correct-key version test cannot.
851        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        // Distinct from the bit-flip test: chop bytes off the payload → length/MAC fail.
869        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        // The binding triad requires inner.kind == outer.kind; seal must produce that.
890        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        // Decrypt the inner and confirm its kind mirrors the outer.
894        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        // the relay-filterable `z` tag must carry the correct epoch pseudonym,
902        // and `v` must be "1" — a regression here silently breaks relay querying.
903        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}