use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use super::kind;
use super::pin_keys;
use super::stream::OpenedStream;
pub const PIN_MAX_ENTRIES: usize = 25;
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
const KIND_SEAL_ENCRYPTED: u16 = 20013;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinEditBundle {
pub seal: Event,
pub keys: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PinEntry {
pub seal: Event,
pub keys: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub edit: Option<PinEditBundle>,
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifiedPin {
pub rumor_id: String,
pub author: String,
pub kind: u16,
pub content: String,
pub tags: Vec<Vec<String>>,
pub epoch: Option<String>,
pub ms: u64,
pub created_at: u64,
pub wrap_hint: Option<String>,
pub edited: Option<EditedContent>,
#[serde(skip_serializing)]
pub entry: PinEntry,
}
#[derive(Debug, Clone, Serialize)]
pub struct EditedContent {
pub content: String,
pub ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PinBuildFailure {
NotEncrypted,
BadPayload,
Unverifiable,
}
fn tag_value<'a>(tags: &'a [Vec<String>], name: &str) -> Option<&'a str> {
tags.iter()
.find(|t| t.first().map(String::as_str) == Some(name))
.and_then(|t| t.get(1))
.map(String::as_str)
}
fn resolve_ms(created_at: u64, tags: &[Vec<String>]) -> u64 {
let ms = tag_value(tags, "ms")
.and_then(|raw| raw.parse::<u64>().ok())
.filter(|ms| *ms <= 999)
.unwrap_or(0);
created_at * 1000 + ms
}
pub fn build_pin_entry(
opened: &OpenedStream,
conv_key: &[u8; 32],
channel_id_hex: &str,
) -> Result<PinEntry, PinBuildFailure> {
let seal = &opened.seal;
if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
return Err(PinBuildFailure::NotEncrypted);
}
let keys =
pin_keys::disclose_keys_for(&seal.content, conv_key).ok_or(PinBuildFailure::BadPayload)?;
if pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys).is_none() {
return Err(PinBuildFailure::BadPayload);
}
let entry = PinEntry {
seal: seal.clone(),
keys: pin_keys::encode_message_keys(&keys),
wrap: Some(opened.wrapper_id.to_hex()),
edit: None,
extra: Default::default(),
};
if verify_pin_entry(&entry, channel_id_hex).is_none() {
return Err(PinBuildFailure::Unverifiable);
}
Ok(entry)
}
pub fn build_pin_edit_bundle(
edit_opened: &OpenedStream,
conv_key: &[u8; 32],
original_author: &str,
original_rumor_id: &str,
channel_id_hex: &str,
) -> Result<PinEditBundle, PinBuildFailure> {
let seal = &edit_opened.seal;
if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
return Err(PinBuildFailure::NotEncrypted);
}
let keys =
pin_keys::disclose_keys_for(&seal.content, conv_key).ok_or(PinBuildFailure::BadPayload)?;
if pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys).is_none() {
return Err(PinBuildFailure::BadPayload);
}
let bundle = PinEditBundle { seal: seal.clone(), keys: pin_keys::encode_message_keys(&keys) };
if verify_edit_bundle(&bundle, original_author, original_rumor_id, channel_id_hex).is_none() {
return Err(PinBuildFailure::Unverifiable);
}
Ok(bundle)
}
#[derive(Deserialize)]
struct RumorFields {
pubkey: String,
kind: u16,
content: String,
created_at: u64,
tags: Vec<Vec<String>>,
}
fn recompute_rumor_id(r: &RumorFields) -> Option<String> {
let pubkey = PublicKey::from_hex(&r.pubkey).ok()?;
let tags: Vec<Tag> = r.tags.iter().map(|t| Tag::parse(t.clone()).ok()).collect::<Option<_>>()?;
let id = EventId::compute(
&pubkey,
&Timestamp::from(r.created_at),
&Kind::from(r.kind),
&Tags::from_list(tags),
&r.content,
);
Some(id.to_hex())
}
pub fn verify_pin_entry(entry: &PinEntry, channel_id_hex: &str) -> Option<VerifiedPin> {
let seal = &entry.seal;
if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
return None;
}
let keys = pin_keys::decode_message_keys(&entry.keys)?;
let plaintext = pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys)?;
let rumor: RumorFields = serde_json::from_str(&plaintext).ok()?;
if rumor.pubkey != seal.pubkey.to_hex() {
return None;
}
if rumor.kind != kind::MESSAGE && rumor.kind != kind::COMMENT {
return None;
}
if channel_id_hex.len() != 64
|| !channel_id_hex.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|| tag_value(&rumor.tags, "channel") != Some(channel_id_hex)
{
return None;
}
let rumor_id = recompute_rumor_id(&rumor)?;
let edited = entry
.edit
.as_ref()
.and_then(|b| verify_edit_bundle(b, &rumor.pubkey, &rumor_id, channel_id_hex));
Some(VerifiedPin {
author: rumor.pubkey,
kind: rumor.kind,
content: edited.as_ref().map(|e| e.content.clone()).unwrap_or_else(|| rumor.content.clone()),
epoch: tag_value(&rumor.tags, "epoch").map(str::to_string),
ms: resolve_ms(rumor.created_at, &rumor.tags),
created_at: rumor.created_at,
tags: rumor.tags,
rumor_id,
wrap_hint: entry.wrap.clone(),
edited,
entry: entry.clone(),
})
}
fn verify_edit_bundle(
bundle: &PinEditBundle,
original_author: &str,
original_rumor_id: &str,
channel_id_hex: &str,
) -> Option<EditedContent> {
let seal = &bundle.seal;
if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
return None;
}
if seal.pubkey.to_hex() != original_author {
return None;
}
if seal.verify().is_err() {
return None;
}
let keys = pin_keys::decode_message_keys(&bundle.keys)?;
let plaintext = pin_keys::decrypt_with_disclosed_keys(&seal.content, &keys)?;
let rumor: RumorFields = serde_json::from_str(&plaintext).ok()?;
if rumor.pubkey != seal.pubkey.to_hex() {
return None;
}
if rumor.kind != kind::EDIT {
return None;
}
if tag_value(&rumor.tags, "channel") != Some(channel_id_hex) {
return None;
}
if tag_value(&rumor.tags, "e") != Some(original_rumor_id) {
return None;
}
Some(EditedContent {
content: rumor.content,
ms: resolve_ms(rumor.created_at, &rumor.tags),
})
}
#[derive(Serialize, Deserialize)]
struct PlainForm {
entries: Vec<PinEntry>,
}
pub fn serialize_public_pin_list(entries: &[PinEntry]) -> Result<String, String> {
let content = serde_json::to_string(&PlainForm { entries: entries.to_vec() })
.map_err(|e| e.to_string())?;
assert_caps(entries.len(), &content)?;
Ok(content)
}
pub fn serialize_sealed_pin_list(
entries: &[PinEntry],
conv_key: &nostr_sdk::prelude::nip44::v2::ConversationKey,
epoch: u64,
) -> Result<String, String> {
if entries.len() > PIN_MAX_ENTRIES {
return Err(format!("pin list exceeds {PIN_MAX_ENTRIES} entries"));
}
let inner = serde_json::to_string(&PlainForm { entries: entries.to_vec() })
.map_err(|e| e.to_string())?;
let sealed_raw = crate::community::cipher::encrypt_with_random_nonce(conv_key, inner.as_bytes())?;
let sealed = base64_simd::STANDARD.encode_to_string(&sealed_raw);
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
assert_caps(entries.len(), &content)?;
Ok(content)
}
fn assert_caps(count: usize, content: &str) -> Result<(), String> {
if count > PIN_MAX_ENTRIES {
return Err(format!("pin list exceeds {PIN_MAX_ENTRIES} entries"));
}
let bytes = content.len();
if bytes > PIN_MAX_CONTENT_BYTES {
return Err(format!("pin list content is {bytes} bytes (cap {PIN_MAX_CONTENT_BYTES})"));
}
Ok(())
}
pub struct ReadPinList {
pub entries: Vec<PinEntry>,
pub sealed: bool,
}
pub fn read_pin_list(
content: &str,
unseal_key: impl Fn(u64) -> Option<[u8; 32]>,
) -> ReadPinList {
const EMPTY: fn() -> ReadPinList = || ReadPinList { entries: Vec::new(), sealed: false };
if content.len() > PIN_MAX_CONTENT_BYTES {
return EMPTY();
}
let Ok(parsed) = serde_json::from_str::<serde_json::Value>(content) else {
return EMPTY();
};
if parsed.get("entries").is_some() {
let Ok(form) = serde_json::from_value::<PlainForm>(parsed) else {
return EMPTY();
};
if form.entries.len() > PIN_MAX_ENTRIES {
return EMPTY();
}
return ReadPinList { entries: form.entries, sealed: false };
}
let (Some(epoch_str), Some(sealed)) = (
parsed.get("epoch").and_then(|v| v.as_str()),
parsed.get("sealed").and_then(|v| v.as_str()),
) else {
return EMPTY();
};
if epoch_str != "0" && (epoch_str.is_empty() || epoch_str.starts_with('0')) {
return EMPTY();
}
let Ok(epoch) = epoch_str.parse::<u64>() else {
return EMPTY();
};
let Some(key) = unseal_key(epoch) else {
return ReadPinList { entries: Vec::new(), sealed: true };
};
let Some(conv_key) = nostr_sdk::prelude::nip44::v2::ConversationKey::from_slice(&key).ok() else {
return EMPTY();
};
let Ok(raw) = base64_simd::STANDARD.decode_to_vec(sealed.as_bytes()) else {
return EMPTY();
};
let Ok(inner) = nostr_sdk::prelude::nip44::v2::decrypt_to_bytes(&conv_key, &raw) else {
return EMPTY();
};
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
return EMPTY();
};
if form.entries.len() > PIN_MAX_ENTRIES {
return EMPTY();
}
ReadPinList { entries: form.entries, sealed: false }
}
pub fn pin_killed_by(pin: &VerifiedPin, delete_author_hex: &str, delete_tags: &[Vec<String>]) -> bool {
if delete_author_hex != pin.author {
return false;
}
delete_tags
.iter()
.any(|t| t.first().map(String::as_str) == Some("e") && t.get(1).map(String::as_str) == Some(pin.rumor_id.as_str()))
}
pub fn with_proven_edit(
entry: &PinEntry,
edit_opened: &OpenedStream,
conv_key: &[u8; 32],
channel_id_hex: &str,
) -> PinEntry {
let seal = &edit_opened.seal;
if seal.kind.as_u16() != KIND_SEAL_ENCRYPTED {
return entry.clone();
}
let Some(keys) = pin_keys::disclose_keys_for(&seal.content, conv_key) else {
return entry.clone();
};
let mut candidate = entry.clone();
candidate.edit = Some(PinEditBundle {
seal: seal.clone(),
keys: pin_keys::encode_message_keys(&keys),
});
match verify_pin_entry(&candidate, channel_id_hex) {
Some(v) if v.edited.is_some() => candidate,
_ => entry.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::community::v2::chat::{
build_edit_rumor, build_message_rumor, open_chat_event, seal_chat_rumor, ChatEvent,
};
use crate::community::v2::derive::channel_group_key;
use crate::community::{ChannelId, Epoch};
const AT: u64 = 1_686_840_217_417;
const WRAP_AT: Timestamp = Timestamp::from_secs(1_700_000_000);
fn chan() -> ChannelId {
ChannelId([0xab; 32])
}
fn chan_hex() -> String {
crate::simd::hex::bytes_to_hex_32(&chan().0)
}
fn group() -> crate::community::v2::derive::GroupKey {
channel_group_key(&[7u8; 32], &chan(), Epoch(0))
}
fn conv_bytes() -> [u8; 32] {
group().conv_key().as_bytes().try_into().unwrap()
}
fn opened_message(author: &Keys, text: &str) -> OpenedStream {
let rumor = build_message_rumor(author.public_key(), &chan(), Epoch(0), text, None, &[], vec![], AT);
let wrap = seal_chat_rumor(&rumor, &group(), author, WRAP_AT, false).unwrap().0;
match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
ChatEvent::Message { opened, .. } => opened,
other => panic!("expected Message, got {other:?}"),
}
}
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
let opened = opened_message(author, text);
let entry = build_pin_entry(&opened, &conv_bytes(), &chan_hex()).unwrap();
(entry, opened)
}
#[test]
fn a_built_entry_verifies_and_proves_the_author_and_words() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "pin me, I'm important");
let v = verify_pin_entry(&entry, &chan_hex()).expect("verifies");
assert_eq!(v.author, author.public_key().to_hex());
assert_eq!(v.content, "pin me, I'm important");
assert_eq!(v.rumor_id, opened.rumor_id.to_hex(), "identity = recomputed rumor id");
assert_eq!(v.ms, AT);
assert_eq!(v.epoch.as_deref(), Some("0"));
assert!(v.edited.is_none());
}
#[test]
fn a_pin_cannot_cross_channels() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "private words");
let other = crate::simd::hex::bytes_to_hex_32(&[0xcd; 32]);
assert!(verify_pin_entry(&entry, &other).is_none());
assert!(verify_pin_entry(&entry, "not-hex").is_none());
assert!(verify_pin_entry(&entry, "").is_none());
}
#[test]
fn tampering_with_the_disclosed_keys_or_seal_fails() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "immutable");
let mut bad = entry.clone();
bad.keys = format!("{}{}", &entry.keys[2..], "00");
assert!(verify_pin_entry(&bad, &chan_hex()).is_none());
let mut forged = entry.clone();
forged.seal.pubkey = Keys::generate().public_key();
assert!(verify_pin_entry(&forged, &chan_hex()).is_none());
}
#[test]
fn a_proven_edit_replaces_the_words_and_a_foreign_one_is_refused() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "teh typo");
let edit_rumor = build_edit_rumor(author.public_key(), &chan(), Epoch(0), &opened.rumor_id.to_hex(), "the typo, fixed", AT + 5_000);
let wrap = seal_chat_rumor(&edit_rumor, &group(), &author, WRAP_AT, false).unwrap().0;
let edit_opened = match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
ChatEvent::Edit { opened, .. } => opened,
other => panic!("expected Edit, got {other:?}"),
};
let refreshed = with_proven_edit(&entry, &edit_opened, &conv_bytes(), &chan_hex());
let v = verify_pin_entry(&refreshed, &chan_hex()).unwrap();
assert_eq!(v.content, "the typo, fixed", "edited words render as current");
assert_eq!(v.edited.as_ref().unwrap().ms, AT + 5_000);
let stranger = Keys::generate();
let foreign_rumor = build_edit_rumor(stranger.public_key(), &chan(), Epoch(0), &opened.rumor_id.to_hex(), "hijacked", AT + 6_000);
let wrap = seal_chat_rumor(&foreign_rumor, &group(), &stranger, WRAP_AT, false).unwrap().0;
let foreign_opened = match open_chat_event(&wrap, &group(), &chan(), Epoch(0)).unwrap() {
ChatEvent::Edit { opened, .. } => opened,
other => panic!("expected Edit, got {other:?}"),
};
let unchanged = with_proven_edit(&entry, &foreign_opened, &conv_bytes(), &chan_hex());
assert!(unchanged.edit.is_none(), "a stranger's edit must not attach");
}
#[test]
fn public_list_round_trips_and_respects_caps() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "hello");
let content = serialize_public_pin_list(&[entry.clone()]).unwrap();
let read = read_pin_list(&content, |_| None);
assert!(!read.sealed);
assert_eq!(read.entries.len(), 1);
assert!(verify_pin_entry(&read.entries[0], &chan_hex()).is_some(), "survives the round trip");
let many: Vec<PinEntry> = (0..26).map(|_| entry.clone()).collect();
assert!(serialize_public_pin_list(&many).is_err());
let violating = serde_json::json!({ "entries": many }).to_string();
assert_eq!(read_pin_list(&violating, |_| None).entries.len(), 0);
}
#[test]
fn sealed_list_is_dark_without_the_key_and_opens_with_it() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "private pin");
let content = serialize_sealed_pin_list(&[entry], group().conv_key(), 4).unwrap();
let dark = read_pin_list(&content, |_| None);
assert!(dark.sealed);
assert!(dark.entries.is_empty());
let lit = read_pin_list(&content, |epoch| (epoch == 4).then(conv_bytes));
assert!(!lit.sealed);
assert_eq!(lit.entries.len(), 1);
assert!(verify_pin_entry(&lit.entries[0], &chan_hex()).is_some());
let wrong = read_pin_list(&content, |_| Some([9u8; 32]));
assert!(wrong.entries.is_empty());
}
#[test]
fn garbage_content_reads_as_empty_never_panics() {
for bad in ["", "not json", "[]", "42", r#"{"entries": 7}"#, r#"{"epoch":"x","sealed":"y"}"#, r#"{"epoch":"04","sealed":"y"}"#] {
let read = read_pin_list(bad, |_| None);
assert!(read.entries.is_empty(), "{bad}");
assert!(!read.sealed, "{bad}");
}
let hostile = r#"{"entries":[null, 42, {"seal": null}, {"keys": "zz"}]}"#;
let read = read_pin_list(hostile, |_| None);
for e in &read.entries {
assert!(verify_pin_entry(e, &chan_hex()).is_none());
}
}
#[test]
fn deletion_matches_by_recomputed_id_and_author_only() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "delete me later");
let v = verify_pin_entry(&entry, &chan_hex()).unwrap();
let e_tag = vec![vec!["e".to_string(), opened.rumor_id.to_hex()]];
assert!(pin_killed_by(&v, &author.public_key().to_hex(), &e_tag));
assert!(!pin_killed_by(&v, &Keys::generate().public_key().to_hex(), &e_tag));
let other_tag = vec![vec!["e".to_string(), "ff".repeat(32)]];
assert!(!pin_killed_by(&v, &author.public_key().to_hex(), &other_tag));
}
#[test]
fn wire_json_matches_the_reference_shape() {
let author = Keys::generate();
let (mut entry, _) = entry_for(&author, "wire check");
entry.wrap = None;
let json = serde_json::to_value(&entry).unwrap();
assert!(json.get("wrap").is_none(), "unset wrap must be omitted, not null");
assert!(json.get("edit").is_none(), "unset edit must be omitted, not null");
let mut with_extra = serde_json::to_value(&entry).unwrap();
with_extra["future_field"] = serde_json::json!({"x": 1});
let reparsed: PinEntry = serde_json::from_value(with_extra).unwrap();
assert_eq!(reparsed.extra.get("future_field").unwrap()["x"], 1);
let re_serialized = serde_json::to_value(&reparsed).unwrap();
assert_eq!(re_serialized["future_field"]["x"], 1, "republish must not strip it");
assert!(verify_pin_entry(&reparsed, &chan_hex()).is_some());
}
#[test]
fn build_failures_are_distinguishable() {
let author = Keys::generate();
let opened = opened_message(&author, "reasons");
assert_eq!(
build_pin_entry(&opened, &[3u8; 32], &chan_hex()).unwrap_err(),
PinBuildFailure::BadPayload
);
assert!(build_pin_entry(&opened, &conv_bytes(), &chan_hex()).is_ok());
}
}