1use nostr_sdk::prelude::*;
16use serde::{Deserialize, Serialize};
17
18use super::kind;
19use super::pin_keys;
20use super::stream::OpenedStream;
21
22pub const PIN_MAX_ENTRIES: usize = 25;
24pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
25
26const KIND_SEAL_ENCRYPTED: u16 = 20013;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PinEditBundle {
32 pub seal: Event,
33 pub keys: String,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PinEntry {
41 pub seal: Event,
44 pub keys: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub wrap: Option<String>,
49 #[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#[derive(Debug, Clone, Serialize)]
60pub struct VerifiedPin {
61 pub rumor_id: String,
63 pub author: String,
65 pub kind: u16,
66 pub content: String,
67 pub tags: Vec<Vec<String>>,
68 pub epoch: Option<String>,
70 pub ms: u64,
72 pub created_at: u64,
73 pub wrap_hint: Option<String>,
75 pub edited: Option<EditedContent>,
77 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90pub enum PinBuildFailure {
91 NotEncrypted,
94 BadPayload,
97 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
109fn 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
118pub 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 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 if verify_pin_entry(&entry, channel_id_hex).is_none() {
151 return Err(PinBuildFailure::Unverifiable);
152 }
153 Ok(entry)
154}
155
156pub 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#[derive(Deserialize)]
184struct RumorFields {
185 pubkey: String,
186 kind: u16,
187 content: String,
188 created_at: u64,
189 tags: Vec<Vec<String>>,
190}
191
192fn 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
207pub fn verify_pin_entry(entry: &PinEntry, channel_id_hex: &str) -> Option<VerifiedPin> {
212 let seal = &entry.seal;
213 if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
216 return None;
217 }
218
219 let keys = pin_keys::decode_message_keys(&entry.keys)?;
221 let plaintext = pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys)?;
222
223 let rumor: RumorFields = serde_json::from_str(&plaintext).ok()?;
225 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 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 let rumor_id = recompute_rumor_id(&rumor)?;
246
247 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
270fn 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 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 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#[derive(Serialize, Deserialize)]
319struct PlainForm {
320 entries: Vec<PinEntry>,
321}
322
323pub 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
333pub 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
364pub struct ReadPinList {
366 pub entries: Vec<PinEntry>,
367 pub sealed: bool,
370}
371
372pub 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 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 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
433pub 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
447pub 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 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 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 #[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 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 let mut bad = entry.clone();
553 bad.keys = format!("{}{}", &entry.keys[2..], "00");
554 assert!(verify_pin_entry(&bad, &chan_hex()).is_none());
555 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 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 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 let many: Vec<PinEntry> = (0..26).map(|_| entry.clone()).collect();
604 assert!(serialize_public_pin_list(&many).is_err());
605 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 let dark = read_pin_list(&content, |_| None);
618 assert!(dark.sealed);
619 assert!(dark.entries.is_empty());
620
621 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 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 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 assert!(pin_killed_by(&v, &author.public_key().to_hex(), &e_tag));
656 assert!(!pin_killed_by(&v, &Keys::generate().public_key().to_hex(), &e_tag));
658 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 #[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 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 assert!(verify_pin_entry(&reparsed, &chan_hex()).is_some());
683 }
684
685 #[test]
687 fn build_failures_are_distinguishable() {
688 let author = Keys::generate();
689 let opened = opened_message(&author, "reasons");
690 assert_eq!(
692 build_pin_entry(&opened, &[3u8; 32], &chan_hex()).unwrap_err(),
693 PinBuildFailure::BadPayload
694 );
695 assert!(build_pin_entry(&opened, &conv_bytes(), &chan_hex()).is_ok());
697 }
698}