Skip to main content

vector_core/community/v2/
pins.rs

1//! Pins — CORD-04 §7. A pin does not quote a message; it proves one.
2//!
3//! One Pin List per Channel on the Control Plane (vsk 11, coordinate
4//! `pins_locator(community_id, channel_id)`), replaced entire per edit like the
5//! Banlist. Each entry carries the original kind-20013 seal verbatim plus the
6//! message's disclosed NIP-44 keys, so any reader able to open the list's form
7//! verifies author, words, Channel, and signed time — holding no history and no
8//! old keys. Compaction re-wraps the head across rotations, which is the whole
9//! point of the placement.
10//!
11//! Wire format shared with Armada's `pins.ts` — entry JSON, both content forms,
12//! and the caps are cross-client surface. Divergence silently breaks pin
13//! verification between clients.
14
15use nostr_sdk::prelude::*;
16use serde::{Deserialize, Serialize};
17
18use super::kind;
19use super::pin_keys;
20use super::stream::OpenedStream;
21
22/// Structural caps (CORD-04 §7 Limits) — a violating edition reads as EMPTY.
23pub const PIN_MAX_ENTRIES: usize = 25;
24pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
25
26/// The seal kind a proof requires (an encrypted chat seal).
27const KIND_SEAL_ENCRYPTED: u16 = 20013;
28
29/// An Edit's proof bundle: the same disclosure, for the revision.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PinEditBundle {
32    pub seal: Event,
33    pub keys: String,
34}
35
36/// One wire entry. Optional fields are omitted when absent (matching the
37/// reference implementation's JSON), and unknown fields are carried through
38/// `extra` so republishing an entry never strips what a newer client added.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PinEntry {
41    /// The original kind-20013 seal event: fields carried exactly, content
42    /// string unaltered.
43    pub seal: Event,
44    /// 76-byte lowercase hex: chacha_key[32] || chacha_nonce[12] || hmac_key[32].
45    pub keys: String,
46    /// Optional, UNVERIFIED locator hint for jump-to-context.
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub wrap: Option<String>,
49    /// The newest provable Edit, for readers who hold no Chat plane (§7 Edits).
50    /// At most one, ever: Edits target the ORIGINAL rumor and never each other,
51    /// so a later Edit REPLACES this rather than appending.
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub edit: Option<PinEditBundle>,
54    #[serde(flatten)]
55    pub extra: serde_json::Map<String, serde_json::Value>,
56}
57
58/// A pin that passed the full §7 verification — safe to render.
59#[derive(Debug, Clone, Serialize)]
60pub struct VerifiedPin {
61    /// Recomputed from the decrypted bytes; never the embedded field.
62    pub rumor_id: String,
63    /// The seal's signer == the rumor's author (hex).
64    pub author: String,
65    pub kind: u16,
66    pub content: String,
67    pub tags: Vec<Vec<String>>,
68    /// The message's own epoch tag — derives the plane address for jump-to-context.
69    pub epoch: Option<String>,
70    /// Ordering basis: created_at*1000 + ms tag.
71    pub ms: u64,
72    pub created_at: u64,
73    /// Untrusted locator hint, if the entry carried one.
74    pub wrap_hint: Option<String>,
75    /// Set when a proven Edit superseded the original's words.
76    pub edited: Option<EditedContent>,
77    /// The wire entry, verbatim — for republishing (re-wraps, omissions).
78    #[serde(skip_serializing)]
79    pub entry: PinEntry,
80}
81
82#[derive(Debug, Clone, Serialize)]
83pub struct EditedContent {
84    pub content: String,
85    pub ms: u64,
86}
87
88/// Why a message could not be pinned — each cause needs a different answer.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum PinBuildFailure {
91    /// The seal is not an encrypted chat seal (plaintext seals carry no NIP-44
92    /// payload to disclose).
93    NotEncrypted,
94    /// The conversation key does not open this payload — the message is from an
95    /// epoch this client does not hold, or the payload is malformed.
96    BadPayload,
97    /// The built entry failed its own verification; publishing it would burn
98    /// list budget on a proof no reader accepts.
99    Unverifiable,
100}
101
102fn tag_value<'a>(tags: &'a [Vec<String>], name: &str) -> Option<&'a str> {
103    tags.iter()
104        .find(|t| t.first().map(String::as_str) == Some(name))
105        .and_then(|t| t.get(1))
106        .map(String::as_str)
107}
108
109/// Ordering basis (CORD-02 §4): `created_at * 1000 + ms`, out-of-range ms as 0.
110fn resolve_ms(created_at: u64, tags: &[Vec<String>]) -> u64 {
111    let ms = tag_value(tags, "ms")
112        .and_then(|raw| raw.parse::<u64>().ok())
113        .filter(|ms| *ms <= 999)
114        .unwrap_or(0);
115    created_at * 1000 + ms
116}
117
118/// Build a pin entry from an opened chat message, with the reason attached on
119/// refusal. Requires the Channel's stream conversation key at the message's
120/// epoch — i.e. the pinner can read what they pin. A pinner told "that message
121/// is from an epoch you no longer hold" when the real cause is a non-encrypted
122/// seal would retry forever, so the caller gets the distinction.
123pub fn build_pin_entry(
124    opened: &OpenedStream,
125    conv_key: &[u8; 32],
126    channel_id_hex: &str,
127) -> Result<PinEntry, PinBuildFailure> {
128    let seal = &opened.seal;
129    if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
130        return Err(PinBuildFailure::NotEncrypted);
131    }
132    let keys =
133        pin_keys::disclose_keys_for(&seal.content, conv_key).ok_or(PinBuildFailure::BadPayload)?;
134    // Deriving keys is not the same as being able to read: the expansion
135    // succeeds under ANY conversation key, and a message written under an epoch
136    // we no longer hold only fails later, at the MAC. Check here so "you don't
137    // hold these keys" and "this proof doesn't hold up" stay different answers.
138    if pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys).is_none() {
139        return Err(PinBuildFailure::BadPayload);
140    }
141    let entry = PinEntry {
142        seal: seal.clone(),
143        keys: pin_keys::encode_message_keys(&keys),
144        wrap: Some(opened.wrapper_id.to_hex()),
145        edit: None,
146        extra: Default::default(),
147    };
148    // Refuse to build an entry that would not verify — the same gate every
149    // reader applies, run before it costs list budget.
150    if verify_pin_entry(&entry, channel_id_hex).is_none() {
151        return Err(PinBuildFailure::Unverifiable);
152    }
153    Ok(entry)
154}
155
156/// Build an Edit's proof bundle from its opened stream — the same disclosure
157/// as an entry, for the revision (§7 Edits). Verification-mirrored like
158/// [`build_pin_entry`]: refuse to build what `verify_edit_bundle` would drop.
159pub fn build_pin_edit_bundle(
160    edit_opened: &OpenedStream,
161    conv_key: &[u8; 32],
162    original_author: &str,
163    original_rumor_id: &str,
164    channel_id_hex: &str,
165) -> Result<PinEditBundle, PinBuildFailure> {
166    let seal = &edit_opened.seal;
167    if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
168        return Err(PinBuildFailure::NotEncrypted);
169    }
170    let keys =
171        pin_keys::disclose_keys_for(&seal.content, conv_key).ok_or(PinBuildFailure::BadPayload)?;
172    if pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys).is_none() {
173        return Err(PinBuildFailure::BadPayload);
174    }
175    let bundle = PinEditBundle { seal: seal.clone(), keys: pin_keys::encode_message_keys(&keys) };
176    if verify_edit_bundle(&bundle, original_author, original_rumor_id, channel_id_hex).is_none() {
177        return Err(PinBuildFailure::Unverifiable);
178    }
179    Ok(bundle)
180}
181
182/// The rumor fields step 4 inspects, parsed strictly from the decrypted bytes.
183#[derive(Deserialize)]
184struct RumorFields {
185    pubkey: String,
186    kind: u16,
187    content: String,
188    created_at: u64,
189    tags: Vec<Vec<String>>,
190}
191
192/// Recompute the rumor's NIP-01 id from its decrypted fields — an embedded
193/// `id` is never trusted.
194fn recompute_rumor_id(r: &RumorFields) -> Option<String> {
195    let pubkey = PublicKey::from_hex(&r.pubkey).ok()?;
196    let tags: Vec<Tag> = r.tags.iter().map(|t| Tag::parse(t.clone()).ok()).collect::<Option<_>>()?;
197    let id = EventId::compute(
198        &pubkey,
199        &Timestamp::from(r.created_at),
200        &Kind::from(r.kind),
201        &Tags::from_list(tags),
202        &r.content,
203    );
204    Some(id.to_hex())
205}
206
207/// The §7 verification, holding nothing but the pin and the list's Channel:
208/// seal kind + signature → MAC → decrypt → rumor checks (author equality, chat
209/// kind, channel binding) → recomputed id. `None` on ANY failure; a failed
210/// entry is dropped alone, its edition folds normally.
211pub fn verify_pin_entry(entry: &PinEntry, channel_id_hex: &str) -> Option<VerifiedPin> {
212    let seal = &entry.seal;
213    // Step 1 — an encrypted chat seal, honestly signed. `verify` checks both
214    // the id-hash and the Schnorr signature.
215    if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
216        return None;
217    }
218
219    // Steps 2–3 — MAC, then decrypt, under the disclosed keys alone.
220    let keys = pin_keys::decode_message_keys(&entry.keys)?;
221    let plaintext = pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys)?;
222
223    // Step 4 — the rumor's own claims, each strict.
224    let rumor: RumorFields = serde_json::from_str(&plaintext).ok()?;
225    // NIP-59's impersonation check: renderers display rumor fields, so a seal
226    // honestly signed around a rumor claiming another author must fail.
227    if rumor.pubkey != seal.pubkey.to_hex() {
228        return None;
229    }
230    if rumor.kind != kind::MESSAGE && rumor.kind != kind::COMMENT {
231        return None;
232    }
233    // The rumor names its Channel under the author's signature (CORD-01
234    // Binding); strict equality against the list's own Channel, absence
235    // failing — without this, a private Channel's keyholder could pin its
236    // messages into a public list, disclosing them Community-wide with proof.
237    if channel_id_hex.len() != 64
238        || !channel_id_hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
239        || tag_value(&rumor.tags, "channel") != Some(channel_id_hex)
240    {
241        return None;
242    }
243
244    // Step 5 — identity from the bytes, never from a field.
245    let rumor_id = recompute_rumor_id(&rumor)?;
246
247    // The Edit bundle, if the entry carries one. A bad bundle drops the EDIT,
248    // never the pin: the original is still proven, and refusing it outright
249    // would hide a message because someone attached a bad correction.
250    let edited = entry
251        .edit
252        .as_ref()
253        .and_then(|b| verify_edit_bundle(b, &rumor.pubkey, &rumor_id, channel_id_hex));
254
255    Some(VerifiedPin {
256        author: rumor.pubkey,
257        kind: rumor.kind,
258        content: edited.as_ref().map(|e| e.content.clone()).unwrap_or_else(|| rumor.content.clone()),
259        epoch: tag_value(&rumor.tags, "epoch").map(str::to_string),
260        ms: resolve_ms(rumor.created_at, &rumor.tags),
261        created_at: rumor.created_at,
262        tags: rumor.tags,
263        rumor_id,
264        wrap_hint: entry.wrap.clone(),
265        edited,
266        entry: entry.clone(),
267    })
268}
269
270/// An Edit bundle proves the SAME author revised THIS message: the five steps
271/// of [`verify_pin_entry`] with kind `3302` substituted, plus the fold's own
272/// two rules — author equality (nobody else may revise another member's words)
273/// and an `e` tag naming the original's recomputed rumor id.
274fn verify_edit_bundle(
275    bundle: &PinEditBundle,
276    original_author: &str,
277    original_rumor_id: &str,
278    channel_id_hex: &str,
279) -> Option<EditedContent> {
280    let seal = &bundle.seal;
281    if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
282        return None;
283    }
284    // Author equality is checkable before any crypto: a bundle sealed by anyone
285    // but the original's author cannot revise it, whatever it decrypts to.
286    if seal.pubkey.to_hex() != original_author {
287        return None;
288    }
289    if seal.verify().is_err() {
290        return None;
291    }
292
293    let keys = pin_keys::decode_message_keys(&bundle.keys)?;
294    let plaintext = pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys)?;
295    let rumor: RumorFields = serde_json::from_str(&plaintext).ok()?;
296    if rumor.pubkey != seal.pubkey.to_hex() {
297        return None;
298    }
299    if rumor.kind != kind::EDIT {
300        return None;
301    }
302    // The Edit binds to this Channel too — the revision path opens no door the
303    // entry path closes.
304    if tag_value(&rumor.tags, "channel") != Some(channel_id_hex) {
305        return None;
306    }
307    if tag_value(&rumor.tags, "e") != Some(original_rumor_id) {
308        return None;
309    }
310    Some(EditedContent {
311        content: rumor.content,
312        ms: resolve_ms(rumor.created_at, &rumor.tags),
313    })
314}
315
316// ── The list's two self-describing content forms ─────────────────────────────
317
318#[derive(Serialize, Deserialize)]
319struct PlainForm {
320    entries: Vec<PinEntry>,
321}
322
323/// Serialize a pin list's `content` for a PUBLIC Channel (plaintext — the
324/// plane's wrap is the gate). Errors on a cap violation: a writer must never
325/// publish an edition every reader would read as empty.
326pub fn serialize_public_pin_list(entries: &[PinEntry]) -> Result<String, String> {
327    let content = serde_json::to_string(&PlainForm { entries: entries.to_vec() })
328        .map_err(|e| e.to_string())?;
329    assert_caps(entries.len(), &content)?;
330    Ok(content)
331}
332
333/// Serialize for a PRIVATE Channel: the entries sealed under the Channel's
334/// group conversation key at `epoch`. Both caps are checked on the final
335/// carried bytes, the sealed envelope living INSIDE the byte cap.
336pub fn serialize_sealed_pin_list(
337    entries: &[PinEntry],
338    conv_key: &nostr_sdk::prelude::nip44::v2::ConversationKey,
339    epoch: u64,
340) -> Result<String, String> {
341    if entries.len() > PIN_MAX_ENTRIES {
342        return Err(format!("pin list exceeds {PIN_MAX_ENTRIES} entries"));
343    }
344    let inner = serde_json::to_string(&PlainForm { entries: entries.to_vec() })
345        .map_err(|e| e.to_string())?;
346    let sealed_raw = crate::community::cipher::encrypt_with_random_nonce(conv_key, inner.as_bytes())?;
347    let sealed = base64_simd::STANDARD.encode_to_string(&sealed_raw);
348    let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
349    assert_caps(entries.len(), &content)?;
350    Ok(content)
351}
352
353fn assert_caps(count: usize, content: &str) -> Result<(), String> {
354    if count > PIN_MAX_ENTRIES {
355        return Err(format!("pin list exceeds {PIN_MAX_ENTRIES} entries"));
356    }
357    let bytes = content.len();
358    if bytes > PIN_MAX_CONTENT_BYTES {
359        return Err(format!("pin list content is {bytes} bytes (cap {PIN_MAX_CONTENT_BYTES})"));
360    }
361    Ok(())
362}
363
364/// A read list: its entries, or the fact it stayed dark.
365pub struct ReadPinList {
366    pub entries: Vec<PinEntry>,
367    /// The sealed form under an epoch key this reader lacks. Darkness, not
368    /// violation — and a WRITER seeing this must withhold, never publish.
369    pub sealed: bool,
370}
371
372/// Read a pin list edition's `content` (§7 Limits): the byte cap judged on the
373/// exact carried bytes by every reader; the entry cap by whoever can open the
374/// form. A violating or unreadable-as-JSON edition reads as an EMPTY list —
375/// never refused from the fold.
376pub fn read_pin_list(
377    content: &str,
378    unseal_key: impl Fn(u64) -> Option<[u8; 32]>,
379) -> ReadPinList {
380    const EMPTY: fn() -> ReadPinList = || ReadPinList { entries: Vec::new(), sealed: false };
381    if content.len() > PIN_MAX_CONTENT_BYTES {
382        return EMPTY();
383    }
384    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) else {
385        return EMPTY();
386    };
387
388    // Public form: { "entries": [...] }
389    if parsed.get("entries").is_some() {
390        let Ok(form) = serde_json::from_value::<PlainForm>(parsed) else {
391            return EMPTY();
392        };
393        if form.entries.len() > PIN_MAX_ENTRIES {
394            return EMPTY();
395        }
396        return ReadPinList { entries: form.entries, sealed: false };
397    }
398
399    // Sealed form: { "epoch": "<decimal u64>", "sealed": "<base64>" }
400    let (Some(epoch_str), Some(sealed)) = (
401        parsed.get("epoch").and_then(|v| v.as_str()),
402        parsed.get("sealed").and_then(|v| v.as_str()),
403    ) else {
404        return EMPTY();
405    };
406    if epoch_str != "0" && (epoch_str.is_empty() || epoch_str.starts_with('0')) {
407        return EMPTY();
408    }
409    let Ok(epoch) = epoch_str.parse::<u64>() else {
410        return EMPTY();
411    };
412    let Some(key) = unseal_key(epoch) else {
413        return ReadPinList { entries: Vec::new(), sealed: true };
414    };
415    let Some(conv_key) = nostr_sdk::prelude::nip44::v2::ConversationKey::from_slice(&key).ok() else {
416        return EMPTY();
417    };
418    let Ok(raw) = base64_simd::STANDARD.decode_to_vec(sealed.as_bytes()) else {
419        return EMPTY();
420    };
421    let Ok(inner) = nostr_sdk::prelude::nip44::v2::decrypt_to_bytes(&conv_key, &raw) else {
422        return EMPTY();
423    };
424    let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
425        return EMPTY();
426    };
427    if form.entries.len() > PIN_MAX_ENTRIES {
428        return EMPTY();
429    }
430    ReadPinList { entries: form.entries, sealed: false }
431}
432
433// ── Deletion (§7): self-erasure outranks curation ────────────────────────────
434
435/// Whether a kind-5 kills this pin: matched by the RECOMPUTED rumor id against
436/// the delete's `e` tags, honored only when the delete's author equals the
437/// pin's proven author.
438pub fn pin_killed_by(pin: &VerifiedPin, delete_author_hex: &str, delete_tags: &[Vec<String>]) -> bool {
439    if delete_author_hex != pin.author {
440        return false;
441    }
442    delete_tags
443        .iter()
444        .any(|t| t.first().map(String::as_str) == Some("e") && t.get(1).map(String::as_str) == Some(pin.rumor_id.as_str()))
445}
446
447/// Attach the newest provable Edit to an entry (§7 Edits). Requires the
448/// Channel conversation key of the Edit's own epoch — i.e. the curator can
449/// read it. Returns the entry unchanged when the Edit cannot be proven, so a
450/// refresh never downgrades a good pin into a broken one.
451pub fn with_proven_edit(
452    entry: &PinEntry,
453    edit_opened: &OpenedStream,
454    conv_key: &[u8; 32],
455    channel_id_hex: &str,
456) -> PinEntry {
457    let seal = &edit_opened.seal;
458    if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
459        return entry.clone();
460    }
461    let Some(keys) = pin_keys::disclose_keys_for(&seal.content, conv_key) else {
462        return entry.clone();
463    };
464    let mut candidate = entry.clone();
465    candidate.edit = Some(PinEditBundle {
466        seal: seal.clone(),
467        keys: pin_keys::encode_message_keys(&keys),
468    });
469    // Only keep it if it actually verifies against this entry — the same gate a
470    // reader will apply, run before it costs list budget.
471    match verify_pin_entry(&candidate, channel_id_hex) {
472        Some(v) if v.edited.is_some() => candidate,
473        _ => entry.clone(),
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::community::v2::chat::{
481        build_edit_rumor, build_message_rumor, open_chat_event, seal_chat_rumor, ChatEvent,
482    };
483    use crate::community::v2::derive::channel_group_key;
484    use crate::community::{ChannelId, Epoch};
485
486    const AT: u64 = 1_686_840_217_417;
487    const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
488
489    fn chan() -> ChannelId {
490        ChannelId([0xab; 32])
491    }
492
493    fn chan_hex() -> String {
494        crate::simd::hex::bytes_to_hex_32(&chan().0)
495    }
496
497    fn group() -> crate::community::v2::derive::GroupKey {
498        channel_group_key(&[7u8; 32], &chan(), Epoch(0))
499    }
500
501    fn conv_bytes() -> [u8; 32] {
502        group().conv_key().as_bytes().try_into().unwrap()
503    }
504
505    /// A real opened chat message, through the production seal/open pipeline.
506    fn opened_message(author: &Keys, text: &str) -> OpenedStream {
507        let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), text, None, &[], vec![], AT);
508        let wrap = seal_chat_rumor(&rumor, &group(), author, WRAP_AT, false).unwrap().0;
509        match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
510            ChatEvent::Message { opened, .. } => opened,
511            other => panic!("expected Message, got {other:?}"),
512        }
513    }
514
515    fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
516        let opened = opened_message(author, text);
517        let entry = build_pin_entry(&opened, &conv_bytes(), &chan_hex()).unwrap();
518        (entry, opened)
519    }
520
521    #[test]
522    fn a_built_entry_verifies_and_proves_the_author_and_words() {
523        let author = Keys::generate();
524        let (entry, opened) = entry_for(&author, "pin me, I'm important");
525        let v = verify_pin_entry(&entry, &chan_hex()).expect("verifies");
526        assert_eq!(v.author, author.public_key().to_hex());
527        assert_eq!(v.content, "pin me, I'm important");
528        assert_eq!(v.rumor_id, opened.rumor_id.to_hex(), "identity = recomputed rumor id");
529        assert_eq!(v.ms, AT);
530        assert_eq!(v.epoch.as_deref(), Some("0"));
531        assert!(v.edited.is_none());
532    }
533
534    /// The channel binding: a keyholder must not be able to pin channel X's
535    /// message into channel Y's list, proof intact (§7 step 4).
536    #[test]
537    fn a_pin_cannot_cross_channels() {
538        let author = Keys::generate();
539        let (entry, _) = entry_for(&author, "private words");
540        let other = crate::simd::hex::bytes_to_hex_32(&[0xcd; 32]);
541        assert!(verify_pin_entry(&entry, &other).is_none());
542        // And a malformed channel id fails closed.
543        assert!(verify_pin_entry(&entry, "not-hex").is_none());
544        assert!(verify_pin_entry(&entry, "").is_none());
545    }
546
547    #[test]
548    fn tampering_with_the_disclosed_keys_or_seal_fails() {
549        let author = Keys::generate();
550        let (entry, _) = entry_for(&author, "immutable");
551        // Wrong keys: MAC fails.
552        let mut bad = entry.clone();
553        bad.keys = format!("{}{}", &entry.keys[2..], "00");
554        assert!(verify_pin_entry(&bad, &chan_hex()).is_none());
555        // A different author's seal around the same payload: signature check
556        // rejects a re-signed seal (id no longer matches its own content).
557        let mut forged = entry.clone();
558        forged.seal.pubkey = Keys::generate().public_key();
559        assert!(verify_pin_entry(&forged, &chan_hex()).is_none());
560    }
561
562    #[test]
563    fn a_proven_edit_replaces_the_words_and_a_foreign_one_is_refused() {
564        let author = Keys::generate();
565        let (entry, opened) = entry_for(&author, "teh typo");
566
567        // The author's own edit, through the real pipeline.
568        let edit_rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &opened.rumor_id.to_hex(), "the typo, fixed", AT + 5_000);
569        let wrap = seal_chat_rumor(&edit_rumor, &group(), &author, WRAP_AT, false).unwrap().0;
570        let edit_opened = match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
571            ChatEvent::Edit { opened, .. } => opened,
572            other => panic!("expected Edit, got {other:?}"),
573        };
574
575        let refreshed = with_proven_edit(&entry, &edit_opened, &conv_bytes(), &chan_hex());
576        let v = verify_pin_entry(&refreshed, &chan_hex()).unwrap();
577        assert_eq!(v.content, "the typo, fixed", "edited words render as current");
578        assert_eq!(v.edited.as_ref().unwrap().ms, AT + 5_000);
579
580        // A STRANGER's edit of the same message: refused, entry unchanged.
581        let stranger = Keys::generate();
582        let foreign_rumor = build_edit_rumor(stranger.public_key(), &chan(), Epoch(0), &opened.rumor_id.to_hex(), "hijacked", AT + 6_000);
583        let wrap = seal_chat_rumor(&foreign_rumor, &group(), &stranger, WRAP_AT, false).unwrap().0;
584        let foreign_opened = match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
585            ChatEvent::Edit { opened, .. } => opened,
586            other => panic!("expected Edit, got {other:?}"),
587        };
588        let unchanged = with_proven_edit(&entry, &foreign_opened, &conv_bytes(), &chan_hex());
589        assert!(unchanged.edit.is_none(), "a stranger's edit must not attach");
590    }
591
592    #[test]
593    fn public_list_round_trips_and_respects_caps() {
594        let author = Keys::generate();
595        let (entry, _) = entry_for(&author, "hello");
596        let content = serialize_public_pin_list(&[entry.clone()]).unwrap();
597        let read = read_pin_list(&content, |_| None);
598        assert!(!read.sealed);
599        assert_eq!(read.entries.len(), 1);
600        assert!(verify_pin_entry(&read.entries[0], &chan_hex()).is_some(), "survives the round trip");
601
602        // 26 entries: the writer refuses...
603        let many: Vec<PinEntry> = (0..26).map(|_| entry.clone()).collect();
604        assert!(serialize_public_pin_list(&many).is_err());
605        // ...and a reader treats a hand-built violating edition as EMPTY.
606        let violating = serde_json::json!({ "entries": many }).to_string();
607        assert_eq!(read_pin_list(&violating, |_| None).entries.len(), 0);
608    }
609
610    #[test]
611    fn sealed_list_is_dark_without_the_key_and_opens_with_it() {
612        let author = Keys::generate();
613        let (entry, _) = entry_for(&author, "private pin");
614        let content = serialize_sealed_pin_list(&[entry], group().conv_key(), 4).unwrap();
615
616        // No key: darkness, not violation — and NOT an empty public list.
617        let dark = read_pin_list(&content, |_| None);
618        assert!(dark.sealed);
619        assert!(dark.entries.is_empty());
620
621        // The right key at the named epoch opens it.
622        let lit = read_pin_list(&content, |epoch| (epoch == 4).then(conv_bytes));
623        assert!(!lit.sealed);
624        assert_eq!(lit.entries.len(), 1);
625        assert!(verify_pin_entry(&lit.entries[0], &chan_hex()).is_some());
626
627        // A wrong key reads as empty (decrypt fails), never as a panic.
628        let wrong = read_pin_list(&content, |_| Some([9u8; 32]));
629        assert!(wrong.entries.is_empty());
630    }
631
632    #[test]
633    fn garbage_content_reads_as_empty_never_panics() {
634        for bad in ["", "not json", "[]", "42", r#"{"entries": 7}"#, r#"{"epoch":"x","sealed":"y"}"#, r#"{"epoch":"04","sealed":"y"}"#] {
635            let read = read_pin_list(bad, |_| None);
636            assert!(read.entries.is_empty(), "{bad}");
637            assert!(!read.sealed, "{bad}");
638        }
639        // Hostile entries inside a well-formed list: dropped at verify, not a panic.
640        let hostile = r#"{"entries":[null, 42, {"seal": null}, {"keys": "zz"}]}"#;
641        let read = read_pin_list(hostile, |_| None);
642        for e in &read.entries {
643            assert!(verify_pin_entry(e, &chan_hex()).is_none());
644        }
645    }
646
647    #[test]
648    fn deletion_matches_by_recomputed_id_and_author_only() {
649        let author = Keys::generate();
650        let (entry, opened) = entry_for(&author, "delete me later");
651        let v = verify_pin_entry(&entry, &chan_hex()).unwrap();
652        let e_tag = vec![vec!["e".to_string(), opened.rumor_id.to_hex()]];
653
654        // The author's own delete kills it.
655        assert!(pin_killed_by(&v, &author.public_key().to_hex(), &e_tag));
656        // Someone else's delete of the same id does not.
657        assert!(!pin_killed_by(&v, &Keys::generate().public_key().to_hex(), &e_tag));
658        // The author's delete of a DIFFERENT message does not.
659        let other_tag = vec![vec!["e".to_string(), "ff".repeat(32)]];
660        assert!(!pin_killed_by(&v, &author.public_key().to_hex(), &other_tag));
661    }
662
663    /// Wire-shape guarantees shared with Armada: optional fields absent when
664    /// unset, unknown fields carried through a round trip.
665    #[test]
666    fn wire_json_matches_the_reference_shape() {
667        let author = Keys::generate();
668        let (mut entry, _) = entry_for(&author, "wire check");
669        entry.wrap = None;
670        let json = serde_json::to_value(&entry).unwrap();
671        assert!(json.get("wrap").is_none(), "unset wrap must be omitted, not null");
672        assert!(json.get("edit").is_none(), "unset edit must be omitted, not null");
673
674        // An unknown field a future client added survives our round trip.
675        let mut with_extra = serde_json::to_value(&entry).unwrap();
676        with_extra["future_field"] = serde_json::json!({"x": 1});
677        let reparsed: PinEntry = serde_json::from_value(with_extra).unwrap();
678        assert_eq!(reparsed.extra.get("future_field").unwrap()["x"], 1);
679        let re_serialized = serde_json::to_value(&reparsed).unwrap();
680        assert_eq!(re_serialized["future_field"]["x"], 1, "republish must not strip it");
681        // And the entry still verifies with the stranger field aboard.
682        assert!(verify_pin_entry(&reparsed, &chan_hex()).is_some());
683    }
684
685    /// The build-refusal reasons stay distinct — the UI answers each differently.
686    #[test]
687    fn build_failures_are_distinguishable() {
688        let author = Keys::generate();
689        let opened = opened_message(&author, "reasons");
690        // A conversation key that doesn't open this message: BadPayload.
691        assert_eq!(
692            build_pin_entry(&opened, &[3u8; 32], &chan_hex()).unwrap_err(),
693            PinBuildFailure::BadPayload
694        );
695        // The right key builds fine.
696        assert!(build_pin_entry(&opened, &conv_bytes(), &chan_hex()).is_ok());
697    }
698}