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).
182pub fn build_inner_typed(
183    author: PublicKey,
184    channel_id: &ChannelId,
185    epoch: Epoch,
186    kind: u16,
187    content: &str,
188    ms: u64,
189    reference: Option<&str>,
190    emoji_tags: &[crate::types::EmojiTag],
191) -> UnsignedEvent {
192    build_inner_full(author, channel_id, epoch, kind, content, ms, reference, emoji_tags, &[])
193}
194
195/// Like [`build_inner_typed`] but also carries a slice of EXTRA inner tags appended verbatim —
196/// used for NIP-92 `imeta` attachment tags (a 3300 message mixing a caption with N files, via
197/// `attachments::attachment_to_imeta`). They are added before signing, so the inner signature
198/// covers them; readers pick out what they need by exact tag name.
199pub fn build_inner_full(
200    author: PublicKey,
201    channel_id: &ChannelId,
202    epoch: Epoch,
203    kind: u16,
204    content: &str,
205    ms: u64,
206    reference: Option<&str>,
207    emoji_tags: &[crate::types::EmojiTag],
208    extra_tags: &[Tag],
209) -> UnsignedEvent {
210    let created_secs = ms / 1000;
211    let ms_offset = ms % 1000;
212    let mut tags = vec![
213        Tag::custom(TAG_CHANNEL, [channel_id.to_hex()]),
214        Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
215        Tag::custom(TAG_MS, [ms_offset.to_string()]),
216    ];
217    // Target reference: an `e` tag marked "reply" (Vector's DM convention) — the
218    // replied-to / reacted-to / edited message's inner id.
219    if let Some(target) = reference.filter(|t| !t.is_empty()) {
220        tags.push(Tag::custom("e", [target.to_string(), String::new(), "reply".to_string()]));
221    }
222    // NIP-30 custom emoji: ["emoji", shortcode, url] for each `:shortcode:` used in the
223    // content (so custom-emoji messages + reactions render the image, parity with DMs).
224    for et in emoji_tags {
225        tags.push(Tag::custom("emoji", [et.shortcode.clone(), et.url.clone()]));
226    }
227    // Extra inner tags appended verbatim (NIP-92 `imeta` attachment tags).
228    tags.extend(extra_tags.iter().cloned());
229    EventBuilder::new(Kind::Custom(kind), content)
230        .tags(tags)
231        .custom_created_at(Timestamp::from_secs(created_secs))
232        .finalize_unsigned_with_id(author)
233}
234
235/// Seal an already-signed inner authorship event into the outer wire event. The inner
236/// may have been signed by local keys or a remote bunker — this stage is signer-agnostic.
237/// Defensively re-checks the binding (kind/channel/epoch) so a caller can never seal
238/// an inner that the receiver would then reject as a splice.
239pub fn seal_with_signed_inner(
240    ephemeral: &Keys,
241    inner: &Event,
242    channel_key: &ChannelKey,
243    channel_id: &ChannelId,
244    epoch: Epoch,
245) -> Result<Event, EnvelopeError> {
246    // Only the community append-plane sub-kinds (message/reaction/edit + cooperative delete +
247    // presence + cooperative kick + webxdc peer signal + typing) may be sealed. Rekey (3303) and
248    // InviteBundle (3304) have their own carriers, so the contiguous 3300..=3302 range is admitted
249    // alongside the explicit 3305/3306/3309/3310/3311.
250    let inner_kind = inner.kind.as_u16();
251    let allowed = (event_kind::COMMUNITY_MESSAGE..=event_kind::COMMUNITY_EDIT).contains(&inner_kind)
252        || inner_kind == event_kind::COMMUNITY_DELETE
253        || inner_kind == event_kind::COMMUNITY_PRESENCE
254        || inner_kind == event_kind::COMMUNITY_KICK
255        || inner_kind == event_kind::COMMUNITY_WEBXDC
256        || inner_kind == event_kind::COMMUNITY_TYPING;
257    if !allowed {
258        return Err(EnvelopeError::KindMismatch {
259            outer: event_kind::COMMUNITY_MESSAGE,
260            inner: inner_kind,
261        });
262    }
263    match unique_tag(inner, TAG_CHANNEL)? {
264        Some(c) if c == channel_id.to_hex() => {}
265        _ => return Err(EnvelopeError::ChannelMismatch),
266    }
267    match unique_tag(inner, TAG_EPOCH)? {
268        Some(e) if e == epoch.0.to_string() => {}
269        _ => return Err(EnvelopeError::EpochMismatch),
270    }
271
272    // Single NIP-44 v2 pass under the raw channel key.
273    let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes())
274        .map_err(EnvelopeError::Encrypt)?;
275
276    // Outer event: ephemeral signer (no author↔channel linkage on the wire), tagged
277    // with the per-epoch pseudonym (relay-filterable `z`) and the version. Outer kind
278    // mirrors the inner (the binding the receiver enforces).
279    let pseudonym = channel_pseudonym(channel_key, channel_id, epoch);
280    EventBuilder::new(Kind::Custom(inner_kind), content_b64)
281        .tags([
282            Tag::custom(
283                "z",
284                [pseudonym.to_hex()],
285            ),
286            Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
287        ])
288        .finalize(ephemeral)
289        .map_err(|e| EnvelopeError::Sign(e.to_string()))
290}
291
292/// Open and fully verify an outer wire event, given the channel key + the exact
293/// channel/epoch coordinate that key belongs to.
294pub fn open_message(
295    outer: &Event,
296    channel_key: &ChannelKey,
297    channel_id: &ChannelId,
298    epoch: Epoch,
299) -> Result<OpenedMessage, EnvelopeError> {
300    // Version-check BEFORE attempting decryption (hook #2).
301    match find_tag(outer, TAG_VERSION).as_deref() {
302        Some(PROTOCOL_VERSION) => {}
303        other => return Err(EnvelopeError::BadVersion(other.map(str::to_string))),
304    }
305
306    let plaintext = cipher::open(channel_key.as_bytes(), &outer.content)
307        .map_err(EnvelopeError::Decrypt)?;
308    let json = String::from_utf8(plaintext).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
309    let inner = Event::from_json(&json).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
310
311    // Inner author signature (the authorship proof).
312    inner.verify().map_err(|_| EnvelopeError::BadSignature)?;
313
314    // Binding triad — type, channel, epoch.
315    if inner.kind.as_u16() != outer.kind.as_u16() {
316        return Err(EnvelopeError::KindMismatch {
317            outer: outer.kind.as_u16(),
318            inner: inner.kind.as_u16(),
319        });
320    }
321    let inner_channel = unique_tag(&inner, TAG_CHANNEL)?.ok_or(EnvelopeError::MissingTag(TAG_CHANNEL))?;
322    if inner_channel != channel_id.to_hex() {
323        return Err(EnvelopeError::ChannelMismatch);
324    }
325    let inner_epoch = unique_tag(&inner, TAG_EPOCH)?.ok_or(EnvelopeError::MissingTag(TAG_EPOCH))?;
326    if inner_epoch != epoch.0.to_string() {
327        return Err(EnvelopeError::EpochMismatch);
328    }
329
330    // Reply-ref + emoji parsing is the SHARED parser's job (runs off `tags` in `process_rumor`), so it
331    // lives in ONE place. The transport keeps only: the ordering `ms` (used to sort fetched events
332    // before they're parsed — via the SAME shared resolver), NIP-92 `imeta` attachments, and the 
333    // authority citation.
334    let ms = Some(crate::rumor::resolve_message_timestamp(
335        inner.created_at.as_secs(),
336        unique_tag(&inner, TAG_MS)?.as_deref(),
337    ));
338    let attachments = super::attachments::attachments_from_tags(
339        inner.tags.iter(),
340        &crate::db::get_download_dir(),
341    );
342    Ok(OpenedMessage {
343        message_id: inner.id,
344        author: inner.pubkey,
345        content: inner.content.clone(),
346        channel_id: *channel_id,
347        epoch,
348        ms,
349        created_at: inner.created_at,
350        kind: inner.kind.as_u16(),
351        attachments,
352        citation: super::edition::AuthorityCitation::from_tags(&inner.tags),
353        wrapper_id: outer.id,
354        tags: inner.tags.clone(),
355    })
356}
357
358/// Open an outer wire event when the member may hold MULTIPLE epoch keys (post-rekey catch-up): select
359/// the decryption key by the outer's `z` pseudonym tag — each epoch addresses a distinct pseudonym we
360/// can recompute — then open under that exact epoch. `epoch_keys` is the member's retained `(epoch, key)`
361/// set for this channel. A `z` matching no held epoch yields [`EnvelopeError::NoHeldEpoch`] (not ours to
362/// read), keeping the per-event cost one pseudonym derivation per held epoch (a handful), no trial-decrypt.
363pub fn open_message_multi(
364    outer: &Event,
365    channel_id: &ChannelId,
366    epoch_keys: &[(Epoch, ChannelKey)],
367) -> Result<OpenedMessage, EnvelopeError> {
368    let z = find_tag(outer, "z").ok_or(EnvelopeError::MissingTag("z"))?;
369    for (epoch, key) in epoch_keys {
370        if channel_pseudonym(key, channel_id, *epoch).to_hex() == z {
371            return open_message(outer, key, channel_id, *epoch);
372        }
373    }
374    Err(EnvelopeError::NoHeldEpoch)
375}
376
377/// First value of the first tag named `name` (for outer routing tags like `v`,
378/// which the ephemeral signer controls and which carry no binding weight).
379fn find_tag(event: &Event, name: &str) -> Option<String> {
380    event.tags.iter().find_map(|t| {
381        let s = t.as_slice();
382        (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
383    })
384}
385
386/// Value of the tag named `name`, requiring it to appear AT MOST ONCE. The inner
387/// event is constructable by any channel-key holder, so a duplicated binding tag
388/// would make first-match nondeterministic — reject it.
389fn unique_tag(event: &Event, name: &'static str) -> Result<Option<String>, EnvelopeError> {
390    let mut found: Option<String> = None;
391    for t in event.tags.iter() {
392        let s = t.as_slice();
393        if s.len() >= 2 && s[0] == name {
394            if found.is_some() {
395                return Err(EnvelopeError::DuplicateTag(name));
396            }
397            found = Some(s[1].clone());
398        }
399    }
400    Ok(found)
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn key() -> ChannelKey {
408        ChannelKey([0x11u8; 32])
409    }
410    fn chan() -> ChannelId {
411        ChannelId([0xaau8; 32])
412    }
413
414    fn channel_tag() -> Tag {
415        Tag::custom(TAG_CHANNEL, [chan().to_hex()])
416    }
417    fn epoch0_tag() -> Tag {
418        Tag::custom(TAG_EPOCH, ["0".to_string()])
419    }
420
421    /// Seal an arbitrary inner event into a correctly-tagged outer (epoch 0).
422    fn wrap_inner(inner: &Event) -> Event {
423        let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
424        EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
425            .tags([
426                Tag::custom(
427                    "z",
428                    [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
429                ),
430                Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
431            ])
432            .finalize(&Keys::generate())
433            .unwrap()
434    }
435
436    #[test]
437    fn round_trip() {
438        let author = Keys::generate();
439        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "gm fren", 1_700_000_000_000)
440            .expect("seal");
441        let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
442        assert_eq!(opened.content, "gm fren");
443        assert_eq!(opened.author, author.public_key());
444        assert_eq!(opened.ms, Some(1_700_000_000_000));
445        assert_eq!(opened.channel_id, chan());
446        assert_eq!(opened.epoch, Epoch(0));
447    }
448
449    #[tokio::test]
450    async fn signer_path_matches_local_keys_path() {
451        // Parity with DMs: the inner event can be signed through the async signing
452        // path (the same one a NIP-46 bunker uses) instead of local keys. `Keys`
453        // implements the async capability traits, so finalizing the unsigned inner
454        // asynchronously exercises that path; the sealed result must open identically.
455        let author = Keys::generate();
456        let ephemeral = Keys::generate();
457
458        let unsigned = build_inner_event(author.public_key(), &chan(), Epoch(0), "via signer", 1_700_000_000_777, None);
459        let inner: Event = unsigned.finalize_async(&author).await.expect("remote-style sign");
460        let outer = seal_with_signed_inner(&ephemeral, &inner, &key(), &chan(), Epoch(0)).expect("seal");
461
462        let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
463        assert_eq!(opened.content, "via signer");
464        assert_eq!(opened.author, author.public_key(), "authorship is the identity key, not ephemeral");
465        assert_eq!(opened.ms, Some(1_700_000_000_777));
466        // Retained ephemeral is the outer signer (so it's still self-deletable).
467        assert_eq!(outer.pubkey, ephemeral.public_key());
468    }
469
470    #[test]
471    fn reply_reference_round_trips() {
472        // A reply target (inner `e` tag) survives seal→open and surfaces on the parsed Message via the
473        // shared parser (reply parsing now lives in `process_rumor`, exercised end-to-end via build_message).
474        let author = Keys::generate();
475        let target = "a".repeat(64);
476        let inner = build_inner_event(author.public_key(), &chan(), Epoch(0), "re: hi", 5, Some(&target))
477            .finalize(&author)
478            .unwrap();
479        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
480        let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
481        let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
482        assert_eq!(msg.replied_to, target);
483
484        // No reply target → replied_to is empty.
485        let plain = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 6).unwrap();
486        let opened2 = open_message(&plain, &key(), &chan(), Epoch(0)).unwrap();
487        assert!(crate::community::inbound::build_message(&opened2, &Keys::generate().public_key()).replied_to.is_empty());
488    }
489
490    #[test]
491    fn custom_emoji_tags_round_trip() {
492        // NIP-30 `["emoji", shortcode, url]` tags survive seal→open and surface on the parsed Message
493        // (shared parser), so custom-emoji messages render the image — parity with DMs.
494        let author = Keys::generate();
495        let tags = vec![crate::types::EmojiTag {
496            shortcode: "fire".into(),
497            url: "https://blossom/fire.png".into(),
498        }];
499        let inner = build_inner_typed(
500            author.public_key(), &chan(), Epoch(0),
501            crate::stored_event::event_kind::COMMUNITY_MESSAGE, "gm :fire:", 1, None, &tags,
502        )
503        .finalize(&author)
504        .unwrap();
505        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
506        let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
507        let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
508        assert_eq!(msg.emoji_tags.len(), 1);
509        assert_eq!(msg.emoji_tags[0].shortcode, "fire");
510        assert_eq!(msg.emoji_tags[0].url, "https://blossom/fire.png");
511    }
512
513    #[test]
514    fn far_future_inner_timestamp_is_clamped() {
515        // W3: the inner created_at isn't relay-clamped (only the outer is published), so a
516        // hostile member could stamp it absurdly far in the future to pin the message to
517        // the top forever. open_message clamps an implausible ms back to receipt time.
518        let author = Keys::generate();
519        let far_future = 99_999_999_999_999u64; // ~year 5138 in epoch-ms
520        let outer = seal_message(&author, &key(), &chan(), Epoch(0), "from the future", far_future).unwrap();
521        let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
522        assert!(opened.ms.unwrap() < far_future, "far-future ordering ms must be clamped to ~now");
523    }
524
525    #[test]
526    fn duplicate_reply_tag_on_a_message_is_tolerated() {
527        // A message's reply pointer is cosmetic, so a duplicate `e` no longer drops the whole message —
528        // the content is preserved (open succeeds). The security-critical disambiguation moved to the
529        // SHARED parser, which rejects an ambiguous TARGET for reactions/edits/deletes
530        // (see `rumor::unique_event_ref`); that's covered by the rumor-level tests.
531        let author = Keys::generate();
532        let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
533            .tags([
534                Tag::custom(TAG_CHANNEL, [chan().to_hex()]),
535                Tag::custom(TAG_EPOCH, ["0".to_string()]),
536                Tag::custom(TAG_MS, ["1".to_string()]),
537                Tag::custom("e", ["aa".repeat(32), String::new(), "reply".to_string()]),
538                Tag::custom("e", ["bb".repeat(32), String::new(), "reply".to_string()]),
539            ])
540            .finalize(&author)
541            .unwrap();
542        let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
543        assert!(open_message(&outer, &key(), &chan(), Epoch(0)).is_ok(),
544            "a message must not be dropped over an ambiguous (cosmetic) reply pointer");
545    }
546
547    #[test]
548    fn multi_attachment_message_round_trips_caption_and_imeta() {
549        // The protocol's multi-attachment capability: ONE event carries a caption
550        // (content) plus N attachments (one imeta each), optionally replying. Verify the
551        // full seal → open path reconstructs the caption, the reply target, and every
552        // attachment's crypto + metadata in order.
553        use crate::types::{Attachment, ImageMetadata};
554        let mk = |name: &str, ext: &str, img: bool| Attachment {
555            id: "x".into(),
556            key: "0".repeat(64),
557            nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(name.as_bytes())),
558            extension: ext.into(),
559            name: name.into(),
560            url: format!("https://blossom.example/{name}"),
561            path: String::new(),
562            size: 1234,
563            img_meta: img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 64, height: 48 }),
564            downloading: false,
565            downloaded: false,
566            webxdc_topic: None,
567            group_id: None,
568            original_hash: Some("a".repeat(64)),
569            fallback_urls: Vec::new(),
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        ).finalize(&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            .finalize(&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                    "z",
708                    [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
709                ),
710                Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
711            ])
712            .finalize(&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            .finalize(&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            .finalize(&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            .finalize(&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(TAG_VERSION, ["999".to_string()])])
769            .finalize(&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            .finalize(&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            .finalize(&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                    "z",
807                    [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
808                ),
809                Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
810            ])
811            .finalize(&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            .finalize(&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            .finalize(&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            .finalize(&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(TAG_VERSION, ["7".to_string()])])
857            .finalize(&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                    "z",
876                    [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
877                ),
878                Tag::custom(TAG_VERSION, [PROTOCOL_VERSION.to_string()]),
879            ])
880            .finalize(&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}