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