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