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, 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(TAG_VSK, [vsk::DISSOLVED.to_string()]),
89        Tag::custom(TAG_EID, [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/// Signer-driven twin of [`seal_dissolved`] for bunker / NIP-55 accounts: the
110/// plaintext dissolution tombstone signs through a [`VectorSigner`]. `owner_pk`
111/// must equal `my_public_key()` (the owner identity the signer signs as).
112pub async fn seal_dissolved_signed<S: crate::signer::VectorSigner + ?Sized>(
113    signer: &S,
114    owner_pk: PublicKey,
115    rumor: &UnsignedEvent,
116    community_id: &CommunityId,
117    wrap_at: Timestamp,
118) -> Result<Event, DissolveError> {
119    let group = dissolved_group_key(community_id);
120    let (wrap, _ephemeral) = stream::seal_and_wrap_signed(signer, owner_pk, rumor, SealForm::Plaintext, &group, stream::KIND_WRAP, wrap_at, &[]).await?;
121    Ok(wrap)
122}
123
124/// Open + structurally verify a wrap at the dissolved address into its signer.
125/// This proves the seal signature and the tombstone shape but NOT the owner —
126/// [`verify_dissolved`] is the fail-closed authority gate.
127pub fn open_dissolved(wrap: &Event, community_id: &CommunityId) -> Result<DissolvedTombstone, DissolveError> {
128    let group = dissolved_group_key(community_id);
129    let opened = stream::open_wrap(wrap, &group)?;
130    // The signed `eid` MUST commit to THIS community — a tombstone lifted from
131    // another community the same owner runs (re-wrapped at this public address)
132    // carries a foreign `eid` and is rejected here, before it can be honored.
133    if !is_tombstone_rumor(&opened.rumor, community_id) {
134        return Err(DissolveError::NotATombstone);
135    }
136    Ok(DissolvedTombstone { owner: opened.author })
137}
138
139/// Whether `wrap` is a valid owner-signed dissolution for `identity`. Valid ONLY
140/// if the identity self-certifies AND the tombstone's seal signer is the exact
141/// owner the `community_id` commits to. Fail-closed: a foreign-signed or
142/// unverifiable tombstone is NOT death.
143pub fn verify_dissolved(wrap: &Event, identity: &CommunityIdentity) -> bool {
144    if !identity.verify() {
145        return false;
146    }
147    let Ok(owner) = identity.owner() else {
148        return false;
149    };
150    match open_dissolved(wrap, &identity.community_id) {
151        Ok(tombstone) => tombstone.owner == owner,
152        Err(_) => false,
153    }
154}
155
156/// A chainless `vsk 10` tombstone for THIS community: kind 3308, `vsk == "10"`,
157/// and `eid == community_id` (the replay binding — see [`dissolved_tombstone_rumor`]).
158/// The seal form and any extra tags are irrelevant — the marker plus the
159/// community binding plus the (already seal-verified) owner signature is the
160/// whole state.
161fn is_tombstone_rumor(rumor: &UnsignedEvent, community_id: &CommunityId) -> bool {
162    rumor.kind.as_u16() == kind::CONTROL
163        && first_tag(rumor, TAG_VSK).as_deref() == Some(vsk::DISSOLVED)
164        && first_tag(rumor, TAG_EID).as_deref() == Some(crate::simd::hex::bytes_to_hex_32(&community_id.0).as_str())
165}
166
167fn first_tag(rumor: &UnsignedEvent, name: &str) -> Option<String> {
168    rumor.tags.iter().find_map(|t| {
169        let s = t.as_slice();
170        (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
171    })
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn identity_and_owner() -> (CommunityIdentity, Keys) {
179        let owner = Keys::generate();
180        let identity = CommunityIdentity::mint(&owner.public_key());
181        assert!(identity.verify());
182        (identity, owner)
183    }
184
185    /// Build a valid owner-signed tombstone wrap for `identity`.
186    fn tombstone_for(identity: &CommunityIdentity, owner: &Keys) -> Event {
187        let rumor = dissolved_tombstone_rumor(owner.public_key(), &identity.community_id, 1_725_000_000);
188        seal_dissolved(&rumor, &identity.community_id, owner, Timestamp::from_secs(1_725_000_000)).unwrap()
189    }
190
191    #[test]
192    fn owner_tombstone_round_trips_and_verifies() {
193        let (identity, owner) = identity_and_owner();
194        let wrap = tombstone_for(&identity, &owner);
195        assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
196        assert!(verify_dissolved(&wrap, &identity));
197    }
198
199    #[test]
200    fn a_non_owner_tombstone_is_not_death() {
201        let (identity, _owner) = identity_and_owner();
202        // Anyone holding the community_id can FIND the address and post there,
203        // but only the committed owner's signature counts.
204        let impostor = Keys::generate();
205        let rumor = dissolved_tombstone_rumor(impostor.public_key(), &identity.community_id, 1_725_000_000);
206        let wrap = seal_dissolved(&rumor, &identity.community_id, &impostor, Timestamp::from_secs(1_725_000_000)).unwrap();
207
208        // It opens (structurally valid, impostor-signed) but fails the authority gate.
209        assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, impostor.public_key());
210        assert!(!verify_dissolved(&wrap, &identity), "a foreign-signed tombstone is not death");
211    }
212
213    #[test]
214    fn a_non_self_certifying_identity_fails_closed() {
215        let (identity, owner) = identity_and_owner();
216        let wrap = tombstone_for(&identity, &owner);
217
218        // Same id, a claimed owner that doesn't reproduce the commitment.
219        let attacker = Keys::generate();
220        let forged = CommunityIdentity {
221            community_id: identity.community_id,
222            owner_xonly: attacker.public_key().to_bytes(),
223            owner_salt: identity.owner_salt,
224        };
225        assert!(!forged.verify());
226        assert!(!verify_dissolved(&wrap, &forged), "an identity that doesn't self-certify can't accept a tombstone");
227    }
228
229    #[test]
230    fn the_address_is_community_id_derived_and_epoch_free() {
231        let (identity, owner) = identity_and_owner();
232        let wrap = tombstone_for(&identity, &owner);
233
234        // A fresh joiner holding ONLY the community_id resolves the same grave at
235        // "any epoch" — the derivation takes none — and opens the tombstone.
236        let a = dissolved_group_key(&identity.community_id);
237        let b = dissolved_group_key(&identity.community_id);
238        assert_eq!(a.pk_hex(), b.pk_hex());
239        assert_eq!(open_dissolved(&wrap, &identity.community_id).unwrap().owner, owner.public_key());
240
241        // A different community's address can't open it (WrongStream, not death).
242        let other = CommunityIdentity::mint(&owner.public_key());
243        assert!(open_dissolved(&wrap, &other.community_id).is_err());
244        assert!(!verify_dissolved(&wrap, &other));
245    }
246
247    #[test]
248    fn a_tombstone_cannot_be_replayed_onto_another_community_of_the_same_owner() {
249        // The critical replay: one owner runs X and Y (both community_ids are
250        // PUBLIC — they ride in invites). O legitimately dissolves X. An attacker
251        // holding only the two public ids lifts X's owner-signed seal and re-wraps
252        // it at Y's (public-id-derived) dissolved address. Y members must NOT read
253        // it as death: the signed `eid` names X, not Y.
254        let owner = Keys::generate();
255        let x = CommunityIdentity::mint(&owner.public_key());
256        let y = CommunityIdentity::mint(&owner.public_key());
257        assert_ne!(x.community_id, y.community_id);
258
259        let wrap_x = tombstone_for(&x, &owner);
260        // Sanity: it kills X.
261        assert!(verify_dissolved(&wrap_x, &x));
262
263        // Attacker lifts the seal out of X's wrap and re-wraps at Y's address.
264        let x_group = dissolved_group_key(&x.community_id);
265        let opened_x = stream::open_wrap(&wrap_x, &x_group).unwrap();
266        let y_group = dissolved_group_key(&y.community_id);
267        let (replayed, _) = stream::wrap_seal(&opened_x.seal, &y_group, stream::KIND_WRAP, Timestamp::from_secs(1_725_000_100)).unwrap();
268
269        // The re-wrap opens at Y's address (valid owner signature) but the signed
270        // eid still names X, so it is NOT a tombstone for Y.
271        assert!(matches!(open_dissolved(&replayed, &y.community_id), Err(DissolveError::NotATombstone)));
272        assert!(!verify_dissolved(&replayed, &y), "an X tombstone must never kill Y");
273    }
274
275    #[test]
276    fn the_tombstone_rumor_is_chainless_and_binds_the_community() {
277        let owner = Keys::generate();
278        let cid = CommunityId([0x5a; 32]);
279        let rumor = dissolved_tombstone_rumor(owner.public_key(), &cid, 1_725_000_000);
280        assert_eq!(rumor.kind.as_u16(), kind::CONTROL);
281        assert!(rumor.content.is_empty());
282        assert_eq!(first_tag(&rumor, TAG_VSK).as_deref(), Some(vsk::DISSOLVED));
283        // eid binds the community (the replay fix), NOT the spec's all-zero.
284        assert_eq!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&cid.0).as_str()));
285        assert_ne!(first_tag(&rumor, TAG_EID).as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&[0u8; 32]).as_str()));
286        // No chain machinery: ev / ep / vac are all absent.
287        for machinery in ["ev", "ep", "vac"] {
288            assert!(first_tag(&rumor, machinery).is_none(), "chainless: {machinery} must be absent");
289        }
290    }
291}