Skip to main content

vector_core/community/
mod.rs

1//! Vector Community protocol (GROUP_PROTOCOL.md).
2//!
3//! A Community is the top-level container (Discord's "server", but Vector is
4//! serverless so the name reflects that); it holds Channels. This module is the
5//! cryptographic core: the frozen key-derivation convention and the message
6//! envelope. It is pure, network-free, and DB-free — the riskiest unknowns
7//! isolated for exhaustive unit testing before anything depends on them.
8
9pub mod attachments;
10pub mod cache;
11pub mod cipher;
12pub mod derive;
13pub mod envelope;
14pub mod inbound;
15pub mod invite;
16pub mod list;
17pub mod metadata;
18pub mod edition;
19pub mod owner;
20pub mod rekey;
21pub mod roster;
22pub mod version;
23pub mod public_invite;
24pub mod realtime;
25pub mod roles;
26pub mod send;
27pub mod service;
28pub mod transport;
29
30use nostr_sdk::prelude::PublicKey;
31use rand::RngCore;
32use serde::{Deserialize, Serialize};
33use zeroize::{Zeroize, ZeroizeOnDrop};
34
35/// A Community's stable identity = a random 32-byte opaque id (NOT a
36/// timestamp-encoding snowflake, which would leak creation time).
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38pub struct CommunityId(pub [u8; 32]);
39
40impl CommunityId {
41    /// Lowercase hex — the addressable-event `d`-tag form.
42    pub fn to_hex(&self) -> String {
43        crate::simd::hex::bytes_to_hex_32(&self.0)
44    }
45}
46
47/// A Channel's stable identity within a Community. Same opaque-random rule as
48/// [`CommunityId`]; doubles as the addressable metadata `d`-tag.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
50pub struct ChannelId(pub [u8; 32]);
51
52impl ChannelId {
53    /// Lowercase hex, the form used in the inner channel-binding tag.
54    pub fn to_hex(&self) -> String {
55        crate::simd::hex::bytes_to_hex_32(&self.0)
56    }
57}
58
59/// The epoch counter — the read-access clock ("two clocks"). Bumps only on a
60/// rekey; stamped explicitly even when it is 0, so multi-channel and rotation stay
61/// additive (forward-compat hook #1).
62#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
63pub struct Epoch(pub u64);
64
65/// A 32-byte symmetric channel secret — the raw NIP-44 v2 `ConversationKey`
66/// material. Zeroized on drop; never logged.
67#[derive(Clone, Zeroize, ZeroizeOnDrop)]
68pub struct ChannelKey(pub [u8; 32]);
69
70impl ChannelKey {
71    pub fn as_bytes(&self) -> &[u8; 32] {
72        &self.0
73    }
74}
75
76// Deliberately no Debug: a channel secret must never reach a log line.
77impl core::fmt::Debug for ChannelKey {
78    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
79        f.write_str("ChannelKey(<redacted>)")
80    }
81}
82
83/// Per-epoch pseudonym = the value carried in the relay-filterable `z` tag.
84/// Opaque 32 bytes; outsiders can't link it across epochs or to an identity.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub struct Pseudonym(pub [u8; 32]);
87
88impl Pseudonym {
89    pub fn to_hex(&self) -> String {
90        crate::simd::hex::bytes_to_hex_32(&self.0)
91    }
92}
93
94/// The server-root / `@everyone` key: always minted, always distinct from any
95/// channel key. Gates metadata + roster + roleless channels. Zeroized on drop.
96#[derive(Clone, Zeroize, ZeroizeOnDrop)]
97pub struct ServerRootKey(pub [u8; 32]);
98
99impl ServerRootKey {
100    pub fn as_bytes(&self) -> &[u8; 32] {
101        &self.0
102    }
103}
104
105impl core::fmt::Debug for ServerRootKey {
106    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
107        f.write_str("ServerRootKey(<redacted>)")
108    }
109}
110
111/// The all-zero hex scope id for server-root-scoped epoch keys (`RekeyScope::ServerRoot`
112/// uses the all-zero `id32` sentinel). A `ChannelId` is random-32, so it can never collide with
113/// this — letting one `community_epoch_keys` table hold both channel keys and the base/server-root
114/// key keyed by `(scope_id, epoch)`.
115pub const SERVER_ROOT_SCOPE_HEX: &str =
116    "0000000000000000000000000000000000000000000000000000000000000000";
117
118/// 32 cryptographically-random bytes (OsRng).
119pub(crate) fn random_32() -> [u8; 32] {
120    let mut b = [0u8; 32];
121    rand::rngs::OsRng.fill_bytes(&mut b);
122    b
123}
124
125/// A reference to an encrypted image blob (community logo/banner), using the same
126/// technique as NIP-17 file attachments: a fresh random AES-GCM key+nonce encrypts the
127/// image, the ciphertext is uploaded to Blossom, and this reference travels inside
128/// ServerRoot-sealed metadata. So possession of the server-root key (every member) gates
129/// the image, and there is no key reuse across images.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CommunityImage {
132    /// Blossom URL of the encrypted blob.
133    pub url: String,
134    /// Hex AES-GCM key (per-image, random).
135    pub key: String,
136    /// Hex AES-GCM nonce.
137    pub nonce: String,
138    /// SHA-256 of the plaintext image (integrity check + local cache key).
139    pub hash: String,
140    /// File extension hint (e.g. "png", "jpg").
141    #[serde(default)]
142    pub ext: String,
143}
144
145/// A Channel inside a Community: its own independent key, current epoch, and name.
146///
147/// MULTI-EPOCH READ: `key`/`epoch` are the channel's CURRENT (head) epoch (what SENDS use), while
148/// `epoch_keys` carries EVERY epoch key the member retains across rekeys. The read paths
149/// (`fetch_channel_*`, `open_message_multi`) query `"#z":[<pseudonym per held epoch>]` and select the
150/// decryption key by the wire event's `z` pseudonym tag — so messages a removed-from-the-future member
151/// posted under an older epoch aren't stranded after a catch-up across one or more rekeys. Non-ratcheted
152/// per-epoch keys make this random-access: any retained epoch's plane decrypts directly, no replay.
153#[derive(Debug, Clone)]
154pub struct Channel {
155    pub id: ChannelId,
156    pub key: ChannelKey,
157    pub epoch: Epoch,
158    pub name: String,
159    /// The owning Community's banlist (the "anti-memberlist"), denormalized onto each channel
160    /// so the inbound path (which holds a `&Channel`) can drop events from banned authors
161    /// without a separate lookup. Populated only by `db::load_community`; empty everywhere a
162    /// channel is built for sending or in tests. See [`crate::community::inbound`].
163    pub banned: Vec<PublicKey>,
164    /// The protected set — the proven owner only (implicit roster position 0), denormalized so the
165    /// inbound path enforces the invariant that the owner is NEVER effectively banned or hidden.
166    /// Admins are NOT in this set: the owner outranks them and may ban/hide them; everyone else is
167    /// ranked through the roster, not here. `db::load_community` filters the owner out of `banned`
168    /// and populates this; empty for send-built channels and tests.
169    pub protected: Vec<PublicKey>,
170    /// The Community's AUTHORIZED roster (roles + grants, post delegation check), denormalized so
171    /// the inbound delete path can verify a moderation-hide the keyless way — the hider's real npub must
172    /// hold `MANAGE_MESSAGES` and outrank the target's author, resolved against this roster.
173    /// `db::load_community` populates it from the cached authorized roster; empty for send-built channels
174    /// and tests (an empty roster authorizes only the owner, via `protected`).
175    pub roster: roles::CommunityRoles,
176    /// EVERY epoch key the member retains for this channel (`(epoch, key)`, from the multi-held archive),
177    /// so the read path can fetch + decrypt across rekeys. Populated only by `db::load_community`; empty
178    /// for send-built channels and tests, where reads fall back to the single head epoch (see
179    /// [`Self::read_epoch_keys`]).
180    pub epoch_keys: Vec<(Epoch, ChannelKey)>,
181    /// The owning Community's dissolution seal, denormalized onto each channel so the inbound path
182    /// (which holds a `&Channel`) drops EVERY subsequent event without a separate lookup — any kind, any
183    /// author, any time. Populated only by `db::load_community`; `false` for send-built channels + tests.
184    pub dissolved: bool,
185}
186
187impl Channel {
188    /// The `(epoch, key)` set the read path queries + decrypts against: every retained epoch when
189    /// loaded from the DB, else just the head (send-built channels / tests). Newest epoch first, so a
190    /// backward page walk and the per-event key lookup both see the current epoch before older ones.
191    pub fn read_epoch_keys(&self) -> Vec<(Epoch, ChannelKey)> {
192        let mut keys = if self.epoch_keys.is_empty() {
193            vec![(self.epoch, self.key.clone())]
194        } else {
195            self.epoch_keys.clone()
196        };
197        keys.sort_by(|a, b| b.0.0.cmp(&a.0.0));
198        keys
199    }
200}
201
202/// A Community (Discord's "server").
203///
204/// Keyless authority model: there is no shared signing key. READ access = key possession
205/// (`server_root_key` + the granted channel keys let any member read/post); WRITE authority = the
206/// member's npub rank in the owner-rooted roster, and the OWNER is derived by verifying the
207/// `owner_attestation` (see `service::is_proven_owner`). `ChannelKey`/`ServerRootKey` secrets are not
208/// serialized here; persistence is a separate, vault-backed concern.
209#[derive(Debug, Clone)]
210pub struct Community {
211    pub id: CommunityId,
212    /// @everyone base key — at `server_root_epoch`.
213    pub server_root_key: ServerRootKey,
214    /// The server-root's current epoch — the base/`@everyone` read clock. Bumps only on a
215    /// base rotation (a Private-community removal, or re-founding); stays 0 in a Public community and
216    /// at MVP. The per-epoch server-root pseudonym is derived from this, so it is the G1 seam the
217    /// control-plane fetch widens against once re-anchoring + multi-epoch fetch ship.
218    pub server_root_epoch: Epoch,
219    pub name: String,
220    /// Short description / topic (server-root-gated metadata; shown in invite previews).
221    pub description: Option<String>,
222    /// Logo (encrypted blob ref — see [`CommunityImage`]).
223    pub icon: Option<CommunityImage>,
224    /// Banner (encrypted blob ref).
225    pub banner: Option<CommunityImage>,
226    /// Preferred relay set for all of this Community's events.
227    pub relays: Vec<String>,
228    pub channels: Vec<Channel>,
229    /// The owner's identity attestation (a signed event JSON, see [`owner`]) binding this
230    /// community's id to the owner's npub. The proven owner is DERIVED by verifying it, never stored
231    /// as a bare claim. `None` until the creator signs it. Travels in the GroupRoot + invite bundle.
232    pub owner_attestation: Option<String>,
233    /// The owner-dissolution SEAL. `true` once a folded GroupDissolved tombstone was verified against
234    /// the proven owner — PERMANENT and irreversible (there is no un-dissolve; the way forward is a fresh
235    /// community). Once set, the control fold stops advancing and the inbound path drops every subsequent
236    /// event (any kind/author/time — the seal is this flag, NOT a timestamp). `false` for a live community.
237    pub dissolved: bool,
238}
239
240/// Protocol cap on a Community's relay set (§ transport). More relays are needless and
241/// amplify resource + metadata-exposure cost; 5 gives redundancy without centralisation.
242/// Enforced by truncate-on-read at every Community/CommunityInvite construction boundary, so a
243/// hostile or legacy bundle degrades to ≤5 distinct relays rather than being honored or rejected.
244pub const MAX_COMMUNITY_RELAYS: usize = 5;
245
246/// Dedupe (order-preserving) + truncate a relay set to [`MAX_COMMUNITY_RELAYS`]. Dedup first so the
247/// cap means "up to 5 DISTINCT relays" — a bundle padding one relay 5× can't waste the budget.
248pub fn cap_relays(relays: Vec<String>) -> Vec<String> {
249    let mut seen = std::collections::HashSet::new();
250    let mut out = Vec::with_capacity(relays.len().min(MAX_COMMUNITY_RELAYS));
251    for r in relays {
252        if out.len() >= MAX_COMMUNITY_RELAYS {
253            break;
254        }
255        if seen.insert(r.clone()) {
256            out.push(r);
257        }
258    }
259    out
260}
261
262impl Community {
263    /// Mint a brand-new Community with one default channel, owned by the creator.
264    /// All ids are random opaque 32-byte values (NOT timestamp snowflakes), and the
265    /// server-root + channel keys are independently generated (hook #3). The owner attestation
266    /// is signed separately by `service::create_community` (it needs the owner's identity signer).
267    pub fn create(
268        name: impl Into<String>,
269        default_channel_name: impl Into<String>,
270        relays: Vec<String>,
271    ) -> Self {
272        let channel = Channel {
273            id: ChannelId(random_32()),
274            key: ChannelKey(random_32()),
275            epoch: Epoch(0),
276            name: default_channel_name.into(),
277            banned: Vec::new(),
278            protected: Vec::new(), roster: Default::default(),
279            epoch_keys: Vec::new(),
280            dissolved: false,
281        };
282        Community {
283            id: CommunityId(random_32()),
284            server_root_key: ServerRootKey(random_32()),
285            server_root_epoch: Epoch(0),
286            name: name.into(),
287            description: None,
288            icon: None,
289            banner: None,
290            relays: cap_relays(relays),
291            channels: vec![channel],
292            // Signed asynchronously by `service::create_community` (needs the owner's identity
293            // signer); a freshly-minted in-memory Community has none yet.
294            owner_attestation: None,
295            dissolved: false,
296        }
297    }
298}