Skip to main content

vector_core/community/
invite.rs

1//! Targeted invites (GROUP_PROTOCOL.md).
2//!
3//! An invite bundle is the key material a new member needs to join: the server-root
4//! key, the granted channels' keys + ids + epochs + names, the relay set (the 
5//! bootstrap), the owner attestation, and the Community id/name. It is delivered to the
6//! invitee's npub over a NIP-17 gift-wrapped DM (the carrier; see the service/command
7//! layer). `accept_invite` reconstructs a member-view Community (keyless — authority is
8//! the owner-rooted roster, not a held key).
9//!
10//! Byte fields are hex strings so the bundle is plain JSON inside the DM rumor.
11
12use serde::{Deserialize, Serialize};
13
14use super::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
15use crate::stored_event::event_kind;
16use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey, UnsignedEvent};
17
18/// `skip_serializing_if` helper: omit a `u64` field from the JSON when it's the default 0 (e.g. an
19/// un-rotated epoch). `serde(default)` restores it on read. Trims the Community List on the wire.
20fn is_zero_u64(n: &u64) -> bool {
21    *n == 0
22}
23
24/// A granted channel inside an invite bundle.
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct InviteChannel {
27    pub id: String,
28    pub key: String,
29    #[serde(default, skip_serializing_if = "is_zero_u64")]
30    pub epoch: u64,
31    pub name: String,
32}
33
34/// Everything a new member needs to join a Community.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36pub struct CommunityInvite {
37    pub community_id: String,
38    pub name: String,
39    pub server_root_key: String,
40    /// The server-root's current epoch, so a joiner adopts the right base read clock. `default`
41    /// keeps older bundles parseable (they predate rotation, so epoch 0 is correct for them).
42    #[serde(default, skip_serializing_if = "is_zero_u64")]
43    pub server_root_epoch: u64,
44    pub relays: Vec<String>,
45    pub channels: Vec<InviteChannel>,
46    /// Owner attestation (signed event JSON) so the joiner learns + verifies who the owner
47    /// is. `serde(default)` keeps older bundles (pre-feature) parseable.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub owner_attestation: Option<String>,
50    /// Community icon (encrypted blob ref) so a PARKED private invite can show the logo before the
51    /// recipient joins (the card fetches + decrypts it like a public-invite preview). STRIPPED from
52    /// Community List blobs (see list.rs) since a rehydrating device folds the icon from the community
53    /// metadata; it's only carried in the actual invite bundle. `default` keeps icon-less bundles parseable.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub icon: Option<super::CommunityImage>,
56}
57
58/// Hard caps on a received bundle. A bundle arrives over an unauthenticated gift wrap
59/// from an arbitrary sender, so a hostile one can declare an unbounded channel/relay
60/// list to force mass allocation + per-channel DB writes + relay connections. Reject
61/// anything past a sane MVP ceiling before allocating.
62const MAX_INVITE_CHANNELS: usize = 256;
63
64impl CommunityInvite {
65    pub fn to_json(&self) -> Result<String, String> {
66        serde_json::to_string(self).map_err(|e| e.to_string())
67    }
68    pub fn from_json(json: &str) -> Result<Self, String> {
69        let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
70        // Truncate-on-read: an inbound bundle is unauthenticated, so enforce the relay cap as it
71        // enters rather than trusting (or rejecting on) the sender's count.
72        inv.relays = super::cap_relays(inv.relays);
73        Ok(inv)
74    }
75
76    /// Reject a bundle whose channel count exceeds the MVP ceiling (DoS guard for inbound,
77    /// attacker-controlled bundles). Relays are capped by truncation (`cap_relays`), not rejection,
78    /// so a >5 or legacy bundle degrades to the cap rather than failing the whole join.
79    pub fn validate(&self) -> Result<(), String> {
80        if self.channels.len() > MAX_INVITE_CHANNELS {
81            return Err(format!("invite declares too many channels ({})", self.channels.len()));
82        }
83        Ok(())
84    }
85}
86
87/// Decode a 64-char hex string to 32 bytes, rejecting malformed input (never silently zero-fills —
88/// a corrupt invite must error, not fabricate keys). Public input → SIMD-validated decode.
89fn hex32(hex: &str) -> Result<[u8; 32], String> {
90    crate::simd::hex::hex_to_bytes_32_checked(hex)
91        .ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
92}
93
94/// Build an invite bundle granting ALL of a Community's channels (the MVP grants the
95/// full channel set; per-channel/role-scoped grants are a later feature). Only the
96/// management *pubkey* is included — never the secret, so an invited member can't
97/// write metadata.
98pub fn build_invite(community: &Community) -> CommunityInvite {
99    CommunityInvite {
100        community_id: community.id.to_hex(),
101        name: community.name.clone(),
102        server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
103        server_root_epoch: community.server_root_epoch.0,
104        relays: super::cap_relays(community.relays.clone()),
105        channels: community
106            .channels
107            .iter()
108            .map(|c| InviteChannel {
109                id: c.id.to_hex(),
110                key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
111                epoch: c.epoch.0,
112                name: c.name.clone(),
113            })
114            .collect(),
115        owner_attestation: community.owner_attestation.clone(),
116        icon: community.icon.clone(),
117    }
118}
119
120/// Reconstruct a **member-view** Community from an invite bundle: full read/post access via the
121/// granted channel keys (keyless — write authority is the member's npub roster rank, not a held
122/// key).
123pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
124    invite.validate()?;
125    let id = CommunityId(hex32(&invite.community_id)?);
126    let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);
127
128    // Keep the owner attestation ONLY if it verifies against this community's id (keyless — the
129    // community_id is the sole binding; a bundle can't smuggle a bogus owner claim).
130    let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
131        super::owner::verify_owner_attestation(att, &invite.community_id)
132            .map(|_| att.clone())
133    });
134
135    let mut channels = Vec::with_capacity(invite.channels.len());
136    for ic in &invite.channels {
137        channels.push(Channel {
138            id: ChannelId(hex32(&ic.id)?),
139            key: ChannelKey(hex32(&ic.key)?),
140            epoch: Epoch(ic.epoch),
141            name: ic.name.clone(),
142            // A fresh joiner starts with no local banlist; it arrives with the metadata fetch.
143            banned: Vec::new(),
144            protected: Vec::new(), roster: Default::default(),
145            // Only the current key is conveyed on join; the archive fills as rekeys are caught up.
146            epoch_keys: Vec::new(),
147            dissolved: false,
148        });
149    }
150
151    Ok(Community {
152        id,
153        server_root_key,
154        server_root_epoch: Epoch(invite.server_root_epoch),
155        name: invite.name.clone(),
156        // Description/banner still arrive with the GroupRoot fold; the icon now rides the bundle so it
157        // shows on the parked invite AND instantly on join (the fold refreshes it authoritatively).
158        description: None,
159        icon: invite.icon.clone(),
160        banner: None,
161        relays: super::cap_relays(invite.relays.clone()),
162        channels,
163        owner_attestation,
164        // A fresh join starts alive; the first control fold detects + seals if a tombstone is present.
165        dissolved: false,
166    })
167}
168
169/// Build the gift-wrap rumor that carries an invite to an invitee (carrier). The
170/// rumor is an unsigned NIP-59 inner event (kind 3304) whose content is the bundle
171/// JSON; the caller gift-wraps it to the invitee's npub over NIP-17 (reusing Vector's
172/// existing private-DM path). `my_pubkey` is the rumor author — irrelevant to the
173/// bundle's trust (the owner attestation inside it anchors authority), it just
174/// satisfies NIP-01 serialization.
175pub fn build_invite_rumor(community: &Community, my_pubkey: PublicKey) -> Result<UnsignedEvent, String> {
176    let json = build_invite(community).to_json()?;
177    Ok(EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json).build(my_pubkey))
178}
179
180/// Parse an inbound rumor as a Community invite. Returns `None` unless the rumor is an
181/// invite (kind 3304) carrying a well-formed bundle — a non-invite DM or corrupt
182/// content yields `None`, never an error, so the inbound dispatcher can fall through.
183pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
184    if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
185        return None;
186    }
187    CommunityInvite::from_json(content).ok()
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use crate::community::envelope::{open_message, seal_message};
194    use nostr_sdk::prelude::Keys;
195
196    #[test]
197    fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
198        // 7 entries, 2 of them duplicates → dedup to 6 distinct, truncate to 5.
199        let many: Vec<String> = vec![
200            "wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
201            "wss://d".into(), "wss://e".into(), "wss://f".into(),
202        ];
203        let capped = super::super::cap_relays(many.clone());
204        assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);
205
206        // Mint honors the cap.
207        let owner = Community::create("HQ", "general", many.clone());
208        assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
209
210        // A hostile bundle declaring >5 relays parses (truncate-on-read), not rejects,
211        // and the accepted member view is capped too.
212        let mut bundle = build_invite(&owner);
213        bundle.relays = many; // force an over-cap bundle past build_invite
214        let json = bundle.to_json().unwrap();
215        let parsed = CommunityInvite::from_json(&json).unwrap();
216        assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
217        let member = accept_invite(&parsed).unwrap();
218        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
219    }
220
221    #[test]
222    fn invite_round_trips_to_member_view() {
223        let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
224        let json = build_invite(&owner).to_json().unwrap();
225        let invite = CommunityInvite::from_json(&json).unwrap();
226        let member = accept_invite(&invite).unwrap();
227
228        // Same Community identity + read material (keyless — the bundle conveys read material only).
229        assert_eq!(member.id, owner.id);
230        assert_eq!(member.name, "HQ");
231        assert_eq!(member.relays, owner.relays);
232        assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
233        assert_eq!(member.channels.len(), 1);
234        assert_eq!(member.channels[0].id, owner.channels[0].id);
235        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
236        assert_eq!(member.channels[0].name, "general");
237    }
238
239    #[test]
240    fn invited_member_can_read_owner_messages() {
241        // The killer property: the keys conveyed by the invite actually WORK. Owner
242        // seals a message under the channel key; the invited member, reconstructed
243        // purely from the bundle, opens it and recovers the author.
244        let owner = Community::create("HQ", "general", vec![]);
245        let owner_author = Keys::generate();
246        let chan = &owner.channels[0];
247        let sealed =
248            seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();
249
250        let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
251        let member = accept_invite(&invite).unwrap();
252        let mc = &member.channels[0];
253        let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
254        assert_eq!(opened.content, "welcome!");
255        assert_eq!(opened.author, owner_author.public_key());
256    }
257
258    #[test]
259    fn malformed_invite_errors() {
260        let owner = Community::create("HQ", "general", vec![]);
261        let mut invite = build_invite(&owner);
262        invite.server_root_key = "zz".into(); // too short + non-hex
263        assert!(accept_invite(&invite).is_err());
264    }
265
266    #[test]
267    fn accept_rejects_a_bundle_exceeding_the_caps() {
268        // DoS guard: an attacker-controlled bundle declaring a huge channel list is rejected
269        // before any allocation/connection. Relays are CAPPED by truncation (cap_relays) rather
270        // than rejected, so a >5 relay bundle still joins — just at ≤5 relays.
271        let owner = Community::create("HQ", "general", vec![]);
272        let mut over_relays = build_invite(&owner);
273        over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
274        let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
275        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");
276
277        let mut over_channels = build_invite(&owner);
278        over_channels.channels = (0..500)
279            .map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
280            .collect();
281        assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
282    }
283
284    #[test]
285    fn accept_rejects_malformed_community_id() {
286        // A corrupt id must error, never silently zero-fill into a fabricated community.
287        let owner = Community::create("HQ", "general", vec![]);
288        let mut bad = build_invite(&owner);
289        bad.community_id = "not-64-hex".into();
290        assert!(accept_invite(&bad).is_err());
291    }
292
293    #[test]
294    fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
295        // The bundle can carry an owner_attestation; an unverifiable one is DROPPED (no spoofed crown),
296        // but the join still succeeds — graceful, no panic, no false owner.
297        let owner = Community::create("HQ", "general", vec![]);
298        let mut inv = build_invite(&owner);
299        inv.owner_attestation = Some("not even an event".to_string());
300        let member = accept_invite(&inv).unwrap();
301        assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
302    }
303
304    #[test]
305    fn accept_an_empty_channel_bundle_is_graceful() {
306        let owner = Community::create("HQ", "general", vec![]);
307        let mut inv = build_invite(&owner);
308        inv.channels.clear();
309        let member = accept_invite(&inv).unwrap();
310        assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
311    }
312
313    #[test]
314    fn bundle_carries_read_keys() {
315        // The invite conveys the read keys (server-root + channel) a member needs to read/post.
316        let owner = Community::create("HQ", "general", vec![]);
317        let json = build_invite(&owner).to_json().unwrap();
318        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
319        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
320    }
321
322    #[test]
323    fn invite_rumor_round_trips() {
324        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
325        let author = Keys::generate();
326        let rumor = build_invite_rumor(&owner, author.public_key()).unwrap();
327
328        assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
329        let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
330        let member = accept_invite(&parsed).unwrap();
331        assert_eq!(member.id, owner.id);
332        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
333    }
334
335    #[test]
336    fn parse_invite_rumor_rejects_wrong_kind() {
337        // A normal text DM (kind 14) must never parse as an invite, even if its content
338        // happened to be valid bundle JSON.
339        let owner = Community::create("HQ", "general", vec![]);
340        let json = build_invite(&owner).to_json().unwrap();
341        assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
342        // Right kind but garbage content → None, not panic.
343        assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
344    }
345
346    #[tokio::test]
347    async fn full_carrier_chain_invitee_reads_owner_message() {
348        // End-to-end (sans the gift-wrap transport, which is src-tauri): owner builds an
349        // invite rumor → invitee parses it → accepts → opens a real channel message the
350        // owner sealed. Proves the rumor faithfully conveys working keys.
351        use crate::community::transport::memory::MemoryRelay;
352        use crate::community::send::{fetch_channel_messages, publish_message};
353
354        let owner = Community::create("HQ", "general", vec!["r1".into()]);
355        let author = Keys::generate();
356        let rumor = build_invite_rumor(&owner, author.public_key()).unwrap();
357
358        // The invitee only ever sees the rumor content.
359        let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
360        let member = accept_invite(&invite).unwrap();
361
362        // Owner posts a message; the member (reconstructed from the bundle) reads it.
363        let relay = MemoryRelay::new();
364        let owner_author = Keys::generate();
365        publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
366            .await
367            .unwrap();
368
369        let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
370        assert_eq!(msgs.len(), 1);
371        assert_eq!(msgs[0].content, "welcome aboard");
372        assert_eq!(msgs[0].author, owner_author.public_key());
373    }
374}