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 crate::event_ext::FinalizeUnsignedWithId;
13use serde::{Deserialize, Serialize};
14
15use super::{Channel, ChannelId, ChannelKey, Community, CommunityId, Epoch, ServerRootKey};
16use crate::stored_event::event_kind;
17use nostr_sdk::prelude::{EventBuilder, Kind, PublicKey, UnsignedEvent};
18
19/// `skip_serializing_if` helper: omit a `u64` field from the JSON when it's the default 0 (e.g. an
20/// un-rotated epoch). `serde(default)` restores it on read. Trims the Community List on the wire.
21fn is_zero_u64(n: &u64) -> bool {
22    *n == 0
23}
24
25/// A granted channel inside an invite bundle.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct InviteChannel {
28    pub id: String,
29    pub key: String,
30    /// Always serialized: v0.4.0 readers have no `serde(default)` here, so an omitted
31    /// epoch fails their whole bundle/list parse. Never add `skip_serializing_if`.
32    #[serde(default)]
33    pub epoch: u64,
34    pub name: String,
35}
36
37/// Everything a new member needs to join a Community.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CommunityInvite {
40    pub community_id: String,
41    pub name: String,
42    pub server_root_key: String,
43    /// The server-root's current epoch, so a joiner adopts the right base read clock. `default`
44    /// keeps older bundles parseable (they predate rotation, so epoch 0 is correct for them).
45    #[serde(default, skip_serializing_if = "is_zero_u64")]
46    pub server_root_epoch: u64,
47    /// Tolerant on read: a peer that carries no relays/channels may omit the field
48    /// entirely, and a required Vec would reject the WHOLE bundle over an absent
49    /// empty list. This bundle is also embedded in the v1 Community List, so one
50    /// such entry would strand the entire list.
51    #[serde(default)]
52    pub relays: Vec<String>,
53    #[serde(default)]
54    pub channels: Vec<InviteChannel>,
55    /// Owner attestation (signed event JSON) so the joiner learns + verifies who the owner
56    /// is. `serde(default)` keeps older bundles (pre-feature) parseable.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub owner_attestation: Option<String>,
59    /// Community icon (encrypted blob ref) so a PARKED private invite can show the logo before the
60    /// recipient joins (the card fetches + decrypts it like a public-invite preview). STRIPPED from
61    /// Community List blobs (see list.rs) since a rehydrating device folds the icon from the community
62    /// metadata; it's only carried in the actual invite bundle. `default` keeps icon-less bundles parseable.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub icon: Option<super::CommunityImage>,
65}
66
67/// Hard caps on a received bundle. A bundle arrives over an unauthenticated gift wrap
68/// from an arbitrary sender, so a hostile one can declare an unbounded channel/relay
69/// list to force mass allocation + per-channel DB writes + relay connections. Reject
70/// anything past a sane MVP ceiling before allocating.
71const MAX_INVITE_CHANNELS: usize = 256;
72
73impl CommunityInvite {
74    pub fn to_json(&self) -> Result<String, String> {
75        serde_json::to_string(self).map_err(|e| e.to_string())
76    }
77    pub fn from_json(json: &str) -> Result<Self, String> {
78        let mut inv: Self = serde_json::from_str(json).map_err(|e| e.to_string())?;
79        // Truncate-on-read: an inbound bundle is unauthenticated, so enforce the relay cap as it
80        // enters rather than trusting (or rejecting on) the sender's count.
81        inv.relays = super::cap_relays(inv.relays);
82        Ok(inv)
83    }
84
85    /// Reject a bundle whose channel count exceeds the MVP ceiling (DoS guard for inbound,
86    /// attacker-controlled bundles). Relays are capped by truncation (`cap_relays`), not rejection,
87    /// so a >5 or legacy bundle degrades to the cap rather than failing the whole join.
88    pub fn validate(&self) -> Result<(), String> {
89        if self.channels.len() > MAX_INVITE_CHANNELS {
90            return Err(format!("invite declares too many channels ({})", self.channels.len()));
91        }
92        Ok(())
93    }
94}
95
96/// Decode a 64-char hex string to 32 bytes, rejecting malformed input (never silently zero-fills —
97/// a corrupt invite must error, not fabricate keys). Public input → SIMD-validated decode.
98fn hex32(hex: &str) -> Result<[u8; 32], String> {
99    crate::simd::hex::hex_to_bytes_32_checked(hex)
100        .ok_or_else(|| format!("invalid or wrong-length 64-char hex ({} chars)", hex.len()))
101}
102
103/// Build an invite bundle granting ALL of a Community's channels (the MVP grants the
104/// full channel set; per-channel/role-scoped grants are a later feature). Only the
105/// management *pubkey* is included — never the secret, so an invited member can't
106/// write metadata.
107pub fn build_invite(community: &Community) -> CommunityInvite {
108    CommunityInvite {
109        community_id: community.id.to_hex(),
110        name: community.name.clone(),
111        server_root_key: crate::simd::hex::bytes_to_hex_32(community.server_root_key.as_bytes()),
112        server_root_epoch: community.server_root_epoch.0,
113        relays: super::cap_relays(community.relays.clone()),
114        channels: community
115            .channels
116            .iter()
117            .map(|c| InviteChannel {
118                id: c.id.to_hex(),
119                key: crate::simd::hex::bytes_to_hex_32(c.key.as_bytes()),
120                epoch: c.epoch.0,
121                name: c.name.clone(),
122            })
123            .collect(),
124        owner_attestation: community.owner_attestation.clone(),
125        icon: community.icon.clone(),
126    }
127}
128
129/// Reconstruct a **member-view** Community from an invite bundle: full read/post access via the
130/// granted channel keys (keyless — write authority is the member's npub roster rank, not a held
131/// key).
132pub fn accept_invite(invite: &CommunityInvite) -> Result<Community, String> {
133    invite.validate()?;
134    let id = CommunityId(hex32(&invite.community_id)?);
135    let server_root_key = ServerRootKey(hex32(&invite.server_root_key)?);
136
137    // Keep the owner attestation ONLY if it verifies against this community's id (keyless — the
138    // community_id is the sole binding; a bundle can't smuggle a bogus owner claim).
139    let owner_attestation = invite.owner_attestation.as_ref().and_then(|att| {
140        super::owner::verify_owner_attestation(att, &invite.community_id)
141            .map(|_| att.clone())
142    });
143
144    let mut channels = Vec::with_capacity(invite.channels.len());
145    for ic in &invite.channels {
146        channels.push(Channel {
147            id: ChannelId(hex32(&ic.id)?),
148            key: ChannelKey(hex32(&ic.key)?),
149            epoch: Epoch(ic.epoch),
150            name: ic.name.clone(),
151            // A fresh joiner starts with no local banlist; it arrives with the metadata fetch.
152            banned: Vec::new(),
153            protected: Vec::new(), roster: Default::default(),
154            // Only the current key is conveyed on join; the archive fills as rekeys are caught up.
155            epoch_keys: Vec::new(),
156            dissolved: false,
157        });
158    }
159
160    Ok(Community {
161        id,
162        server_root_key,
163        server_root_epoch: Epoch(invite.server_root_epoch),
164        name: invite.name.clone(),
165        // Description/banner still arrive with the GroupRoot fold; the icon now rides the bundle so it
166        // shows on the parked invite AND instantly on join (the fold refreshes it authoritatively).
167        description: None,
168        icon: invite.icon.clone(),
169        banner: None,
170        relays: super::cap_relays(invite.relays.clone()),
171        channels,
172        owner_attestation,
173        // A fresh join starts alive; the first control fold detects + seals if a tombstone is present.
174        dissolved: false,
175    })
176}
177
178/// Build the gift-wrap rumor that carries an invite to an invitee (carrier). The
179/// rumor is an unsigned NIP-59 inner event (kind 3304) whose content is the bundle
180/// JSON; the caller gift-wraps it to the invitee's npub over NIP-17 (reusing Vector's
181/// existing private-DM path). `my_pubkey` is the rumor author — irrelevant to the
182/// bundle's trust (the owner attestation inside it anchors authority), it just
183/// satisfies NIP-01 serialization.
184pub fn build_invite_rumor(
185    community: &Community,
186    my_pubkey: PublicKey,
187    now_secs: u64,
188) -> Result<UnsignedEvent, String> {
189    let json = build_invite(community).to_json()?;
190    Ok(
191        EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), json)
192            .tag(nostr_sdk::prelude::Tag::expiration(
193                nostr_sdk::prelude::Timestamp::from_secs(now_secs + DIRECT_INVITE_EXPIRY_SECS),
194            ))
195            .finalize_unsigned_with_id(my_pubkey),
196    )
197}
198
199/// How long a Direct Invite stays valid. A bundle hands out live key material for a
200/// community that keeps evolving (rotations, renames, bans), so an invite that never
201/// expires is both stale key material and clutter in the recipient's list. The sender
202/// stamps NIP-40 on the rumor AND (mirrored by the send path) on the outer gift wrap, so
203/// relays drop it on schedule; recipients enforce it themselves too, since relay support
204/// for NIP-40 is optional.
205pub const DIRECT_INVITE_EXPIRY_SECS: u64 = 24 * 60 * 60;
206
207/// Read a NIP-40 `expiration` tag (unix seconds) off an invite rumor's tags. `None` =
208/// no expiry declared (a pre-expiry sender), which callers treat as permanent.
209pub fn expiration_secs(tags: &nostr_sdk::prelude::Tags) -> Option<u64> {
210    tags.iter()
211        .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
212        .and_then(|t| t.as_slice().get(1))
213        .and_then(|v| v.parse::<u64>().ok())
214}
215
216/// Parse an inbound rumor as a Community invite. Returns `None` unless the rumor is an
217/// invite (kind 3304) carrying a well-formed bundle — a non-invite DM or corrupt
218/// content yields `None`, never an error, so the inbound dispatcher can fall through.
219pub fn parse_invite_rumor(kind: Kind, content: &str) -> Option<CommunityInvite> {
220    if kind != Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE) {
221        return None;
222    }
223    CommunityInvite::from_json(content).ok()
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use crate::community::envelope::{open_message, seal_message};
230    use nostr_sdk::prelude::Keys;
231
232    #[test]
233    fn relay_cap_truncates_dedups_and_holds_at_every_boundary() {
234        // 7 entries, 2 of them duplicates → dedup to 6 distinct, truncate to 5.
235        let many: Vec<String> = vec![
236            "wss://a".into(), "wss://b".into(), "wss://a".into(), "wss://c".into(),
237            "wss://d".into(), "wss://e".into(), "wss://f".into(),
238        ];
239        let capped = super::super::cap_relays(many.clone());
240        assert_eq!(capped, vec!["wss://a", "wss://b", "wss://c", "wss://d", "wss://e"]);
241
242        // Mint honors the cap.
243        let owner = Community::create("HQ", "general", many.clone());
244        assert!(owner.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
245
246        // A hostile bundle declaring >5 relays parses (truncate-on-read), not rejects,
247        // and the accepted member view is capped too.
248        let mut bundle = build_invite(&owner);
249        bundle.relays = many; // force an over-cap bundle past build_invite
250        let json = bundle.to_json().unwrap();
251        let parsed = CommunityInvite::from_json(&json).unwrap();
252        assert!(parsed.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
253        let member = accept_invite(&parsed).unwrap();
254        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS);
255    }
256
257    /// A Direct Invite carries a 24h NIP-40 expiry on the RUMOR. The send path mirrors any
258    /// rumor `expiration` tag onto the outer gift wrap, so tagging the rumor is what makes
259    /// relays drop the wrap on schedule — and it survives into the recipient's parked row.
260    #[test]
261    fn invite_rumor_declares_a_24h_expiry() {
262        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
263        let author = Keys::generate();
264        let now = 1_800_000_000u64;
265        let rumor = build_invite_rumor(&owner, author.public_key(), now).unwrap();
266
267        let exp = expiration_secs(&rumor.tags).expect("rumor carries a NIP-40 expiration tag");
268        assert_eq!(exp, now + 86_400, "24h after the send time");
269        assert_eq!(DIRECT_INVITE_EXPIRY_SECS, 86_400);
270    }
271
272    /// `expiration_secs` is the recipient's read of the sender's promise, so it must be
273    /// total: absent, malformed, or valueless tags all mean "no deadline declared" rather
274    /// than a panic or a bogus 0 deadline (which would void the invite instantly).
275    #[test]
276    fn expiration_secs_is_total_over_hostile_tags() {
277        use nostr_sdk::prelude::{Tag, Timestamp};
278        let author = Keys::generate();
279        let mk = |tags: Vec<Tag>| {
280            EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "{}")
281                .tags(tags)
282                .finalize_unsigned_with_id(author.public_key())
283        };
284
285        assert_eq!(expiration_secs(&mk(vec![]).tags), None, "no tag = no deadline");
286        // A non-numeric value must not be read as a deadline.
287        assert_eq!(
288            expiration_secs(&mk(vec![Tag::custom("expiration", ["soon"])]).tags),
289            None,
290        );
291        // A valueless expiration tag likewise.
292        assert_eq!(
293            expiration_secs(&mk(vec![Tag::custom("expiration", Vec::<String>::new())]).tags),
294            None,
295        );
296        // A real tag reads back exactly, even alongside other tags.
297        let tags = vec![
298            Tag::public_key(author.public_key()),
299            Tag::expiration(Timestamp::from_secs(1_800_086_400)),
300        ];
301        assert_eq!(expiration_secs(&mk(tags).tags), Some(1_800_086_400));
302    }
303
304    #[test]
305    fn epoch_zero_channel_serializes_the_field_explicitly() {
306        // v0.4.0 wire contract: InviteChannel.epoch has no serde(default) on shipped
307        // clients, so it must appear in the JSON even at 0 — in the invite bundle AND
308        // in every CommunityInvite embedded in a Community List blob.
309        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
310        let bundle = build_invite(&owner);
311        assert_eq!(bundle.channels[0].epoch, 0);
312        let json = bundle.to_json().unwrap();
313        assert!(json.contains("\"epoch\":0"), "epoch must never be omitted: {json}");
314    }
315
316    #[test]
317    fn invite_round_trips_to_member_view() {
318        let owner = Community::create("HQ", "general", vec!["wss://r1".into(), "wss://r2".into()]);
319        let json = build_invite(&owner).to_json().unwrap();
320        let invite = CommunityInvite::from_json(&json).unwrap();
321        let member = accept_invite(&invite).unwrap();
322
323        // Same Community identity + read material (keyless — the bundle conveys read material only).
324        assert_eq!(member.id, owner.id);
325        assert_eq!(member.name, "HQ");
326        assert_eq!(member.relays, owner.relays);
327        assert_eq!(member.server_root_key.as_bytes(), owner.server_root_key.as_bytes());
328        assert_eq!(member.channels.len(), 1);
329        assert_eq!(member.channels[0].id, owner.channels[0].id);
330        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
331        assert_eq!(member.channels[0].name, "general");
332    }
333
334    #[test]
335    fn invited_member_can_read_owner_messages() {
336        // The killer property: the keys conveyed by the invite actually WORK. Owner
337        // seals a message under the channel key; the invited member, reconstructed
338        // purely from the bundle, opens it and recovers the author.
339        let owner = Community::create("HQ", "general", vec![]);
340        let owner_author = Keys::generate();
341        let chan = &owner.channels[0];
342        let sealed =
343            seal_message(&owner_author, &chan.key, &chan.id, chan.epoch, "welcome!", 1).unwrap();
344
345        let invite = CommunityInvite::from_json(&build_invite(&owner).to_json().unwrap()).unwrap();
346        let member = accept_invite(&invite).unwrap();
347        let mc = &member.channels[0];
348        let opened = open_message(&sealed, &mc.key, &mc.id, mc.epoch).unwrap();
349        assert_eq!(opened.content, "welcome!");
350        assert_eq!(opened.author, owner_author.public_key());
351    }
352
353    #[test]
354    fn malformed_invite_errors() {
355        let owner = Community::create("HQ", "general", vec![]);
356        let mut invite = build_invite(&owner);
357        invite.server_root_key = "zz".into(); // too short + non-hex
358        assert!(accept_invite(&invite).is_err());
359    }
360
361    #[test]
362    fn accept_rejects_a_bundle_exceeding_the_caps() {
363        // DoS guard: an attacker-controlled bundle declaring a huge channel list is rejected
364        // before any allocation/connection. Relays are CAPPED by truncation (cap_relays) rather
365        // than rejected, so a >5 relay bundle still joins — just at ≤5 relays.
366        let owner = Community::create("HQ", "general", vec![]);
367        let mut over_relays = build_invite(&owner);
368        over_relays.relays = (0..100).map(|i| format!("wss://r{i}")).collect();
369        let member = accept_invite(&over_relays).expect("over-relay bundle truncates, not rejects");
370        assert!(member.relays.len() <= super::super::MAX_COMMUNITY_RELAYS, "relays truncated to cap");
371
372        let mut over_channels = build_invite(&owner);
373        over_channels.channels = (0..500)
374            .map(|i| InviteChannel { id: "aa".repeat(32), key: "bb".repeat(32), epoch: 0, name: format!("c{i}") })
375            .collect();
376        assert!(accept_invite(&over_channels).is_err(), "too many channels → rejected");
377    }
378
379    #[test]
380    fn accept_rejects_malformed_community_id() {
381        // A corrupt id must error, never silently zero-fill into a fabricated community.
382        let owner = Community::create("HQ", "general", vec![]);
383        let mut bad = build_invite(&owner);
384        bad.community_id = "not-64-hex".into();
385        assert!(accept_invite(&bad).is_err());
386    }
387
388    #[test]
389    fn accept_drops_a_bogus_owner_attestation_but_still_joins() {
390        // The bundle can carry an owner_attestation; an unverifiable one is DROPPED (no spoofed crown),
391        // but the join still succeeds — graceful, no panic, no false owner.
392        let owner = Community::create("HQ", "general", vec![]);
393        let mut inv = build_invite(&owner);
394        inv.owner_attestation = Some("not even an event".to_string());
395        let member = accept_invite(&inv).unwrap();
396        assert!(member.owner_attestation.is_none(), "an unverifiable attestation is dropped, not trusted");
397    }
398
399    #[test]
400    fn accept_an_empty_channel_bundle_is_graceful() {
401        let owner = Community::create("HQ", "general", vec![]);
402        let mut inv = build_invite(&owner);
403        inv.channels.clear();
404        let member = accept_invite(&inv).unwrap();
405        assert!(member.channels.is_empty(), "a 0-channel bundle accepts without panic");
406    }
407
408    #[test]
409    fn bundle_carries_read_keys() {
410        // The invite conveys the read keys (server-root + channel) a member needs to read/post.
411        let owner = Community::create("HQ", "general", vec![]);
412        let json = build_invite(&owner).to_json().unwrap();
413        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.server_root_key.as_bytes())));
414        assert!(json.contains(&crate::simd::hex::bytes_to_hex_32(owner.channels[0].key.as_bytes())));
415    }
416
417    #[test]
418    fn invite_rumor_round_trips() {
419        let owner = Community::create("HQ", "general", vec!["wss://r1".into()]);
420        let author = Keys::generate();
421        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
422
423        assert_eq!(rumor.kind, Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE));
424        let parsed = parse_invite_rumor(rumor.kind, &rumor.content).expect("parses");
425        let member = accept_invite(&parsed).unwrap();
426        assert_eq!(member.id, owner.id);
427        assert_eq!(member.channels[0].key.as_bytes(), owner.channels[0].key.as_bytes());
428    }
429
430    #[test]
431    fn invite_rumor_stamps_real_send_time_not_backdated() {
432        // The decline-tombstone supersession orders re-invites by the inner rumor's
433        // `created_at`. That only works if the rumor carries the true send time; a
434        // NIP-59-style backdate here would make a fresh re-invite look older than a
435        // decline and get silently dropped (the "can't re-invite after decline" bug).
436        let owner = Community::create("HQ", "general", vec![]);
437        let author = Keys::generate();
438        let now = nostr_sdk::prelude::Timestamp::now().as_secs();
439        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
440        let stamped = rumor.created_at.as_secs();
441        assert!(
442            stamped + 5 >= now && stamped <= now + 5,
443            "rumor created_at {stamped} must track now {now}, not be backdated"
444        );
445    }
446
447    #[test]
448    fn parse_invite_rumor_rejects_wrong_kind() {
449        // A normal text DM (kind 14) must never parse as an invite, even if its content
450        // happened to be valid bundle JSON.
451        let owner = Community::create("HQ", "general", vec![]);
452        let json = build_invite(&owner).to_json().unwrap();
453        assert!(parse_invite_rumor(Kind::Custom(14), &json).is_none());
454        // Right kind but garbage content → None, not panic.
455        assert!(parse_invite_rumor(Kind::Custom(event_kind::COMMUNITY_INVITE_BUNDLE), "not json").is_none());
456    }
457
458    #[tokio::test]
459    async fn full_carrier_chain_invitee_reads_owner_message() {
460        // End-to-end (sans the gift-wrap transport, which is src-tauri): owner builds an
461        // invite rumor → invitee parses it → accepts → opens a real channel message the
462        // owner sealed. Proves the rumor faithfully conveys working keys.
463        use crate::community::transport::memory::MemoryRelay;
464        use crate::community::send::{fetch_channel_messages, publish_message};
465
466        let owner = Community::create("HQ", "general", vec!["r1".into()]);
467        let author = Keys::generate();
468        let rumor = build_invite_rumor(&owner, author.public_key(), 1_800_000_000).unwrap();
469
470        // The invitee only ever sees the rumor content.
471        let invite = parse_invite_rumor(rumor.kind, &rumor.content).expect("invite");
472        let member = accept_invite(&invite).unwrap();
473
474        // Owner posts a message; the member (reconstructed from the bundle) reads it.
475        let relay = MemoryRelay::new();
476        let owner_author = Keys::generate();
477        publish_message(&relay, &owner, &owner.channels[0], &owner_author, "welcome aboard", 1)
478            .await
479            .unwrap();
480
481        let msgs = fetch_channel_messages(&relay, &member, &member.channels[0]).await.unwrap();
482        assert_eq!(msgs.len(), 1);
483        assert_eq!(msgs[0].content, "welcome aboard");
484        assert_eq!(msgs[0].author, owner_author.public_key());
485    }
486}