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 invite_list;
17pub mod list;
18pub mod metadata;
19pub mod policy;
20pub mod moderation;
21pub mod edition;
22pub mod migration;
23pub mod owner;
24pub mod rekey;
25pub mod roster;
26pub mod version;
27pub mod public_invite;
28pub mod raid;
29pub mod realtime;
30pub mod roles;
31pub mod send;
32pub mod service;
33pub mod transport;
34pub mod v2;
35
36use nostr_sdk::prelude::PublicKey;
37use rand::RngCore;
38use serde::{Deserialize, Serialize};
39use zeroize::{Zeroize, ZeroizeOnDrop};
40
41/// How fresh an inner send time must be to surface as a REALTIME event (ms).
42/// Generous against sender clock skew and relay push latency; tiny against the
43/// hours-to-months staleness of a relay-to-relay backfill replay.
44pub const REALTIME_FRESH_WINDOW_MS: u64 = 300_000;
45
46/// Whether an inner send time is fresh enough to surface as REALTIME. The live
47/// subscriptions are limit-0 (never history), but a relay backfilling from a
48/// peer ACCEPTS old events as new writes and pushes them to matching live subs
49/// — without this gate a month-old join or mention surfaces as if it just
50/// happened (bots welcome long-gone joiners, answer stale mentions). Gates only
51/// the "new" surfaces (`is_new`, bot events, pings); folding and persistence
52/// are NEVER gated on freshness.
53pub fn is_realtime_fresh(at_ms: u64) -> bool {
54 let now_ms = std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
56 .map(|d| d.as_millis() as u64)
57 .unwrap_or(0);
58 at_ms.saturating_add(REALTIME_FRESH_WINDOW_MS) >= now_ms
59}
60
61/// Which Concord protocol a community runs. Vector carries both for a migration
62/// window: v1 (the shipped `#z`-addressed stack) and v2 (the self-certifying-id
63/// CORD stack, `community::v2`). Persisted as the `communities.protocol` integer.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ConcordProtocol {
66 V1,
67 V2,
68}
69
70impl ConcordProtocol {
71 pub fn as_i64(self) -> i64 {
72 match self {
73 ConcordProtocol::V1 => 1,
74 ConcordProtocol::V2 => 2,
75 }
76 }
77
78 /// Map the stored integer; anything unrecognized (or a legacy NULL read as 0)
79 /// falls back to v1, the pre-migration default.
80 pub fn from_i64(n: i64) -> ConcordProtocol {
81 match n {
82 2 => ConcordProtocol::V2,
83 _ => ConcordProtocol::V1,
84 }
85 }
86}
87
88/// A Community's stable identity = a random 32-byte opaque id (NOT a
89/// timestamp-encoding snowflake, which would leak creation time).
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
91pub struct CommunityId(pub [u8; 32]);
92
93impl CommunityId {
94 /// Lowercase hex — the addressable-event `d`-tag form.
95 pub fn to_hex(&self) -> String {
96 crate::simd::hex::bytes_to_hex_32(&self.0)
97 }
98}
99
100/// A Channel's stable identity within a Community. Same opaque-random rule as
101/// [`CommunityId`]; doubles as the addressable metadata `d`-tag.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
103pub struct ChannelId(pub [u8; 32]);
104
105impl ChannelId {
106 /// Lowercase hex, the form used in the inner channel-binding tag.
107 pub fn to_hex(&self) -> String {
108 crate::simd::hex::bytes_to_hex_32(&self.0)
109 }
110}
111
112/// The epoch counter — the read-access clock ("two clocks"). Bumps only on a
113/// rekey; stamped explicitly even when it is 0, so multi-channel and rotation stay
114/// additive (forward-compat hook #1).
115#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
116pub struct Epoch(pub u64);
117
118/// A 32-byte symmetric channel secret — the raw NIP-44 v2 `ConversationKey`
119/// material. Zeroized on drop; never logged.
120#[derive(Clone, Zeroize, ZeroizeOnDrop)]
121pub struct ChannelKey(pub [u8; 32]);
122
123impl ChannelKey {
124 pub fn as_bytes(&self) -> &[u8; 32] {
125 &self.0
126 }
127}
128
129// Deliberately no Debug: a channel secret must never reach a log line.
130impl core::fmt::Debug for ChannelKey {
131 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
132 f.write_str("ChannelKey(<redacted>)")
133 }
134}
135
136/// Per-epoch pseudonym = the value carried in the relay-filterable `z` tag.
137/// Opaque 32 bytes; outsiders can't link it across epochs or to an identity.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
139pub struct Pseudonym(pub [u8; 32]);
140
141impl Pseudonym {
142 pub fn to_hex(&self) -> String {
143 crate::simd::hex::bytes_to_hex_32(&self.0)
144 }
145}
146
147/// The server-root / `@everyone` key: always minted, always distinct from any
148/// channel key. Gates metadata + roster + roleless channels. Zeroized on drop.
149#[derive(Clone, Zeroize, ZeroizeOnDrop)]
150pub struct ServerRootKey(pub [u8; 32]);
151
152impl ServerRootKey {
153 pub fn as_bytes(&self) -> &[u8; 32] {
154 &self.0
155 }
156}
157
158impl core::fmt::Debug for ServerRootKey {
159 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
160 f.write_str("ServerRootKey(<redacted>)")
161 }
162}
163
164/// The all-zero hex scope id for server-root-scoped epoch keys (`RekeyScope::ServerRoot`
165/// uses the all-zero `id32` sentinel). A `ChannelId` is random-32, so it can never collide with
166/// this — letting one `community_epoch_keys` table hold both channel keys and the base/server-root
167/// key keyed by `(scope_id, epoch)`.
168pub const SERVER_ROOT_SCOPE_HEX: &str =
169 "0000000000000000000000000000000000000000000000000000000000000000";
170
171/// The epoch-key-archive scope for a v2 `control_root` (CORD-02 §2): non-hex on
172/// purpose, so it collides with neither a channel id nor the root sentinel.
173/// Reserves the pair beside the root at mint (retry idempotency) and archives
174/// the secret per epoch.
175pub const CONTROL_ROOT_SCOPE: &str = "control-root";
176
177/// 32 cryptographically-random bytes (OsRng).
178pub(crate) fn random_32() -> [u8; 32] {
179 let mut b = [0u8; 32];
180 rand::rngs::OsRng.fill_bytes(&mut b);
181 b
182}
183
184/// A reference to an encrypted image blob (community logo/banner), using the same
185/// technique as NIP-17 file attachments: a fresh random AES-GCM key+nonce encrypts the
186/// image, the ciphertext is uploaded to Blossom, and this reference travels inside
187/// ServerRoot-sealed metadata. So possession of the server-root key (every member) gates
188/// the image, and there is no key reuse across images.
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190pub struct CommunityImage {
191 /// Blossom URL of the encrypted blob.
192 pub url: String,
193 /// Hex AES-GCM key (per-image, random).
194 pub key: String,
195 /// Hex AES-GCM nonce.
196 pub nonce: String,
197 /// SHA-256 of the plaintext image (integrity check + local cache key).
198 pub hash: String,
199 /// File extension hint (e.g. "png", "jpg").
200 #[serde(default)]
201 pub ext: String,
202}
203
204/// A Channel inside a Community: its own independent key, current epoch, and name.
205///
206/// MULTI-EPOCH READ: `key`/`epoch` are the channel's CURRENT (head) epoch (what SENDS use), while
207/// `epoch_keys` carries EVERY epoch key the member retains across rekeys. The read paths
208/// (`fetch_channel_*`, `open_message_multi`) query `"#z":[<pseudonym per held epoch>]` and select the
209/// decryption key by the wire event's `z` pseudonym tag — so messages a removed-from-the-future member
210/// posted under an older epoch aren't stranded after a catch-up across one or more rekeys. Non-ratcheted
211/// per-epoch keys make this random-access: any retained epoch's plane decrypts directly, no replay.
212#[derive(Debug, Clone)]
213pub struct Channel {
214 pub id: ChannelId,
215 pub key: ChannelKey,
216 pub epoch: Epoch,
217 pub name: String,
218 /// The owning Community's banlist (the "anti-memberlist"), denormalized onto each channel
219 /// so the inbound path (which holds a `&Channel`) can drop events from banned authors
220 /// without a separate lookup. Populated only by `db::load_community`; empty everywhere a
221 /// channel is built for sending or in tests. See [`crate::community::inbound`].
222 pub banned: Vec<PublicKey>,
223 /// The protected set — the proven owner only (implicit roster position 0), denormalized so the
224 /// inbound path enforces the invariant that the owner is NEVER effectively banned or hidden.
225 /// Admins are NOT in this set: the owner outranks them and may ban/hide them; everyone else is
226 /// ranked through the roster, not here. `db::load_community` filters the owner out of `banned`
227 /// and populates this; empty for send-built channels and tests.
228 pub protected: Vec<PublicKey>,
229 /// The Community's AUTHORIZED roster (roles + grants, post delegation check), denormalized so
230 /// the inbound delete path can verify a moderation-hide the keyless way — the hider's real npub must
231 /// hold `MANAGE_MESSAGES` and outrank the target's author, resolved against this roster.
232 /// `db::load_community` populates it from the cached authorized roster; empty for send-built channels
233 /// and tests (an empty roster authorizes only the owner, via `protected`).
234 pub roster: roles::CommunityRoles,
235 /// EVERY epoch key the member retains for this channel (`(epoch, key)`, from the multi-held archive),
236 /// so the read path can fetch + decrypt across rekeys. Populated only by `db::load_community`; empty
237 /// for send-built channels and tests, where reads fall back to the single head epoch (see
238 /// [`Self::read_epoch_keys`]).
239 pub epoch_keys: Vec<(Epoch, ChannelKey)>,
240 /// The owning Community's dissolution seal, denormalized onto each channel so the inbound path
241 /// (which holds a `&Channel`) drops EVERY subsequent event without a separate lookup — any kind, any
242 /// author, any time. Populated only by `db::load_community`; `false` for send-built channels + tests.
243 pub dissolved: bool,
244}
245
246impl Channel {
247 /// The `(epoch, key)` set the read path queries + decrypts against: every retained epoch when
248 /// loaded from the DB, else just the head (send-built channels / tests). Newest epoch first, so a
249 /// backward page walk and the per-event key lookup both see the current epoch before older ones.
250 pub fn read_epoch_keys(&self) -> Vec<(Epoch, ChannelKey)> {
251 let mut keys = if self.epoch_keys.is_empty() {
252 vec![(self.epoch, self.key.clone())]
253 } else {
254 self.epoch_keys.clone()
255 };
256 keys.sort_by(|a, b| b.0.0.cmp(&a.0.0));
257 keys
258 }
259}
260
261/// A Community (Discord's "server").
262///
263/// Keyless authority model: there is no shared signing key. READ access = key possession
264/// (`server_root_key` + the granted channel keys let any member read/post); WRITE authority = the
265/// member's npub rank in the owner-rooted roster, and the OWNER is derived by verifying the
266/// `owner_attestation` (see `service::is_proven_owner`). `ChannelKey`/`ServerRootKey` secrets are not
267/// serialized here; persistence is a separate, vault-backed concern.
268#[derive(Debug, Clone)]
269pub struct Community {
270 pub id: CommunityId,
271 /// @everyone base key — at `server_root_epoch`.
272 pub server_root_key: ServerRootKey,
273 /// The server-root's current epoch — the base/`@everyone` read clock. Bumps only on a
274 /// base rotation (a Private-community removal, or re-founding); stays 0 in a Public community and
275 /// at MVP. The per-epoch server-root pseudonym is derived from this, so it is the G1 seam the
276 /// control-plane fetch widens against once re-anchoring + multi-epoch fetch ship.
277 pub server_root_epoch: Epoch,
278 pub name: String,
279 /// Short description / topic (server-root-gated metadata; shown in invite previews).
280 pub description: Option<String>,
281 /// Logo (encrypted blob ref — see [`CommunityImage`]).
282 pub icon: Option<CommunityImage>,
283 /// Banner (encrypted blob ref).
284 pub banner: Option<CommunityImage>,
285 /// Preferred relay set for all of this Community's events.
286 pub relays: Vec<String>,
287 pub channels: Vec<Channel>,
288 /// The owner's identity attestation (a signed event JSON, see [`owner`]) binding this
289 /// community's id to the owner's npub. The proven owner is DERIVED by verifying it, never stored
290 /// as a bare claim. `None` until the creator signs it. Travels in the GroupRoot + invite bundle.
291 pub owner_attestation: Option<String>,
292 /// The owner-dissolution SEAL. `true` once a folded GroupDissolved tombstone was verified against
293 /// the proven owner — PERMANENT and irreversible (there is no un-dissolve; the way forward is a fresh
294 /// community). Once set, the control fold stops advancing and the inbound path drops every subsequent
295 /// event (any kind/author/time — the seal is this flag, NOT a timestamp). `false` for a live community.
296 pub dissolved: bool,
297}
298
299/// Protocol cap on a Community's relay set (§ transport). More relays are needless and
300/// amplify resource + metadata-exposure cost; 5 gives redundancy without centralisation.
301/// Enforced by truncate-on-read at every Community/CommunityInvite construction boundary, so a
302/// hostile or legacy bundle degrades to ≤5 distinct relays rather than being honored or rejected.
303pub const MAX_COMMUNITY_RELAYS: usize = 5;
304
305/// Dedupe (order-preserving) + truncate a relay set to [`MAX_COMMUNITY_RELAYS`]. Dedup first so the
306/// cap means "up to 5 DISTINCT relays" — a bundle padding one relay 5× can't waste the budget.
307pub fn cap_relays(relays: Vec<String>) -> Vec<String> {
308 let mut seen = std::collections::HashSet::new();
309 let mut out = Vec::with_capacity(relays.len().min(MAX_COMMUNITY_RELAYS));
310 for r in relays {
311 if out.len() >= MAX_COMMUNITY_RELAYS {
312 break;
313 }
314 if seen.insert(r.clone()) {
315 out.push(r);
316 }
317 }
318 out
319}
320
321impl Community {
322 /// Mint a brand-new Community with one default channel, owned by the creator.
323 /// All ids are random opaque 32-byte values (NOT timestamp snowflakes), and the
324 /// server-root + channel keys are independently generated (hook #3). The owner attestation
325 /// is signed separately by `service::create_community` (it needs the owner's identity signer).
326 pub fn create(
327 name: impl Into<String>,
328 default_channel_name: impl Into<String>,
329 relays: Vec<String>,
330 ) -> Self {
331 let channel = Channel {
332 id: ChannelId(random_32()),
333 key: ChannelKey(random_32()),
334 epoch: Epoch(0),
335 name: default_channel_name.into(),
336 banned: Vec::new(),
337 protected: Vec::new(), roster: Default::default(),
338 epoch_keys: Vec::new(),
339 dissolved: false,
340 };
341 Community {
342 id: CommunityId(random_32()),
343 server_root_key: ServerRootKey(random_32()),
344 server_root_epoch: Epoch(0),
345 name: name.into(),
346 description: None,
347 icon: None,
348 banner: None,
349 relays: cap_relays(relays),
350 channels: vec![channel],
351 // Signed asynchronously by `service::create_community` (needs the owner's identity
352 // signer); a freshly-minted in-memory Community has none yet.
353 owner_attestation: None,
354 dissolved: false,
355 }
356 }
357}
358
359#[cfg(test)]
360mod freshness_tests {
361 use super::*;
362
363 // The realtime gate: a just-sent event is fresh; a relay-backfill replay
364 // (hours to months old) and far-future junk are not "new" surfaces.
365 #[test]
366 fn realtime_freshness_window() {
367 let now_ms = std::time::SystemTime::now()
368 .duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() as u64;
369 assert!(is_realtime_fresh(now_ms), "just-sent is fresh");
370 assert!(is_realtime_fresh(now_ms - REALTIME_FRESH_WINDOW_MS / 2), "modest delay is fresh");
371 assert!(!is_realtime_fresh(now_ms - 3_600_000), "an hour-old replay is not");
372 assert!(!is_realtime_fresh(now_ms.saturating_sub(30 * 24 * 3_600_000)), "a month-old replay is not");
373 assert!(is_realtime_fresh(now_ms + 60_000), "sender clock slightly ahead still fresh");
374 }
375}