vector_core/community/v2/
dissolution.rs1use nostr_sdk::prelude::{Event, Keys, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
30
31use super::super::CommunityId;
32use super::control::CommunityIdentity;
33use super::derive::dissolved_group_key;
34use super::stream::{self, SealForm, StreamError};
35use super::{kind, vsk};
36
37const TAG_VSK: &str = "vsk";
38const TAG_EID: &str = "eid";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DissolvedTombstone {
44 pub owner: PublicKey,
45}
46
47#[derive(Debug)]
49pub enum DissolveError {
50 Stream(StreamError),
51 NotATombstone,
53}
54
55impl std::fmt::Display for DissolveError {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 match self {
58 DissolveError::Stream(e) => write!(f, "stream: {e}"),
59 DissolveError::NotATombstone => write!(f, "not a dissolution tombstone"),
60 }
61 }
62}
63
64impl std::error::Error for DissolveError {}
65
66impl From<StreamError> for DissolveError {
67 fn from(e: StreamError) -> Self {
68 DissolveError::Stream(e)
69 }
70}
71
72pub fn dissolved_tombstone_rumor(owner: PublicKey, community_id: &CommunityId, created_at_secs: u64) -> UnsignedEvent {
87 let tags = vec![
88 Tag::custom(TagKind::Custom(TAG_VSK.into()), [vsk::DISSOLVED.to_string()]),
89 Tag::custom(TagKind::Custom(TAG_EID.into()), [crate::simd::hex::bytes_to_hex_32(&community_id.0)]),
90 ];
91 stream::build_rumor_secs(kind::CONTROL, owner, "", tags, created_at_secs)
92}
93
94pub fn seal_dissolved(
98 rumor: &UnsignedEvent,
99 community_id: &CommunityId,
100 owner_keys: &Keys,
101 wrap_at: Timestamp,
102) -> Result<Event, DissolveError> {
103 let group = dissolved_group_key(community_id);
104 let seal = stream::build_seal(rumor, SealForm::Plaintext, &group, owner_keys)?;
105 let (wrap, _ephemeral) = stream::wrap_seal(&seal, &group, stream::KIND_WRAP, wrap_at)?;
106 Ok(wrap)
107}
108
109pub fn open_dissolved(wrap: &Event, community_id: &CommunityId) -> Result<DissolvedTombstone, DissolveError> {
113 let group = dissolved_group_key(community_id);
114 let opened = stream::open_wrap(wrap, &group)?;
115 if !is_tombstone_rumor(&opened.rumor, community_id) {
119 return Err(DissolveError::NotATombstone);
120 }
121 Ok(DissolvedTombstone { owner: opened.author })
122}
123
124pub fn verify_dissolved(wrap: &Event, identity: &CommunityIdentity) -> bool {
129 if !identity.verify() {
130 return false;
131 }
132 let Ok(owner) = identity.owner() else {
133 return false;
134 };
135 match open_dissolved(wrap, &identity.community_id) {
136 Ok(tombstone) => tombstone.owner == owner,
137 Err(_) => false,
138 }
139}
140
141fn is_tombstone_rumor(rumor: &UnsignedEvent, community_id: &CommunityId) -> bool {
147 rumor.kind.as_u16() == kind::CONTROL
148 && first_tag(rumor, TAG_VSK).as_deref() == Some(vsk::DISSOLVED)
149 && first_tag(rumor, TAG_EID).as_deref() == Some(crate::simd::hex::bytes_to_hex_32(&community_id.0).as_str())
150}
151
152fn first_tag(rumor: &UnsignedEvent, name: &str) -> Option<String> {
153 rumor.tags.iter().find_map(|t| {
154 let s = t.as_slice();
155 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
156 })
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 fn identity_and_owner() -> (CommunityIdentity, Keys) {
164 let owner = Keys::generate();
165 let identity = CommunityIdentity::mint(&owner.public_key());
166 assert!(identity.verify());
167 (identity, owner)
168 }
169
170 fn tombstone_for(identity: &CommunityIdentity, owner: &Keys) -> Event {
172 let rumor = dissolved_tombstone_rumor(owner.public_key(), &identity.community_id, 1_725_000_000);
173 seal_dissolved(&rumor, &identity.community_id, owner, Timestamp::from_secs(1_725_000_000)).unwrap()
174 }
175
176 #[test]
177 fn owner_tombstone_round_trips_and_verifies() {
178 let (identity, owner) = identity_and_owner();
179 let wrap = tombstone_for(&identity, &owner);
180 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
181 assert!(verify_dissolved(&wrap, &identity));
182 }
183
184 #[test]
185 fn a_non_owner_tombstone_is_not_death() {
186 let (identity, _owner) = identity_and_owner();
187 let impostor = Keys::generate();
190 let rumor = dissolved_tombstone_rumor(impostor.public_key(), &identity.community_id, 1_725_000_000);
191 let wrap = seal_dissolved(&rumor, &identity.community_id, &impostor, Timestamp::from_secs(1_725_000_000)).unwrap();
192
193 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, impostor.public_key());
195 assert!(!verify_dissolved(&wrap, &identity), "a foreign-signed tombstone is not death");
196 }
197
198 #[test]
199 fn a_non_self_certifying_identity_fails_closed() {
200 let (identity, owner) = identity_and_owner();
201 let wrap = tombstone_for(&identity, &owner);
202
203 let attacker = Keys::generate();
205 let forged = CommunityIdentity {
206 community_id: identity.community_id,
207 owner_xonly: attacker.public_key().to_bytes(),
208 owner_salt: identity.owner_salt,
209 };
210 assert!(!forged.verify());
211 assert!(!verify_dissolved(&wrap, &forged), "an identity that doesn't self-certify can't accept a tombstone");
212 }
213
214 #[test]
215 fn the_address_is_community_id_derived_and_epoch_free() {
216 let (identity, owner) = identity_and_owner();
217 let wrap = tombstone_for(&identity, &owner);
218
219 let a = dissolved_group_key(&identity.community_id);
222 let b = dissolved_group_key(&identity.community_id);
223 assert_eq!(a.pk_hex(), b.pk_hex());
224 assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
225
226 let other = CommunityIdentity::mint(&owner.public_key());
228 assert!(open_dissolved(&wrap, &other.community_id).is_err());
229 assert!(!verify_dissolved(&wrap, &other));
230 }
231
232 #[test]
233 fn a_tombstone_cannot_be_replayed_onto_another_community_of_the_same_owner() {
234 let owner = Keys::generate();
240 let x = CommunityIdentity::mint(&owner.public_key());
241 let y = CommunityIdentity::mint(&owner.public_key());
242 assert_ne!(x.community_id, y.community_id);
243
244 let wrap_x = tombstone_for(&x, &owner);
245 assert!(verify_dissolved(&wrap_x, &x));
247
248 let x_group = dissolved_group_key(&x.community_id);
250 let opened_x = stream::open_wrap(&wrap_x, &x_group).unwrap();
251 let y_group = dissolved_group_key(&y.community_id);
252 let (replayed, _) = stream::wrap_seal(&opened_x.seal, &y_group, stream::KIND_WRAP, Timestamp::from_secs(1_725_000_100)).unwrap();
253
254 assert!(matches!(open_dissolved(&replayed, &y.community_id), Err(DissolveError::NotATombstone)));
257 assert!(!verify_dissolved(&replayed, &y), "an X tombstone must never kill Y");
258 }
259
260 #[test]
261 fn the_tombstone_rumor_is_chainless_and_binds_the_community() {
262 let owner = Keys::generate();
263 let cid = CommunityId([0x5a; 32]);
264 let rumor = dissolved_tombstone_rumor(owner.public_key(), &cid, 1_725_000_000);
265 assert_eq!(rumor.kind.as_u16(), kind::CONTROL);
266 assert!(rumor.content.is_empty());
267 assert_eq!(first_tag(&rumor, TAG_VSK).as_deref(), Some(vsk::DISSOLVED));
268 assert_eq!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&cid.0).as_str()));
270 assert_ne!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&[0u8; 32]).as_str()));
271 for machinery in ["ev", "ep", "vac"] {
273 assert!(first_tag(&rumor, machinery).is_none(), "chainless: {machinery} must be absent");
274 }
275 }
276}