Skip to main content

vector_core/community/v2/
stream.rs

1//! CORD-01 Private Streams — the Concord v2 envelope.
2//!
3//! Every durable plane event is the same three-layer shape: a kind-1059 **wrap**
4//! signed by the plane's derived group key (fixed author, random ephemeral `p`
5//! tag — NIP-59 reversed), containing a **seal** signed by the author's real
6//! key, containing the unsigned **rumor** that carries the functional kind.
7//!
8//! Two seal forms, fixed per plane (CORD-02 §5), declared by the seal's kind:
9//!   - **20013 encrypted** (Chat, Guestbook, rekey planes): the rumor is
10//!     NIP-44-encrypted *again* inside the already-encrypted wrap, so no layer
11//!     can ever be lifted out as a standalone public event.
12//!   - **20014 plaintext** (Control Plane ONLY): the seal's content is the
13//!     rumor's serialized JSON string byte-verbatim, which is what lets a
14//!     compaction re-wrap a signed edition into a new epoch with the signature
15//!     intact. A re-wrap MUST carry those exact bytes forward, never
16//!     re-serialize.
17//!
18//! Ephemeral actions (typing, voice presence) ride the identical structure at
19//! kind 21059 — relays MUST NOT store it.
20//!
21//! NIP-44 hard-caps plaintext at 65,535 bytes and libraries are lenient, so the
22//! cap is enforced HERE at every nesting layer — a lenient publisher mints
23//! events a strict reader cannot decrypt.
24
25use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey};
26use nostr_sdk::prelude::{Event, EventBuilder, EventId, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
27
28use super::super::{ChannelId, Epoch};
29use super::derive::GroupKey;
30
31/// Durable stream wrap.
32pub const KIND_WRAP: u16 = 1059;
33/// Ephemeral stream wrap — identical structure, relays MUST NOT store it.
34pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
35/// Encrypted seal (Chat / Guestbook / rekey planes).
36pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
37/// Plaintext seal (Control Plane only).
38pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
39
40/// NIP-44 v2 plaintext hard cap, enforced at every nesting layer.
41pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
42
43const TAG_MS: &str = "ms";
44const TAG_CHANNEL: &str = "channel";
45const TAG_EPOCH: &str = "epoch";
46
47/// Which seal form a plane uses (CORD-02 §5) — a fixed property of the plane,
48/// never a per-message choice.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum SealForm {
51    Encrypted,
52    Plaintext,
53}
54
55impl SealForm {
56    pub fn kind(self) -> u16 {
57        match self {
58            SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
59            SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
60        }
61    }
62
63    fn from_kind(kind: u16) -> Option<Self> {
64        match kind {
65            KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
66            KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
67            _ => None,
68        }
69    }
70}
71
72/// Errors from building or opening a v2 stream event.
73#[derive(Debug)]
74pub enum StreamError {
75    Sign(String),
76    Encrypt(String),
77    Decrypt(String),
78    Parse(String),
79    /// A plaintext (wrap/seal/rumor JSON) exceeds the NIP-44 65,535-byte cap.
80    Oversize(usize),
81    /// Outer kind is neither 1059 nor 21059.
82    BadWrapKind(u16),
83    /// Wrap author isn't this plane's group key — not this stream's event.
84    WrongStream,
85    /// Seal kind is neither 20013 nor 20014.
86    BadSealKind(u16),
87    /// The seal's Schnorr signature (or id) failed to verify.
88    BadSealSignature,
89    /// Rumor pubkey ≠ seal pubkey — the seal doesn't vouch for this author.
90    AuthorMismatch,
91    /// Rumor's claimed id ≠ the hash of its serialized form.
92    BadRumorId,
93    /// `ms` tag present but not an integer in 0..=999 — the event is malformed
94    /// and MUST be dropped, never clamped or interpreted.
95    BadMs,
96    /// Inner channel id ≠ the channel whose key decrypted this (cross-channel splice).
97    ChannelMismatch,
98    /// Inner epoch ≠ the epoch whose key decrypted this (cross-epoch splice/replay).
99    EpochMismatch,
100    MissingTag(&'static str),
101    /// A binding tag appears more than once — ambiguous, rejected.
102    DuplicateTag(&'static str),
103    /// `rewrap` was handed a non-plaintext seal — only 20014 survives re-wrapping
104    /// (a signature over ciphertext binds the old key).
105    NotRewrappable,
106}
107
108impl std::fmt::Display for StreamError {
109    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110        match self {
111            StreamError::Sign(e) => write!(f, "sign: {e}"),
112            StreamError::Encrypt(e) => write!(f, "encrypt: {e}"),
113            StreamError::Decrypt(e) => write!(f, "decrypt: {e}"),
114            StreamError::Parse(e) => write!(f, "parse: {e}"),
115            StreamError::Oversize(n) => write!(f, "plaintext {n} bytes exceeds NIP-44 cap"),
116            StreamError::BadWrapKind(k) => write!(f, "not a stream wrap kind: {k}"),
117            StreamError::WrongStream => write!(f, "wrap author is not this stream"),
118            StreamError::BadSealKind(k) => write!(f, "not a seal kind: {k}"),
119            StreamError::BadSealSignature => write!(f, "seal signature invalid"),
120            StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
121            StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
122            StreamError::BadMs => write!(f, "ms tag outside 0..=999"),
123            StreamError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"),
124            StreamError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"),
125            StreamError::MissingTag(t) => write!(f, "missing rumor tag: {t}"),
126            StreamError::DuplicateTag(t) => write!(f, "duplicate rumor tag: {t}"),
127            StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
128        }
129    }
130}
131
132impl std::error::Error for StreamError {}
133
134/// A fully verified, opened stream event.
135#[derive(Debug, Clone)]
136pub struct OpenedStream {
137    /// The unsigned rumor, id-verified against its serialized form.
138    pub rumor: UnsignedEvent,
139    /// The rumor's verified id (content-derived — the protocol's message id).
140    pub rumor_id: EventId,
141    /// The real author: the seal's Schnorr-verified pubkey (== rumor pubkey).
142    pub author: PublicKey,
143    /// Which seal form carried it.
144    pub seal_form: SealForm,
145    /// The verified seal, retained so a compaction can re-wrap a plaintext seal
146    /// byte-verbatim (its content string carries the signed rumor bytes).
147    pub seal: Event,
148    /// The outer wrap's id (per-transport identity; differs per re-wrap).
149    pub wrapper_id: EventId,
150    /// True event time in ms: `created_at * 1000 + ms-tag` (tag absent = 0).
151    pub at_ms: u64,
152}
153
154// ── millisecond ordering (CORD-02 §4) ────────────────────────────────────────
155
156/// Split a full epoch-ms send time into (`created_at` seconds, `ms` remainder).
157pub fn split_ms(at_ms: u64) -> (u64, u16) {
158    (at_ms / 1000, (at_ms % 1000) as u16)
159}
160
161/// Resolve a rumor's true millisecond time — STRICT per CORD-02 §5: an absent
162/// `ms` tag is offset 0; ANY present `ms` tag that isn't a lone integer in
163/// 0..=999 makes the event malformed (`BadMs` — drop it, never clamp), or the
164/// excess would smuggle arbitrary "future" past the coalesce clock checks.
165///
166/// A present-but-valueless `["ms"]` and a duplicated `ms` tag both count as
167/// malformed here — the generic `unique_tag_unsigned` treats a valueless tag as
168/// absent (correct for the binding path, which then rejects true absence as
169/// MissingTag), but for `ms` "present yet uninterpretable" must be BadMs, not a
170/// silent default, so this scans every occurrence of the tag name directly.
171pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
172    let secs = rumor.created_at.as_secs();
173    let mut offset: Option<u64> = None;
174    for t in rumor.tags.iter() {
175        let s = t.as_slice();
176        if s.first().map(|k| k.as_str()) != Some(TAG_MS) {
177            continue;
178        }
179        if offset.is_some() {
180            // A second ms occurrence (valued or not) is ambiguous.
181            return Err(StreamError::BadMs);
182        }
183        // Present but valueless, or not a lone 0..=999 decimal without leading
184        // zeros — malformed. Digit-only FIRST: `u64::from_str` would otherwise
185        // accept a leading `+` ("+5", "+000"), a second byte-encoding a strict peer
186        // rejects — the exact cross-impl divergence this gate exists to prevent.
187        let raw = s.get(1).ok_or(StreamError::BadMs)?;
188        if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
189            return Err(StreamError::BadMs);
190        }
191        let n: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
192        if n > 999 || (raw.len() > 1 && raw.starts_with('0')) {
193            return Err(StreamError::BadMs);
194        }
195        offset = Some(n);
196    }
197    Ok(secs.saturating_mul(1000).saturating_add(offset.unwrap_or(0)))
198}
199
200// ── build side ───────────────────────────────────────────────────────────────
201
202/// Build an unsigned rumor carrying a full epoch-ms timestamp: `created_at`
203/// takes the seconds, an `["ms", <0..999>]` tag the remainder.
204pub fn build_rumor_ms(
205    kind: u16,
206    author: PublicKey,
207    content: &str,
208    mut tags: Vec<Tag>,
209    at_ms: u64,
210) -> UnsignedEvent {
211    let (secs, offset) = split_ms(at_ms);
212    tags.push(Tag::custom(TagKind::Custom(TAG_MS.into()), [offset.to_string()]));
213    build_rumor_secs(kind, author, content, tags, secs)
214}
215
216/// Build an unsigned rumor with a plain seconds timestamp and NO `ms` tag —
217/// the Control Plane shape (editions fold by version, not time).
218pub fn build_rumor_secs(
219    kind: u16,
220    author: PublicKey,
221    content: &str,
222    tags: Vec<Tag>,
223    at_secs: u64,
224) -> UnsignedEvent {
225    // allow_self_tagging: EventBuilder silently strips a `p` naming the author,
226    // which would break a self-reaction's NIP-25 shape. Rumor tags are the
227    // builder's byte-verbatim contract — no hidden normalization.
228    let mut rumor = EventBuilder::new(Kind::Custom(kind), content)
229        .tags(tags)
230        .allow_self_tagging()
231        .custom_created_at(Timestamp::from_secs(at_secs))
232        .build(author);
233    rumor.ensure_id();
234    rumor
235}
236
237/// The seal's `content` for a rumor: NIP-44 ciphertext under the stream's
238/// conversation key (encrypted form) or the rumor's serialized JSON string
239/// verbatim (plaintext form). Split from signing so the caller can sign with
240/// local keys OR a NIP-46 bunker — the seal is `(seal_form.kind(), content,
241/// created_at = rumor.created_at)` signed by the author's real key.
242pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError> {
243    let json = rumor.as_json();
244    cap(json.len())?;
245    match form {
246        SealForm::Plaintext => Ok(json),
247        SealForm::Encrypted => {
248            let ct = encrypt_to_bytes(group.conv_key(), json.as_bytes()).map_err(|e| StreamError::Encrypt(e.to_string()))?;
249            Ok(base64_simd::STANDARD.encode_to_string(&ct))
250        }
251    }
252}
253
254/// Local-keys convenience: build + sign the seal in one step. Wire-identical to
255/// the split path (`seal_content` + caller-side signing), which bunker accounts
256/// use instead.
257pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author_keys: &Keys) -> Result<Event, StreamError> {
258    let content = seal_content(rumor, form, group)?;
259    EventBuilder::new(Kind::Custom(form.kind()), content)
260        .custom_created_at(rumor.created_at)
261        .sign_with_keys(author_keys)
262        .map_err(|e| StreamError::Sign(e.to_string()))
263}
264
265/// Wrap a signed seal into the outer stream event: content = NIP-44 under the
266/// stream conversation key, signed by the group key, one random ephemeral `p`
267/// tag (NIP-59 reversed). Returns the wrap and the ephemeral `p` keypair — a
268/// client MAY retain the latter to best-effort NIP-09-scrub the wrap later.
269///
270/// `wrap_kind` is [`KIND_WRAP`] or [`KIND_WRAP_EPHEMERAL`]; `wrap_at` is the
271/// wrap's `created_at` (untweaked wall clock — CORD-01 forbids NIP-59's
272/// timestamp tweak on stream events).
273pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, wrap_at: Timestamp) -> Result<(Event, Keys), StreamError> {
274    wrap_seal_with_tags(seal, group, wrap_kind, wrap_at, &[])
275}
276
277/// [`wrap_seal`] plus caller-supplied `extra_tags` on the outer wrap. The chat
278/// plane uses this to mirror a message's NIP-40 `expiration` (Self-Destruct
279/// Timer) onto the wrap so relays drop the stored event on schedule; every other
280/// plane wraps with none.
281pub fn wrap_seal_with_tags(
282    seal: &Event,
283    group: &GroupKey,
284    wrap_kind: u16,
285    wrap_at: Timestamp,
286    extra_tags: &[Tag],
287) -> Result<(Event, Keys), StreamError> {
288    if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
289        return Err(StreamError::BadWrapKind(wrap_kind));
290    }
291    let seal_json = seal.as_json();
292    cap(seal_json.len())?;
293    let ct = encrypt_to_bytes(group.conv_key(), seal_json.as_bytes()).map_err(|e| StreamError::Encrypt(e.to_string()))?;
294    let ephemeral = Keys::generate();
295    let mut tags = vec![Tag::public_key(ephemeral.public_key())];
296    tags.extend_from_slice(extra_tags);
297    let wrap = EventBuilder::new(Kind::Custom(wrap_kind), base64_simd::STANDARD.encode_to_string(&ct))
298        .tags(tags)
299        .custom_created_at(wrap_at)
300        .sign_with_keys(group.keys())
301        .map_err(|e| StreamError::Sign(e.to_string()))?;
302    Ok((wrap, ephemeral))
303}
304
305/// Re-wrap an already-verified PLAINTEXT seal into another stream (a compaction
306/// carrying a signed edition into a new epoch). The seal event is carried
307/// whole — its content string holds the rumor bytes verbatim, so the rumor id
308/// and the author's signature survive.
309pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, wrap_at: Timestamp) -> Result<(Event, Keys), StreamError> {
310    if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
311        return Err(StreamError::NotRewrappable);
312    }
313    wrap_seal(seal, new_group, KIND_WRAP, wrap_at)
314}
315
316// ── open side ────────────────────────────────────────────────────────────────
317
318/// Open and fully verify a stream wrap against the plane's group key.
319///
320/// Verification chain: wrap kind → wrap author == stream address → decrypt
321/// (the NIP-44 MAC under the members-only conversation key is the envelope
322/// gate — the wrap's own signature adds nothing an outsider couldn't also
323/// forge-or-not, so it isn't re-checked) → seal kind → seal Schnorr verify →
324/// rumor recover → rumor.pubkey == seal.pubkey → rumor.id == computed hash
325/// (never trust a claimed id) → strict ms resolve.
326pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
327    let wrap_kind = wrap.kind.as_u16();
328    if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
329        return Err(StreamError::BadWrapKind(wrap_kind));
330    }
331    if wrap.pubkey != group.pk() {
332        return Err(StreamError::WrongStream);
333    }
334
335    let seal_json = open_nip44(group.conv_key(), &wrap.content)?;
336    let seal: Event = Event::from_json(&seal_json).map_err(|e| StreamError::Parse(e.to_string()))?;
337    let seal_form = SealForm::from_kind(seal.kind.as_u16()).ok_or(StreamError::BadSealKind(seal.kind.as_u16()))?;
338    seal.verify().map_err(|_| StreamError::BadSealSignature)?;
339
340    let rumor_json = match seal_form {
341        SealForm::Plaintext => seal.content.clone(),
342        SealForm::Encrypted => open_nip44(group.conv_key(), &seal.content)?,
343    };
344    let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| StreamError::Parse(e.to_string()))?;
345
346    if rumor.pubkey != seal.pubkey {
347        return Err(StreamError::AuthorMismatch);
348    }
349    // Never trust a claimed id: recompute from the serialized fields
350    // unconditionally (`ensure_id` is a no-op when an id is present, so it
351    // would wave a forged one through). An absent id just takes the computed one.
352    let computed = EventId::new(&rumor.pubkey, &rumor.created_at, &rumor.kind, &rumor.tags, &rumor.content);
353    if let Some(claimed) = rumor.id {
354        if claimed != computed {
355            return Err(StreamError::BadRumorId);
356        }
357    }
358    rumor.id = Some(computed);
359    let at_ms = resolve_ms_strict(&rumor)?;
360
361    Ok(OpenedStream {
362        rumor_id: computed,
363        author: seal.pubkey,
364        seal_form,
365        seal,
366        wrapper_id: wrap.id,
367        at_ms,
368        rumor,
369    })
370}
371
372/// Enforce the Chat-plane binding (CORD-03 §3): the rumor MUST commit
373/// `["channel", id]` + `["epoch", n]`, strict-equal to the coordinate whose key
374/// decrypted the wrap; a mismatch (or duplicate/absent tag) is a splice — drop.
375pub fn check_channel_binding(rumor: &UnsignedEvent, channel_id: &ChannelId, epoch: Epoch) -> Result<(), StreamError> {
376    match unique_tag_unsigned(rumor, TAG_CHANNEL)? {
377        Some(c) if c == channel_id.to_hex() => {}
378        Some(_) => return Err(StreamError::ChannelMismatch),
379        None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
380    }
381    match unique_tag_unsigned(rumor, TAG_EPOCH)? {
382        Some(e) if e == epoch.0.to_string() => {}
383        Some(_) => return Err(StreamError::EpochMismatch),
384        None => return Err(StreamError::MissingTag(TAG_EPOCH)),
385    }
386    Ok(())
387}
388
389/// The standard chat binding tags for a rumor: `["channel", id]` + `["epoch", n]`.
390pub fn channel_binding_tags(channel_id: &ChannelId, epoch: Epoch) -> Vec<Tag> {
391    vec![
392        Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel_id.to_hex()]),
393        Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]),
394    ]
395}
396
397// ── helpers ──────────────────────────────────────────────────────────────────
398
399fn cap(len: usize) -> Result<(), StreamError> {
400    if len > NIP44_MAX_PLAINTEXT {
401        return Err(StreamError::Oversize(len));
402    }
403    Ok(())
404}
405
406fn open_nip44(conv_key: &ConversationKey, content_b64: &str) -> Result<String, StreamError> {
407    let ct = base64_simd::STANDARD
408        .decode_to_vec(content_b64.as_bytes())
409        .map_err(|e| StreamError::Decrypt(e.to_string()))?;
410    let pt = decrypt_to_bytes(conv_key, &ct).map_err(|e| StreamError::Decrypt(e.to_string()))?;
411    String::from_utf8(pt).map_err(|e| StreamError::Parse(e.to_string()))
412}
413
414/// Value of the tag named `name` on an unsigned rumor, requiring it to appear
415/// AT MOST ONCE (any keyholder can craft a rumor; a duplicated binding tag
416/// makes first-match nondeterministic — reject).
417fn unique_tag_unsigned(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
418    let mut found: Option<String> = None;
419    for t in rumor.tags.iter() {
420        let s = t.as_slice();
421        if s.len() >= 2 && s[0] == name {
422            if found.is_some() {
423                return Err(StreamError::DuplicateTag(name));
424            }
425            found = Some(s[1].clone());
426        }
427    }
428    Ok(found)
429}
430
431#[cfg(test)]
432mod tests {
433    use super::super::super::{ChannelId, Epoch};
434    use super::super::derive::channel_group_key;
435    use super::super::kind;
436    use super::*;
437
438    fn group() -> GroupKey {
439        channel_group_key(&[7u8; 32], &chan(), Epoch(0))
440    }
441
442    fn chan() -> ChannelId {
443        ChannelId([0xabu8; 32])
444    }
445
446    fn send(author: &Keys, group: &GroupKey, form: SealForm, content: &str, at_ms: u64) -> Event {
447        let tags = channel_binding_tags(&chan(), Epoch(0));
448        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), content, tags, at_ms);
449        let seal = build_seal(&rumor, form, group, author).unwrap();
450        wrap_seal(&seal, group, KIND_WRAP, Timestamp::from_secs(1_700_000_000)).unwrap().0
451    }
452
453    #[test]
454    fn encrypted_round_trip_preserves_author_content_and_ms() {
455        let author = Keys::generate();
456        let wrap = send(&author, &group(), SealForm::Encrypted, "Hey chat!", 1_686_840_217_417);
457        assert_eq!(wrap.kind.as_u16(), KIND_WRAP);
458        assert_eq!(wrap.pubkey, group().pk(), "wrap is signed by the stream key");
459
460        let opened = open_wrap(&wrap, &group()).unwrap();
461        assert_eq!(opened.author, author.public_key());
462        assert_eq!(opened.rumor.content, "Hey chat!");
463        assert_eq!(opened.at_ms, 1_686_840_217_417);
464        assert_eq!(opened.seal_form, SealForm::Encrypted);
465        check_channel_binding(&opened.rumor, &chan(), Epoch(0)).unwrap();
466    }
467
468    #[test]
469    fn plaintext_seal_round_trip_carries_rumor_verbatim() {
470        let author = Keys::generate();
471        let wrap = send(&author, &group(), SealForm::Plaintext, "an edition", 1_686_840_217_000);
472        let opened = open_wrap(&wrap, &group()).unwrap();
473        assert_eq!(opened.seal_form, SealForm::Plaintext);
474        // The seal's content IS the rumor's JSON — the compaction contract.
475        assert_eq!(opened.seal.content, opened.rumor.as_json());
476    }
477
478    #[test]
479    fn wrong_stream_key_cannot_open() {
480        let author = Keys::generate();
481        let wrap = send(&author, &group(), SealForm::Encrypted, "secret", 1_000);
482        let other = channel_group_key(&[8u8; 32], &chan(), Epoch(0));
483        // Different address entirely → WrongStream before any decrypt attempt.
484        assert!(matches!(open_wrap(&wrap, &other), Err(StreamError::WrongStream)));
485    }
486
487    #[test]
488    fn tampered_wrap_content_fails_the_mac() {
489        let author = Keys::generate();
490        let mut wrap = send(&author, &group(), SealForm::Encrypted, "x", 1_000);
491        let mut json: serde_json::Value = serde_json::from_str(&wrap.as_json()).unwrap();
492        let ct = json["content"].as_str().unwrap().to_string();
493        // Flip a mid-payload character (the first char of a NIP-44 base64 payload
494        // is always 'A' — the 0x02 version byte — so tampering there is a no-op).
495        let mut bytes = ct.into_bytes();
496        bytes[20] = if bytes[20] == b'B' { b'C' } else { b'B' };
497        json["content"] = serde_json::Value::String(String::from_utf8(bytes).unwrap());
498        wrap = Event::from_json(json.to_string()).unwrap();
499        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::Decrypt(_))));
500    }
501
502    #[test]
503    fn forged_seal_signature_is_rejected() {
504        let author = Keys::generate();
505        let impostor = Keys::generate();
506        let tags = channel_binding_tags(&chan(), Epoch(0));
507        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "hi", tags, 1_000);
508        // Seal signed by the impostor but CLAIMING the author's pubkey: rebuild
509        // the seal event JSON with a swapped pubkey — the sig no longer matches.
510        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &impostor).unwrap();
511        let mut json: serde_json::Value = serde_json::from_str(&seal.as_json()).unwrap();
512        json["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
513        // (id also changes with pubkey — recompute is not attempted; both id and
514        // sig checks are downstream of Event::from_json/verify.)
515        let forged = Event::from_json(json.to_string());
516        let Ok(forged) = forged else { return }; // strict parsers may reject outright — equally a pass
517        let (wrap, _) = wrap_seal(&forged, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
518        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadSealSignature)));
519    }
520
521    #[test]
522    fn rumor_author_must_match_seal_author() {
523        let author = Keys::generate();
524        let other = Keys::generate();
525        let tags = channel_binding_tags(&chan(), Epoch(0));
526        // Rumor claims `other` as its author, but the seal is signed by `author`.
527        let rumor = build_rumor_ms(kind::MESSAGE, other.public_key(), "spoof", tags, 1_000);
528        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
529        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
530        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::AuthorMismatch)));
531    }
532
533    #[test]
534    fn forged_rumor_id_is_rejected() {
535        let author = Keys::generate();
536        let tags = channel_binding_tags(&chan(), Epoch(0));
537        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "real", tags, 1_000);
538        let mut json: serde_json::Value = serde_json::from_str(&rumor.as_json()).unwrap();
539        json["id"] = serde_json::Value::String("00".repeat(32));
540        let forged_json = json.to_string();
541        // Hand-build a seal around the forged rumor bytes (plaintext form so the
542        // bytes ride verbatim).
543        let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged_json)
544            .custom_created_at(rumor.created_at)
545            .sign_with_keys(&author)
546            .unwrap();
547        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
548        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadRumorId)));
549    }
550
551    #[test]
552    fn ms_is_strict_absent_is_zero_invalid_is_dropped() {
553        let author = Keys::generate();
554        // Absent ms = offset 0.
555        let rumor = build_rumor_secs(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
556        assert_eq!(resolve_ms_strict(&rumor).unwrap(), 1_000_000);
557        // 999 is the max valid offset.
558        let ok = build_rumor_secs(
559            kind::MESSAGE,
560            author.public_key(),
561            "x",
562            vec![Tag::custom(TagKind::Custom("ms".into()), ["999".to_string()])],
563            1_000,
564        );
565        assert_eq!(resolve_ms_strict(&ok).unwrap(), 1_000_999);
566        // 1000, negatives, non-integers, leading zeros, and a leading '+' (which
567        // `u64::from_str` would otherwise accept as a second byte-encoding):
568        // malformed — DROP, never clamp.
569        for bad in ["1000", "-1", "12.5", "abc", "007", "", "+5", "+0", "+000", "+999"] {
570            let r = build_rumor_secs(
571                kind::MESSAGE,
572                author.public_key(),
573                "x",
574                vec![Tag::custom(TagKind::Custom("ms".into()), [bad.to_string()])],
575                1_000,
576            );
577            assert!(
578                matches!(resolve_ms_strict(&r), Err(StreamError::BadMs)),
579                "ms={bad:?} must be malformed"
580            );
581        }
582    }
583
584    #[test]
585    fn a_valueless_or_duplicated_ms_tag_is_malformed_not_silently_zero() {
586        // A present-but-valueless ["ms"] must be BadMs, not treated as absent (a
587        // silent offset-0 default would honor a rumor a spec-strict peer drops).
588        let author = Keys::generate();
589        let bare = build_rumor_secs(
590            kind::MESSAGE,
591            author.public_key(),
592            "x",
593            vec![Tag::custom(TagKind::Custom("ms".into()), Vec::<String>::new())],
594            1_000,
595        );
596        assert!(matches!(resolve_ms_strict(&bare), Err(StreamError::BadMs)));
597        // A valued ms plus a valueless one must not let the valued one win — two
598        // ms occurrences are ambiguous.
599        let two = build_rumor_secs(
600            kind::MESSAGE,
601            author.public_key(),
602            "x",
603            vec![
604                Tag::custom(TagKind::Custom("ms".into()), Vec::<String>::new()),
605                Tag::custom(TagKind::Custom("ms".into()), ["5".to_string()]),
606            ],
607            1_000,
608        );
609        assert!(matches!(resolve_ms_strict(&two), Err(StreamError::BadMs)));
610        // Two valued ms tags are also ambiguous.
611        let two_valued = build_rumor_secs(
612            kind::MESSAGE,
613            author.public_key(),
614            "x",
615            vec![
616                Tag::custom(TagKind::Custom("ms".into()), ["1".to_string()]),
617                Tag::custom(TagKind::Custom("ms".into()), ["2".to_string()]),
618            ],
619            1_000,
620        );
621        assert!(matches!(resolve_ms_strict(&two_valued), Err(StreamError::BadMs)));
622    }
623
624    #[test]
625    fn binding_rejects_splices_and_duplicates() {
626        let author = Keys::generate();
627        let tags = channel_binding_tags(&chan(), Epoch(0));
628        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
629        // Wrong channel and wrong epoch both reject.
630        assert!(matches!(
631            check_channel_binding(&rumor, &ChannelId([0xcd; 32]), Epoch(0)),
632            Err(StreamError::ChannelMismatch)
633        ));
634        assert!(matches!(
635            check_channel_binding(&rumor, &chan(), Epoch(1)),
636            Err(StreamError::EpochMismatch)
637        ));
638        // A duplicated binding tag is ambiguous — rejected outright.
639        let mut tags = channel_binding_tags(&chan(), Epoch(0));
640        tags.extend(channel_binding_tags(&chan(), Epoch(0)));
641        let dup = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
642        assert!(matches!(
643            check_channel_binding(&dup, &chan(), Epoch(0)),
644            Err(StreamError::DuplicateTag(_))
645        ));
646        // Missing binding tags reject too.
647        let bare = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
648        assert!(matches!(
649            check_channel_binding(&bare, &chan(), Epoch(0)),
650            Err(StreamError::MissingTag(_))
651        ));
652    }
653
654    #[test]
655    fn oversize_plaintext_is_refused_at_build_time() {
656        let author = Keys::generate();
657        let big = "x".repeat(NIP44_MAX_PLAINTEXT + 1);
658        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), &big, vec![], 1_000);
659        assert!(matches!(
660            seal_content(&rumor, SealForm::Encrypted, &group()),
661            Err(StreamError::Oversize(_))
662        ));
663    }
664
665    #[test]
666    fn ephemeral_wrap_round_trips_and_bad_wrap_kind_rejects() {
667        let author = Keys::generate();
668        let tags = channel_binding_tags(&chan(), Epoch(0));
669        let rumor = build_rumor_ms(kind::TYPING, author.public_key(), "", tags, 5_000);
670        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
671        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP_EPHEMERAL, Timestamp::from_secs(5)).unwrap();
672        assert_eq!(wrap.kind.as_u16(), KIND_WRAP_EPHEMERAL);
673        assert_eq!(open_wrap(&wrap, &group()).unwrap().rumor.kind.as_u16(), kind::TYPING);
674        assert!(matches!(
675            wrap_seal(&seal, &group(), 1058, Timestamp::from_secs(5)),
676            Err(StreamError::BadWrapKind(1058))
677        ));
678    }
679
680    #[test]
681    fn rewrap_preserves_rumor_id_and_signature_across_epochs() {
682        let author = Keys::generate();
683        let wrap = send(&author, &group(), SealForm::Plaintext, "the head edition", 9_000);
684        let opened = open_wrap(&wrap, &group()).unwrap();
685
686        // Compaction: carry the verified seal into the next epoch's stream.
687        let next = channel_group_key(&[7u8; 32], &chan(), Epoch(1));
688        let (rewrapped, _) = rewrap_seal(&opened.seal, &next, Timestamp::from_secs(2_000)).unwrap();
689        let reopened = open_wrap(&rewrapped, &next).unwrap();
690
691        assert_eq!(reopened.rumor_id, opened.rumor_id, "rumor id survives the re-wrap");
692        assert_eq!(reopened.author, author.public_key(), "authorship survives");
693        assert_eq!(reopened.seal.sig, opened.seal.sig, "the original signature rides verbatim");
694        assert_ne!(reopened.wrapper_id, opened.wrapper_id, "outer identity differs per wrap");
695
696        // Encrypted seals must refuse to re-wrap (sig binds the old key's ciphertext).
697        let enc = send(&author, &group(), SealForm::Encrypted, "no", 9_000);
698        let enc_opened = open_wrap(&enc, &group()).unwrap();
699        assert!(matches!(
700            rewrap_seal(&enc_opened.seal, &next, Timestamp::from_secs(2_000)),
701            Err(StreamError::NotRewrappable)
702        ));
703    }
704
705    #[test]
706    fn wrap_p_tag_is_ephemeral_not_the_stream_or_author() {
707        let author = Keys::generate();
708        let g = group();
709        let tags = channel_binding_tags(&chan(), Epoch(0));
710        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
711        let seal = build_seal(&rumor, SealForm::Encrypted, &g, &author).unwrap();
712        let (wrap, ephemeral) = wrap_seal(&seal, &g, KIND_WRAP, Timestamp::from_secs(1)).unwrap();
713        let p = wrap
714            .tags
715            .iter()
716            .find_map(|t| {
717                let s = t.as_slice();
718                (s.len() >= 2 && s[0] == "p").then(|| s[1].clone())
719            })
720            .expect("wrap carries a p tag");
721        assert_eq!(p, ephemeral.public_key().to_hex());
722        assert_ne!(p, g.pk_hex());
723        assert_ne!(p, author.public_key().to_hex());
724    }
725}