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.
318#[allow(clippy::too_many_arguments)]
319pub async fn seal_and_wrap_signed<S: crate::signer::VectorSigner + ?Sized>(
320    signer: &S,
321    author: PublicKey,
322    rumor: &UnsignedEvent,
323    form: SealForm,
324    group: &GroupKey,
325    wrap_kind: u16,
326    wrap_at: Timestamp,
327    extra_tags: &[Tag],
328) -> Result<(Event, Keys), StreamError> {
329    let content = seal_content(rumor, form, group)?;
330    let unsigned = EventBuilder::new(Kind::Custom(form.kind()), content)
331        .custom_created_at(rumor.created_at)
332        .finalize_unsigned_with_id(author);
333    let seal = signer
334        .sign_event_async(unsigned)
335        .await
336        .map_err(|e| StreamError::Sign(e.to_string()))?;
337    wrap_seal_with_tags(&seal, group, wrap_kind, wrap_at, extra_tags)
338}
339
340/// Re-wrap an already-verified PLAINTEXT seal into another stream (a compaction
341/// carrying a signed edition into a new epoch). The seal event is carried
342/// whole — its content string holds the rumor bytes verbatim, so the rumor id
343/// and the author's signature survive.
344pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, wrap_at: Timestamp) -> Result<(Event, Keys), StreamError> {
345    if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
346        return Err(StreamError::NotRewrappable);
347    }
348    wrap_seal(seal, new_group, KIND_WRAP, wrap_at)
349}
350
351// ── open side ────────────────────────────────────────────────────────────────
352
353/// Open and fully verify a stream wrap against the plane's group key.
354///
355/// Verification chain: wrap kind → wrap author == stream address → decrypt
356/// (the NIP-44 MAC under the members-only conversation key is the envelope
357/// gate — the wrap's own signature adds nothing an outsider couldn't also
358/// forge-or-not, so it isn't re-checked) → seal kind → seal Schnorr verify →
359/// rumor recover → rumor.pubkey == seal.pubkey → rumor.id == computed hash
360/// (never trust a claimed id) → strict ms resolve.
361pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
362    open_wrap_at(wrap, &group.pk(), group.conv_key(), false)
363}
364
365/// [`open_wrap`] against a stream READ VIEW: the address to verify by and the
366/// conversation key that opens the wraps — no signing secret needed. With
367/// `verify_wrap_sig` (a Write-Restricted Stream, CORD-01), the wrap's own
368/// signature is additionally checked: there the signer set is narrower than the
369/// readership, so unlike an ordinary stream wrap (signed with a key every
370/// reader holds — proves nothing) it proves a write-keyholder published this,
371/// and a reader MUST check it rather than lean on the relays having done so.
372pub fn open_wrap_at(
373    wrap: &Event,
374    address: &PublicKey,
375    conv_key: &nostr_sdk::prelude::nip44::v2::ConversationKey,
376    verify_wrap_sig: bool,
377) -> Result<OpenedStream, StreamError> {
378    let wrap_kind = wrap.kind.as_u16();
379    if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
380        return Err(StreamError::BadWrapKind(wrap_kind));
381    }
382    if wrap.pubkey != *address {
383        return Err(StreamError::WrongStream);
384    }
385    if verify_wrap_sig && wrap.verify().is_err() {
386        return Err(StreamError::BadWrapSignature);
387    }
388
389    let seal_json = open_nip44(conv_key, &wrap.content)?;
390    let seal: Event = Event::from_json(&seal_json).map_err(|e| StreamError::Parse(e.to_string()))?;
391    let seal_form = SealForm::from_kind(seal.kind.as_u16()).ok_or(StreamError::BadSealKind(seal.kind.as_u16()))?;
392    seal.verify().map_err(|_| StreamError::BadSealSignature)?;
393
394    let rumor_json = match seal_form {
395        SealForm::Plaintext => seal.content.clone(),
396        SealForm::Encrypted => open_nip44(conv_key, &seal.content)?,
397    };
398    let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| StreamError::Parse(e.to_string()))?;
399
400    if rumor.pubkey != seal.pubkey {
401        return Err(StreamError::AuthorMismatch);
402    }
403    // Never trust a claimed id: recompute from the serialized fields
404    // unconditionally (`ensure_id` is a no-op when an id is present, so it
405    // would wave a forged one through). An absent id just takes the computed one.
406    let computed = EventId::compute(&rumor.pubkey, &rumor.created_at, &rumor.kind, &rumor.tags, &rumor.content);
407    if let Some(claimed) = rumor.id {
408        if claimed != computed {
409            return Err(StreamError::BadRumorId);
410        }
411    }
412    rumor.id = Some(computed);
413    let at_ms = resolve_ms_strict(&rumor)?;
414
415    Ok(OpenedStream {
416        rumor_id: computed,
417        author: seal.pubkey,
418        seal_form,
419        seal,
420        wrapper_id: wrap.id,
421        at_ms,
422        rumor,
423    })
424}
425
426/// Enforce the Chat-plane binding (CORD-03 §3): the rumor MUST commit
427/// `["channel", id]` + `["epoch", n]`, strict-equal to the coordinate whose key
428/// decrypted the wrap; a mismatch (or duplicate/absent tag) is a splice — drop.
429pub fn check_channel_binding(rumor: &UnsignedEvent, channel_id: &ChannelId, epoch: Epoch) -> Result<(), StreamError> {
430    match unique_tag_unsigned(rumor, TAG_CHANNEL)? {
431        Some(c) if c == channel_id.to_hex() => {}
432        Some(_) => return Err(StreamError::ChannelMismatch),
433        None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
434    }
435    match unique_tag_unsigned(rumor, TAG_EPOCH)? {
436        Some(e) if e == epoch.0.to_string() => {}
437        Some(_) => return Err(StreamError::EpochMismatch),
438        None => return Err(StreamError::MissingTag(TAG_EPOCH)),
439    }
440    Ok(())
441}
442
443/// The standard chat binding tags for a rumor: `["channel", id]` + `["epoch", n]`.
444pub fn channel_binding_tags(channel_id: &ChannelId, epoch: Epoch) -> Vec<Tag> {
445    vec![
446        Tag::custom(TAG_CHANNEL, [channel_id.to_hex()]),
447        Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
448    ]
449}
450
451// ── helpers ──────────────────────────────────────────────────────────────────
452
453fn cap(len: usize) -> Result<(), StreamError> {
454    if len > NIP44_MAX_PLAINTEXT {
455        return Err(StreamError::Oversize(len));
456    }
457    Ok(())
458}
459
460fn open_nip44(conv_key: &ConversationKey, content_b64: &str) -> Result<String, StreamError> {
461    let ct = base64_simd::STANDARD
462        .decode_to_vec(content_b64.as_bytes())
463        .map_err(|e| StreamError::Decrypt(e.to_string()))?;
464    let pt = decrypt_to_bytes(conv_key, &ct).map_err(|e| StreamError::Decrypt(e.to_string()))?;
465    String::from_utf8(pt).map_err(|e| StreamError::Parse(e.to_string()))
466}
467
468/// Value of the tag named `name` on an unsigned rumor, requiring it to appear
469/// AT MOST ONCE (any keyholder can craft a rumor; a duplicated binding tag
470/// makes first-match nondeterministic — reject).
471fn unique_tag_unsigned(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
472    let mut found: Option<String> = None;
473    for t in rumor.tags.iter() {
474        let s = t.as_slice();
475        if s.len() >= 2 && s[0] == name {
476            if found.is_some() {
477                return Err(StreamError::DuplicateTag(name));
478            }
479            found = Some(s[1].clone());
480        }
481    }
482    Ok(found)
483}
484
485#[cfg(test)]
486mod tests {
487
488    /// Wrap raw `Keys` as the polymorphic signer. `VectorSigner` pins its error to
489    /// `SignerError`, which bare `Keys` doesn't satisfy, so tests go through the
490    /// same enum the app uses.
491    fn as_signer(k: &Keys) -> crate::signer::ActiveSigner {
492        crate::signer::ActiveSigner::Keys(k.clone())
493    }
494    use super::super::super::{ChannelId, Epoch};
495    use super::super::derive::channel_group_key;
496    use super::super::kind;
497    use super::*;
498
499    fn group() -> GroupKey {
500        channel_group_key(&[7u8; 32], &chan(), Epoch(0))
501    }
502
503    fn chan() -> ChannelId {
504        ChannelId([0xabu8; 32])
505    }
506
507    fn send(author: &Keys, group: &GroupKey, form: SealForm, content: &str, at_ms: u64) -> Event {
508        let tags = channel_binding_tags(&chan(), Epoch(0));
509        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), content, tags, at_ms);
510        let seal = build_seal(&rumor, form, group, author).unwrap();
511        wrap_seal(&seal, group, KIND_WRAP, Timestamp::from_secs(1_700_000_000)).unwrap().0
512    }
513
514    #[test]
515    fn encrypted_round_trip_preserves_author_content_and_ms() {
516        let author = Keys::generate();
517        let wrap = send(&author, &group(), SealForm::Encrypted, "Hey chat!", 1_686_840_217_417);
518        assert_eq!(wrap.kind.as_u16(), KIND_WRAP);
519        assert_eq!(wrap.pubkey, group().pk(), "wrap is signed by the stream key");
520
521        let opened = open_wrap(&wrap, &group()).unwrap();
522        assert_eq!(opened.author, author.public_key());
523        assert_eq!(opened.rumor.content, "Hey chat!");
524        assert_eq!(opened.at_ms, 1_686_840_217_417);
525        assert_eq!(opened.seal_form, SealForm::Encrypted);
526        check_channel_binding(&opened.rumor, &chan(), Epoch(0)).unwrap();
527    }
528
529    #[tokio::test]
530    async fn signer_seal_opens_identically_to_local_seal() {
531        // A signer that is a plain Keys (NOT the vault) must produce a wrap that
532        // opens to the same rumor + author as the local build_seal path — the seal
533        // half of the wire-identity guarantee the bunker/NIP-55 integration rests on.
534        let author = Keys::generate();
535        let g = group();
536        let tags = channel_binding_tags(&chan(), Epoch(0));
537        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "parity", tags, 1_686_840_217_000);
538        let wrap_at = Timestamp::from_secs(1_686_840_217);
539
540        let seal_l = build_seal(&rumor, SealForm::Encrypted, &g, &author).unwrap();
541        let (wrap_l, _) = wrap_seal(&seal_l, &g, KIND_WRAP, wrap_at).unwrap();
542        let (wrap_s, _) = seal_and_wrap_signed(&as_signer(&author), author.public_key(), &rumor, SealForm::Encrypted, &g, KIND_WRAP, wrap_at, &[])
543            .await
544            .unwrap();
545
546        let opened_l = open_wrap(&wrap_l, &g).unwrap();
547        let opened_s = open_wrap(&wrap_s, &g).unwrap();
548        assert_eq!(opened_l.rumor_id, opened_s.rumor_id, "same inner rumor id via either seal path");
549        assert_eq!(opened_s.author, author.public_key(), "signer seal carries the author identity");
550        assert_eq!(opened_l.seal_form, opened_s.seal_form);
551        assert_eq!(opened_s.rumor.content, "parity");
552    }
553
554    #[test]
555    fn plaintext_seal_round_trip_carries_rumor_verbatim() {
556        let author = Keys::generate();
557        let wrap = send(&author, &group(), SealForm::Plaintext, "an edition", 1_686_840_217_000);
558        let opened = open_wrap(&wrap, &group()).unwrap();
559        assert_eq!(opened.seal_form, SealForm::Plaintext);
560        // The seal's content IS the rumor's JSON — the compaction contract.
561        assert_eq!(opened.seal.content, opened.rumor.as_json());
562    }
563
564    #[test]
565    fn wrong_stream_key_cannot_open() {
566        let author = Keys::generate();
567        let wrap = send(&author, &group(), SealForm::Encrypted, "secret", 1_000);
568        let other = channel_group_key(&[8u8; 32], &chan(), Epoch(0));
569        // Different address entirely → WrongStream before any decrypt attempt.
570        assert!(matches!(open_wrap(&wrap, &other), Err(StreamError::WrongStream)));
571    }
572
573    #[test]
574    fn tampered_wrap_content_fails_the_mac() {
575        let author = Keys::generate();
576        let mut wrap = send(&author, &group(), SealForm::Encrypted, "x", 1_000);
577        let mut json: serde_json::Value = serde_json::from_str(&wrap.as_json()).unwrap();
578        let ct = json["content"].as_str().unwrap().to_string();
579        // Flip a mid-payload character (the first char of a NIP-44 base64 payload
580        // is always 'A' — the 0x02 version byte — so tampering there is a no-op).
581        let mut bytes = ct.into_bytes();
582        bytes[20] = if bytes[20] == b'B' { b'C' } else { b'B' };
583        json["content"] = serde_json::Value::String(String::from_utf8(bytes).unwrap());
584        wrap = Event::from_json(json.to_string()).unwrap();
585        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::Decrypt(_))));
586    }
587
588    #[test]
589    fn forged_seal_signature_is_rejected() {
590        let author = Keys::generate();
591        let impostor = Keys::generate();
592        let tags = channel_binding_tags(&chan(), Epoch(0));
593        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "hi", tags, 1_000);
594        // Seal signed by the impostor but CLAIMING the author's pubkey: rebuild
595        // the seal event JSON with a swapped pubkey — the sig no longer matches.
596        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &impostor).unwrap();
597        let mut json: serde_json::Value = serde_json::from_str(&seal.as_json()).unwrap();
598        json["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
599        // (id also changes with pubkey — recompute is not attempted; both id and
600        // sig checks are downstream of Event::from_json/verify.)
601        let forged = Event::from_json(json.to_string());
602        let Ok(forged) = forged else { return }; // strict parsers may reject outright — equally a pass
603        let (wrap, _) = wrap_seal(&forged, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
604        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadSealSignature)));
605    }
606
607    #[test]
608    fn rumor_author_must_match_seal_author() {
609        let author = Keys::generate();
610        let other = Keys::generate();
611        let tags = channel_binding_tags(&chan(), Epoch(0));
612        // Rumor claims `other` as its author, but the seal is signed by `author`.
613        let rumor = build_rumor_ms(kind::MESSAGE, other.public_key(), "spoof", tags, 1_000);
614        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
615        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
616        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::AuthorMismatch)));
617    }
618
619    #[test]
620    fn forged_rumor_id_is_rejected() {
621        let author = Keys::generate();
622        let tags = channel_binding_tags(&chan(), Epoch(0));
623        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "real", tags, 1_000);
624        let mut json: serde_json::Value = serde_json::from_str(&rumor.as_json()).unwrap();
625        json["id"] = serde_json::Value::String("00".repeat(32));
626        let forged_json = json.to_string();
627        // Hand-build a seal around the forged rumor bytes (plaintext form so the
628        // bytes ride verbatim).
629        let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged_json)
630            .custom_created_at(rumor.created_at)
631            .finalize(&author)
632            .unwrap();
633        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
634        assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadRumorId)));
635    }
636
637    #[test]
638    fn ms_is_strict_absent_is_zero_invalid_is_dropped() {
639        let author = Keys::generate();
640        // Absent ms = offset 0.
641        let rumor = build_rumor_secs(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
642        assert_eq!(resolve_ms_strict(&rumor).unwrap(), 1_000_000);
643        // 999 is the max valid offset.
644        let ok = build_rumor_secs(
645            kind::MESSAGE,
646            author.public_key(),
647            "x",
648            vec![Tag::custom("ms", ["999".to_string()])],
649            1_000,
650        );
651        assert_eq!(resolve_ms_strict(&ok).unwrap(), 1_000_999);
652        // 1000, negatives, non-integers, leading zeros, and a leading '+' (which
653        // `u64::from_str` would otherwise accept as a second byte-encoding):
654        // malformed — DROP, never clamp.
655        for bad in ["1000", "-1", "12.5", "abc", "007", "", "+5", "+0", "+000", "+999"] {
656            let r = build_rumor_secs(
657                kind::MESSAGE,
658                author.public_key(),
659                "x",
660                vec![Tag::custom("ms", [bad.to_string()])],
661                1_000,
662            );
663            assert!(
664                matches!(resolve_ms_strict(&r), Err(StreamError::BadMs)),
665                "ms={bad:?} must be malformed"
666            );
667        }
668    }
669
670    #[test]
671    fn a_valueless_ms_tag_is_malformed_and_a_duplicate_takes_the_first() {
672        // A present-but-valueless ["ms"] must be BadMs, not treated as absent (a
673        // silent offset-0 default would honor a rumor a spec-strict peer drops).
674        let author = Keys::generate();
675        let bare = build_rumor_secs(
676            kind::MESSAGE,
677            author.public_key(),
678            "x",
679            vec![Tag::custom("ms", Vec::<String>::new())],
680            1_000,
681        );
682        assert!(matches!(resolve_ms_strict(&bare), Err(StreamError::BadMs)));
683        // A valueless FIRST occurrence is still malformed: first-wins picks it,
684        // and it carries no value to interpret. A later valued tag can't rescue it.
685        let two = build_rumor_secs(
686            kind::MESSAGE,
687            author.public_key(),
688            "x",
689            vec![
690                Tag::custom("ms", Vec::<String>::new()),
691                Tag::custom("ms", ["5".to_string()]),
692            ],
693            1_000,
694        );
695        assert!(matches!(resolve_ms_strict(&two), Err(StreamError::BadMs)));
696        // Two VALUED ms tags: the first wins, matching Armada. Rejecting the
697        // rumor instead made the two clients disagree on whether the event
698        // exists — and `ms` orders messages, Guestbook recency and List
699        // tiebreaks, so that reached membership. Conceding costs nothing: `ms`
700        // is the publisher's own value, so a second tag buys an attacker no
701        // reach a single one didn't already give them.
702        let two_valued = build_rumor_secs(
703            kind::MESSAGE,
704            author.public_key(),
705            "x",
706            vec![
707                Tag::custom("ms", ["1".to_string()]),
708                Tag::custom("ms", ["2".to_string()]),
709            ],
710            1_000,
711        );
712        assert_eq!(resolve_ms_strict(&two_valued).unwrap(), 1_000_001, "the FIRST ms wins");
713    }
714
715    #[test]
716    fn tag_numbers_are_spec_shaped_decimals() {
717        use crate::community::edition::is_tag_decimal;
718        // CORD-01 §5: "its decimal form with no leading zeros".
719        for good in ["4", "0", "1099511627776"] {
720            assert!(is_tag_decimal(good), "{good:?} is decimal form");
721        }
722        for bad in ["04", "007", "00", "+4", "-4", "0x4", "1e2", " 4", "4 ", "", "4.0"] {
723            assert!(!is_tag_decimal(bad), "{bad:?} is NOT decimal form");
724        }
725    }
726
727    #[test]
728    fn a_citation_version_must_be_plain_digits() {
729        // CORD-01 §5: a tag number rides as its decimal form. `u64::from_str`
730        // accepts a leading `+`, which Armada's digit check rejects — so an
731        // action citing "+5" would be honored by one client and parked by the
732        // other. Same guard `resolve_ms_strict` already applies to `ms`.
733        let eid = "ab".repeat(32);
734        let hash = "cd".repeat(32);
735        let cite = |v: &str| {
736            let tags = nostr_sdk::prelude::Tags::from_list(vec![Tag::custom(
737                "vac",
738                [eid.clone(), v.to_string(), hash.clone()],
739            )]);
740            crate::community::edition::AuthorityCitation::from_tags(&tags)
741        };
742        assert!(cite("5").is_some(), "a plain decimal is the shape the spec names");
743        assert!(cite("+5").is_none(), "a leading plus is not decimal form");
744        assert!(cite("").is_none());
745        assert!(cite("5x").is_none());
746        assert!(cite("05").is_none(), "no leading zeros (CORD-01 §5)");
747    }
748
749    #[test]
750    fn binding_rejects_splices_and_duplicates() {
751        let author = Keys::generate();
752        let tags = channel_binding_tags(&chan(), Epoch(0));
753        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
754        // Wrong channel and wrong epoch both reject.
755        assert!(matches!(
756            check_channel_binding(&rumor, &ChannelId([0xcd; 32]), Epoch(0)),
757            Err(StreamError::ChannelMismatch)
758        ));
759        assert!(matches!(
760            check_channel_binding(&rumor, &chan(), Epoch(1)),
761            Err(StreamError::EpochMismatch)
762        ));
763        // A duplicated binding tag is ambiguous — rejected outright.
764        let mut tags = channel_binding_tags(&chan(), Epoch(0));
765        tags.extend(channel_binding_tags(&chan(), Epoch(0)));
766        let dup = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
767        assert!(matches!(
768            check_channel_binding(&dup, &chan(), Epoch(0)),
769            Err(StreamError::DuplicateTag(_))
770        ));
771        // Missing binding tags reject too.
772        let bare = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
773        assert!(matches!(
774            check_channel_binding(&bare, &chan(), Epoch(0)),
775            Err(StreamError::MissingTag(_))
776        ));
777    }
778
779    #[test]
780    fn oversize_plaintext_is_refused_at_build_time() {
781        let author = Keys::generate();
782        let big = "x".repeat(NIP44_MAX_PLAINTEXT + 1);
783        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), &big, vec![], 1_000);
784        assert!(matches!(
785            seal_content(&rumor, SealForm::Encrypted, &group()),
786            Err(StreamError::Oversize(_))
787        ));
788    }
789
790    #[test]
791    fn ephemeral_wrap_round_trips_and_bad_wrap_kind_rejects() {
792        let author = Keys::generate();
793        let tags = channel_binding_tags(&chan(), Epoch(0));
794        let rumor = build_rumor_ms(kind::TYPING, author.public_key(), "", tags, 5_000);
795        let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
796        let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP_EPHEMERAL, Timestamp::from_secs(5)).unwrap();
797        assert_eq!(wrap.kind.as_u16(), KIND_WRAP_EPHEMERAL);
798        assert_eq!(open_wrap(&wrap, &group()).unwrap().rumor.kind.as_u16(), kind::TYPING);
799        assert!(matches!(
800            wrap_seal(&seal, &group(), 1058, Timestamp::from_secs(5)),
801            Err(StreamError::BadWrapKind(1058))
802        ));
803    }
804
805    #[test]
806    fn rewrap_preserves_rumor_id_and_signature_across_epochs() {
807        let author = Keys::generate();
808        let wrap = send(&author, &group(), SealForm::Plaintext, "the head edition", 9_000);
809        let opened = open_wrap(&wrap, &group()).unwrap();
810
811        // Compaction: carry the verified seal into the next epoch's stream.
812        let next = channel_group_key(&[7u8; 32], &chan(), Epoch(1));
813        let (rewrapped, _) = rewrap_seal(&opened.seal, &next, Timestamp::from_secs(2_000)).unwrap();
814        let reopened = open_wrap(&rewrapped, &next).unwrap();
815
816        assert_eq!(reopened.rumor_id, opened.rumor_id, "rumor id survives the re-wrap");
817        assert_eq!(reopened.author, author.public_key(), "authorship survives");
818        assert_eq!(reopened.seal.sig, opened.seal.sig, "the original signature rides verbatim");
819        assert_ne!(reopened.wrapper_id, opened.wrapper_id, "outer identity differs per wrap");
820
821        // Encrypted seals must refuse to re-wrap (sig binds the old key's ciphertext).
822        let enc = send(&author, &group(), SealForm::Encrypted, "no", 9_000);
823        let enc_opened = open_wrap(&enc, &group()).unwrap();
824        assert!(matches!(
825            rewrap_seal(&enc_opened.seal, &next, Timestamp::from_secs(2_000)),
826            Err(StreamError::NotRewrappable)
827        ));
828    }
829
830    #[test]
831    fn wrap_p_tag_is_ephemeral_not_the_stream_or_author() {
832        let author = Keys::generate();
833        let g = group();
834        let tags = channel_binding_tags(&chan(), Epoch(0));
835        let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
836        let seal = build_seal(&rumor, SealForm::Encrypted, &g, &author).unwrap();
837        let (wrap, ephemeral) = wrap_seal(&seal, &g, KIND_WRAP, Timestamp::from_secs(1)).unwrap();
838        let p = wrap
839            .tags
840            .iter()
841            .find_map(|t| {
842                let s = t.as_slice();
843                (s.len() >= 2 && s[0] == "p").then(|| s[1].clone())
844            })
845            .expect("wrap carries a p tag");
846        assert_eq!(p, ephemeral.public_key().to_hex());
847        assert_ne!(p, g.pk_hex());
848        assert_ne!(p, author.public_key().to_hex());
849    }
850}