Skip to main content

vector_core/community/v2/
dissolution.rs

1//! Dissolution — CORD-02 §9.
2//!
3//! A Community ends by one owner-signed **tombstone** at a coordinate derived
4//! from the `community_id` ALONE ([`super::derive::dissolved_group_key`]) — no
5//! key, no epoch — so every member past or present resolves the same address and
6//! a Refounding can never strand the grave. The tombstone is terminal and
7//! CHAINLESS: no version to race, nothing to edit; the presence of one valid
8//! owner-signed edition at the coordinate IS the state.
9//!
10//! The tombstone is authority-bearing state anyone holding only the
11//! `community_id` must be able to verify, and its content is empty, so Vector
12//! seals it with the **plaintext** seal form (kind 20014, like the Control
13//! Plane) rather than the double-encrypted form — nothing here is secret. On
14//! read the check is lenient about the seal form but strict about who signed it:
15//! only the owner the `community_id` commits to (§1) counts. An impostor's event
16//! at the (findable-by-anyone) address is noise — fail-closed, an
17//! unverifiable-or-foreign tombstone is NOT death.
18//!
19//! Two caller responsibilities this module deliberately does NOT implement:
20//!   - On a verified tombstone the client seals the Community read-only:
21//!     subscriptions halted, held keys still open history but nothing new is
22//!     honored. Death wins every race — once a valid tombstone exists no epoch
23//!     advance past it is honored, so a Refounding racing a dissolution loses
24//!     (the caller enforces this at epoch-advance, not here).
25//!   - The one carve-out is a member's own kind-5 self-delete of their own past
26//!     message, honored even post-seal (a self-scrub injects no content, so
27//!     read-only isn't violated). That lives on the message plane, not here.
28
29use 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/// A verified dissolution tombstone: it carries only its proven owner (the seal
41/// signer). Everything else is fixed by the protocol.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DissolvedTombstone {
44    pub owner: PublicKey,
45}
46
47/// Errors from opening a tombstone wrap.
48#[derive(Debug)]
49pub enum DissolveError {
50    Stream(StreamError),
51    /// The opened rumor isn't a chainless `vsk 10` / all-zero-`eid` tombstone.
52    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
72/// Build the owner-dissolution tombstone rumor: kind 3308, empty content,
73/// `["vsk","10"]` + `["eid", <community_id>]`, and CHAINLESS — no `ev`, `ep`, or
74/// `vac`.
75///
76/// **The `eid` MUST be the `community_id`, NOT the all-zero the CORD-02 §9 wire
77/// example shows.** The signed rumor is otherwise community-agnostic and the
78/// dissolved plane is addressed by the *public* `community_id` (it ships in every
79/// invite), so with a zero `eid` an owner's genuine tombstone for community X can
80/// be lifted and re-wrapped at the dissolved address of any OTHER community Y the
81/// same owner runs — the owner's signature validates for both, killing Y with no
82/// membership or ownership needed. Committing the `community_id` inside the signed
83/// payload makes a seal minted for X fail the binding check at Y. This is a
84/// deliberate divergence from the frozen §9 shape (a spec erratum to raise
85/// upstream — Armada carries the same replay hole).
86pub 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
94/// Sign (plaintext seal) + wrap the tombstone rumor at the community's dissolved
95/// address. Local-keys convenience; a bunker signs the seal itself via
96/// [`stream::seal_content`] + [`stream::wrap_seal`] for identical wire output.
97pub 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
109/// Open + structurally verify a wrap at the dissolved address into its signer.
110/// This proves the seal signature and the tombstone shape but NOT the owner —
111/// [`verify_dissolved`] is the fail-closed authority gate.
112pub 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    // The signed `eid` MUST commit to THIS community — a tombstone lifted from
116    // another community the same owner runs (re-wrapped at this public address)
117    // carries a foreign `eid` and is rejected here, before it can be honored.
118    if !is_tombstone_rumor(&opened.rumor, community_id) {
119        return Err(DissolveError::NotATombstone);
120    }
121    Ok(DissolvedTombstone { owner: opened.author })
122}
123
124/// Whether `wrap` is a valid owner-signed dissolution for `identity`. Valid ONLY
125/// if the identity self-certifies AND the tombstone's seal signer is the exact
126/// owner the `community_id` commits to. Fail-closed: a foreign-signed or
127/// unverifiable tombstone is NOT death.
128pub 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
141/// A chainless `vsk 10` tombstone for THIS community: kind 3308, `vsk == "10"`,
142/// and `eid == community_id` (the replay binding — see [`dissolved_tombstone_rumor`]).
143/// The seal form and any extra tags are irrelevant — the marker plus the
144/// community binding plus the (already seal-verified) owner signature is the
145/// whole state.
146fn 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    /// Build a valid owner-signed tombstone wrap for `identity`.
171    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        // Anyone holding the community_id can FIND the address and post there,
188        // but only the committed owner's signature counts.
189        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        // It opens (structurally valid, impostor-signed) but fails the authority gate.
194        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        // Same id, a claimed owner that doesn't reproduce the commitment.
204        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        // A fresh joiner holding ONLY the community_id resolves the same grave at
220        // "any epoch" — the derivation takes none — and opens the tombstone.
221        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        // A different community's address can't open it (WrongStream, not death).
227        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        // The critical replay: one owner runs X and Y (both community_ids are
235        // PUBLIC — they ride in invites). O legitimately dissolves X. An attacker
236        // holding only the two public ids lifts X's owner-signed seal and re-wraps
237        // it at Y's (public-id-derived) dissolved address. Y members must NOT read
238        // it as death: the signed `eid` names X, not Y.
239        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        // Sanity: it kills X.
246        assert!(verify_dissolved(&wrap_x, &x));
247
248        // Attacker lifts the seal out of X's wrap and re-wraps at Y's address.
249        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        // The re-wrap opens at Y's address (valid owner signature) but the signed
255        // eid still names X, so it is NOT a tombstone for Y.
256        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        // eid binds the community (the replay fix), NOT the spec's all-zero.
269        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        // No chain machinery: ev / ep / vac are all absent.
272        for machinery in ["ev", "ep", "vac"] {
273            assert!(first_tag(&rumor, machinery).is_none(), "chainless: {machinery} must be absent");
274        }
275    }
276}