use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
use super::super::CommunityId;
use super::control::CommunityIdentity;
use super::derive::dissolved_group_key;
use super::stream::{self, SealForm, StreamError};
use super::{kind, vsk};
const TAG_VSK: &str = "vsk";
const TAG_EID: &str = "eid";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DissolvedTombstone {
pub owner: PublicKey,
}
#[derive(Debug)]
pub enum DissolveError {
Stream(StreamError),
NotATombstone,
}
impl std::fmt::Display for DissolveError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DissolveError::Stream(e) => write!(f, "stream: {e}"),
DissolveError::NotATombstone => write!(f, "not a dissolution tombstone"),
}
}
}
impl std::error::Error for DissolveError {}
impl From<StreamError> for DissolveError {
fn from(e: StreamError) -> Self {
DissolveError::Stream(e)
}
}
pub fn dissolved_tombstone_rumor(owner: PublicKey, community_id: &CommunityId, created_at_secs: u64) -> UnsignedEvent {
let tags = vec![
Tag::custom(TagKind::Custom(TAG_VSK.into()), [vsk::DISSOLVED.to_string()]),
Tag::custom(TagKind::Custom(TAG_EID.into()), [crate::simd::hex::bytes_to_hex_32(&community_id.0)]),
];
stream::build_rumor_secs(kind::CONTROL, owner, "", tags, created_at_secs)
}
pub fn seal_dissolved(
rumor: &UnsignedEvent,
community_id: &CommunityId,
owner_keys: &Keys,
wrap_at: Timestamp,
) -> Result<Event, DissolveError> {
let group = dissolved_group_key(community_id);
let seal = stream::build_seal(rumor, SealForm::Plaintext, &group, owner_keys)?;
let (wrap, _ephemeral) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, wrap_at)?;
Ok(wrap)
}
pub fn open_dissolved(wrap: &Event, community_id: &CommunityId) -> Result<DissolvedTombstone, DissolveError> {
let group = dissolved_group_key(community_id);
let opened = stream::open_wrap(wrap, &group)?;
if !is_tombstone_rumor(&opened.rumor, community_id) {
return Err(DissolveError::NotATombstone);
}
Ok(DissolvedTombstone { owner: opened.author })
}
pub fn verify_dissolved(wrap: &Event, identity: &CommunityIdentity) -> bool {
if !identity.verify() {
return false;
}
let Ok(owner) = identity.owner() else {
return false;
};
match open_dissolved(wrap, &identity.community_id) {
Ok(tombstone) => tombstone.owner == owner,
Err(_) => false,
}
}
fn is_tombstone_rumor(rumor: &UnsignedEvent, community_id: &CommunityId) -> bool {
rumor.kind.as_u16() == kind::CONTROL
&& first_tag(rumor, TAG_VSK).as_deref() == Some(vsk::DISSOLVED)
&& first_tag(rumor, TAG_EID).as_deref() == Some(crate::simd::hex::bytes_to_hex_32(&community_id.0).as_str())
}
fn first_tag(rumor: &UnsignedEvent, name: &str) -> Option<String> {
rumor.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == name).then(|| s[1].clone())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn identity_and_owner() -> (CommunityIdentity, Keys) {
let owner = Keys::generate();
let identity = CommunityIdentity::mint(&owner.public_key());
assert!(identity.verify());
(identity, owner)
}
fn tombstone_for(identity: &CommunityIdentity, owner: &Keys) -> Event {
let rumor = dissolved_tombstone_rumor(owner.public_key(), &identity.community_id, 1_725_000_000);
seal_dissolved(&rumor, &identity.community_id, owner, Timestamp::from_secs(1_725_000_000)).unwrap()
}
#[test]
fn owner_tombstone_round_trips_and_verifies() {
let (identity, owner) = identity_and_owner();
let wrap = tombstone_for(&identity, &owner);
assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
assert!(verify_dissolved(&wrap, &identity));
}
#[test]
fn a_non_owner_tombstone_is_not_death() {
let (identity, _owner) = identity_and_owner();
let impostor = Keys::generate();
let rumor = dissolved_tombstone_rumor(impostor.public_key(), &identity.community_id, 1_725_000_000);
let wrap = seal_dissolved(&rumor, &identity.community_id, &impostor, Timestamp::from_secs(1_725_000_000)).unwrap();
assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, impostor.public_key());
assert!(!verify_dissolved(&wrap, &identity), "a foreign-signed tombstone is not death");
}
#[test]
fn a_non_self_certifying_identity_fails_closed() {
let (identity, owner) = identity_and_owner();
let wrap = tombstone_for(&identity, &owner);
let attacker = Keys::generate();
let forged = CommunityIdentity {
community_id: identity.community_id,
owner_xonly: attacker.public_key().to_bytes(),
owner_salt: identity.owner_salt,
};
assert!(!forged.verify());
assert!(!verify_dissolved(&wrap, &forged), "an identity that doesn't self-certify can't accept a tombstone");
}
#[test]
fn the_address_is_community_id_derived_and_epoch_free() {
let (identity, owner) = identity_and_owner();
let wrap = tombstone_for(&identity, &owner);
let a = dissolved_group_key(&identity.community_id);
let b = dissolved_group_key(&identity.community_id);
assert_eq!(a.pk_hex(), b.pk_hex());
assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
let other = CommunityIdentity::mint(&owner.public_key());
assert!(open_dissolved(&wrap, &other.community_id).is_err());
assert!(!verify_dissolved(&wrap, &other));
}
#[test]
fn a_tombstone_cannot_be_replayed_onto_another_community_of_the_same_owner() {
let owner = Keys::generate();
let x = CommunityIdentity::mint(&owner.public_key());
let y = CommunityIdentity::mint(&owner.public_key());
assert_ne!(x.community_id, y.community_id);
let wrap_x = tombstone_for(&x, &owner);
assert!(verify_dissolved(&wrap_x, &x));
let x_group = dissolved_group_key(&x.community_id);
let opened_x = stream::open_wrap(&wrap_x, &x_group).unwrap();
let y_group = dissolved_group_key(&y.community_id);
let (replayed, _) = stream::wrap_seal(&opened_x.seal, &y_group, stream::KIND_WRAP, Timestamp::from_secs(1_725_000_100)).unwrap();
assert!(matches!(open_dissolved(&replayed, &y.community_id), Err(DissolveError::NotATombstone)));
assert!(!verify_dissolved(&replayed, &y), "an X tombstone must never kill Y");
}
#[test]
fn the_tombstone_rumor_is_chainless_and_binds_the_community() {
let owner = Keys::generate();
let cid = CommunityId([0x5a; 32]);
let rumor = dissolved_tombstone_rumor(owner.public_key(), &cid, 1_725_000_000);
assert_eq!(rumor.kind.as_u16(), kind::CONTROL);
assert!(rumor.content.is_empty());
assert_eq!(first_tag(&rumor, TAG_VSK).as_deref(), Some(vsk::DISSOLVED));
assert_eq!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&cid.0).as_str()));
assert_ne!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&[0u8; 32]).as_str()));
for machinery in ["ev", "ep", "vac"] {
assert!(first_tag(&rumor, machinery).is_none(), "chainless: {machinery} must be absent");
}
}
}