1use nostr_sdk::nips::nip44::{
23 self,
24 v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey},
25};
26use nostr_sdk::nips::nip59::RANGE_RANDOM_TIMESTAMP_TWEAK;
27use nostr_sdk::prelude::{
28 Event, EventBuilder, FromBech32, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind, Timestamp, ToBech32, UnsignedEvent,
29};
30use serde::{Deserialize, Serialize};
31
32use super::super::{cap_relays, CommunityId};
33use super::control::ImageRef;
34use super::derive::{verify_community_id, TOKEN_LEN};
35use super::{kind, vsk};
36
37pub const MAX_BUNDLE_CHANNELS: usize = 256;
41
42pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
47
48pub const FRAGMENT_VERSION: u8 = 4;
51
52pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
55
56const FLAG_STOCK_SET: u8 = 0x01;
58
59const RELAY_DICT: [&str; 4] = [
64 "wss://jskitty.com/nostr", "wss://asia.vectorapp.io/nostr", "wss://relay.ditto.pub", "wss://relay.dreamith.to", ];
69
70#[derive(Debug)]
74pub enum InviteError {
75 Json(String),
76 BadHex(&'static str),
78 TooManyChannels(usize),
80 OwnerMismatch,
82 BadFragment(&'static str),
84 BadVersion(u8),
86 BadLink(&'static str),
88 BadEvent(&'static str),
91 Crypto(String),
93}
94
95impl std::fmt::Display for InviteError {
96 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97 match self {
98 InviteError::Json(e) => write!(f, "json: {e}"),
99 InviteError::BadHex(field) => write!(f, "field {field} is not 32-byte hex"),
100 InviteError::TooManyChannels(n) => write!(f, "bundle carries {n} channels (cap {MAX_BUNDLE_CHANNELS})"),
101 InviteError::OwnerMismatch => write!(f, "bundle owner does not reproduce its community_id"),
102 InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
103 InviteError::BadVersion(v) => write!(f, "unsupported invite fragment version {v}"),
104 InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
105 InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
106 InviteError::Crypto(e) => write!(f, "crypto: {e}"),
107 }
108 }
109}
110
111impl std::error::Error for InviteError {}
112
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct ChannelGrant {
119 pub id: String,
121 pub key: String,
123 pub epoch: u64,
124 pub name: String,
125}
126
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct CommunityInvite {
131 pub community_id: String,
133 pub owner: String,
135 pub owner_salt: String,
137 pub community_root: String,
139 pub root_epoch: u64,
140 pub channels: Vec<ChannelGrant>,
141 pub relays: Vec<String>,
142 pub name: String,
144 #[serde(skip_serializing_if = "Option::is_none")]
145 pub icon: Option<ImageRef>,
146 #[serde(skip_serializing_if = "Option::is_none")]
148 pub expires_at: Option<u64>,
149 #[serde(skip_serializing_if = "Option::is_none")]
151 pub creator_npub: Option<String>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub label: Option<String>,
154 #[serde(flatten)]
157 pub extra: serde_json::Map<String, serde_json::Value>,
158}
159
160impl CommunityInvite {
161 pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
165 let mut bundle: CommunityInvite = serde_json::from_str(json).map_err(|e| InviteError::Json(e.to_string()))?;
166 if bundle.channels.len() > MAX_BUNDLE_CHANNELS {
167 return Err(InviteError::TooManyChannels(bundle.channels.len()));
168 }
169 bundle.relays = cap_relays(std::mem::take(&mut bundle.relays));
170 bundle.validate()?;
171 Ok(bundle)
172 }
173
174 pub fn validate(&self) -> Result<(), InviteError> {
178 if self.channels.len() > MAX_BUNDLE_CHANNELS {
179 return Err(InviteError::TooManyChannels(self.channels.len()));
180 }
181 if self.root_epoch > MAX_BUNDLE_EPOCH || self.channels.iter().any(|c| c.epoch > MAX_BUNDLE_EPOCH) {
186 return Err(InviteError::BadFragment("epoch out of range"));
187 }
188 let owner = hex32(&self.owner, "owner")?;
189 let salt = hex32(&self.owner_salt, "owner_salt")?;
190 let cid = hex32(&self.community_id, "community_id")?;
191 if !verify_community_id(&CommunityId(cid), &owner, &salt) {
192 return Err(InviteError::OwnerMismatch);
193 }
194 Ok(())
195 }
196
197 pub fn expired(&self, now_ms: u64) -> bool {
201 self.expires_at.is_some_and(|e| now_ms > e)
202 }
203}
204
205#[derive(Debug)]
209pub enum BundleState {
210 Live(Box<CommunityInvite>),
212 Revoked,
214}
215
216pub fn build_bundle_event(
221 link_signer: &Keys,
222 bundle: &CommunityInvite,
223 bundle_key: &[u8; 32],
224) -> Result<Event, InviteError> {
225 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
226 let content = seal_bundle(bundle_key, &json)?;
227 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
228 .tags([d_empty(), vsk_tag(vsk::INVITE_LIVE)])
229 .sign_with_keys(link_signer)
230 .map_err(|e| InviteError::Crypto(e.to_string()))
231}
232
233pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
237 EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), "")
238 .tags([d_empty(), vsk_tag(vsk::INVITE_REVOKED)])
239 .sign_with_keys(link_signer)
240 .map_err(|e| InviteError::Crypto(e.to_string()))
241}
242
243pub fn parse_bundle_event(
250 event: &Event,
251 expected_signer: &PublicKey,
252 bundle_key: &[u8; 32],
253) -> Result<BundleState, InviteError> {
254 if event.kind.as_u16() != kind::INVITE_BUNDLE {
255 return Err(InviteError::BadEvent("wrong kind"));
256 }
257 if event.pubkey != *expected_signer {
258 return Err(InviteError::BadEvent("author is not the link signer"));
259 }
260 event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
261
262 match first_tag(event, "vsk").as_deref() {
263 Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
264 Some(v) if v == vsk::INVITE_LIVE => {}
265 _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
266 }
267
268 let json = open_bundle(bundle_key, &event.content)?;
269 Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
270}
271
272const INVITE_PATH: &str = "/invite/";
275
276#[derive(Debug, Clone)]
278pub struct ParsedInviteLink {
279 pub link_signer: PublicKey,
281 pub token: [u8; TOKEN_LEN],
282 pub bootstrap_relays: Vec<String>,
283 pub naddr: String,
285}
286
287pub fn stock_relays() -> Vec<String> {
290 RELAY_DICT.iter().map(|s| s.to_string()).collect()
291}
292
293pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
299 let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
300
301 let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
302 bytes.push(FRAGMENT_VERSION);
303 if is_stock {
304 bytes.push(FLAG_STOCK_SET);
305 } else {
306 bytes.push(0x00);
307 let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
308 bytes.push(bounded.len() as u8);
309 for relay in bounded {
310 if let Some(id) = dict_id(relay) {
311 bytes.push(id);
312 } else if let Some(host) = relay.strip_prefix("wss://") {
313 if host.len() > u8::MAX as usize {
314 return Err(InviteError::BadFragment("relay host too long"));
315 }
316 bytes.extend_from_slice(&[0x00, host.len() as u8]);
317 bytes.extend_from_slice(host.as_bytes());
318 } else {
319 if relay.len() > u8::MAX as usize {
320 return Err(InviteError::BadFragment("relay url too long"));
321 }
322 bytes.extend_from_slice(&[0xff, relay.len() as u8]);
323 bytes.extend_from_slice(relay.as_bytes());
324 }
325 }
326 }
327 bytes.extend_from_slice(token);
328 Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
329}
330
331pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
336 let bytes = base64_simd::URL_SAFE_NO_PAD
337 .decode_to_vec(fragment.trim().as_bytes())
338 .map_err(|_| InviteError::BadFragment("not base64url"))?;
339
340 if bytes.len() < 2 {
341 return Err(InviteError::BadFragment("truncated"));
342 }
343 let version = bytes[0];
344 if version != FRAGMENT_VERSION {
347 return Err(InviteError::BadVersion(version));
348 }
349 let flags = bytes[1];
350 let mut o = 2usize;
351
352 let mut relays = Vec::new();
353 if flags & FLAG_STOCK_SET != 0 {
354 relays = stock_relays();
355 } else {
356 let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
357 o += 1;
358 if count > MAX_BOOTSTRAP_RELAYS {
359 return Err(InviteError::BadFragment("too many bootstrap relays"));
360 }
361 for _ in 0..count {
362 let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
363 o += 1;
364 if (1..=254).contains(&lead) {
365 if let Some(url) = dict_url(lead) {
366 relays.push(url.to_string());
367 }
368 } else {
370 let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
371 o += 1;
372 let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
373 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
374 let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
375 relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
376 o = end;
377 }
378 }
379 }
380
381 let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
382 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
383 let mut token = [0u8; TOKEN_LEN];
384 token.copy_from_slice(raw);
385 if end != bytes.len() {
386 return Err(InviteError::BadFragment("trailing bytes"));
387 }
388 Ok((token, relays))
389}
390
391pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
395 let coord = nostr_sdk::nips::nip01::Coordinate {
396 kind: Kind::Custom(kind::INVITE_BUNDLE),
397 public_key: *link_signer,
398 identifier: String::new(),
399 };
400 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
401 nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
402 .to_bech32()
403 .map_err(|e| InviteError::Crypto(e.to_string()))
404}
405
406pub fn build_invite_url(
409 base: &str,
410 link_signer: &PublicKey,
411 token: &[u8; TOKEN_LEN],
412 relays: &[String],
413) -> Result<String, InviteError> {
414 let naddr = bundle_naddr(link_signer)?;
415 let fragment = encode_fragment(token, relays)?;
416 Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
417}
418
419pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
422 let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
423 if fragment.is_empty() {
424 return Err(InviteError::BadLink("empty fragment"));
425 }
426 let naddr = match locator.find(INVITE_PATH) {
428 Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
429 None => locator.trim_start_matches("nostr:"),
430 };
431 let link_signer = signer_from_naddr(naddr)?;
432 let (token, bootstrap_relays) = decode_fragment(fragment)?;
433 Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
442pub struct InviteEntry {
443 pub token: String,
444 pub signer_sk: String,
445 pub community_id: String,
446 pub url: String,
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub label: Option<String>,
449 pub created_at: u64,
450 #[serde(skip_serializing_if = "Option::is_none")]
451 pub expires_at: Option<u64>,
452 #[serde(flatten)]
453 pub extra: serde_json::Map<String, serde_json::Value>,
454}
455
456#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
458pub struct InviteTombstone {
459 pub token: String,
460 pub community_id: String,
461 #[serde(flatten)]
462 pub extra: serde_json::Map<String, serde_json::Value>,
463}
464
465#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
468pub struct InviteList {
469 #[serde(default)]
470 pub entries: Vec<InviteEntry>,
471 #[serde(default)]
472 pub tombstones: Vec<InviteTombstone>,
473 #[serde(flatten)]
474 pub extra: serde_json::Map<String, serde_json::Value>,
475}
476
477pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
482 use std::collections::BTreeMap;
483
484 let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
485 for e in a.entries.into_iter().chain(b.entries) {
486 entries.entry(e.token.clone()).or_insert(e);
487 }
488 let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
489 for t in a.tombstones.into_iter().chain(b.tombstones) {
490 tombstones.entry(t.token.clone()).or_insert(t);
491 }
492 for token in tombstones.keys() {
493 entries.remove(token);
494 }
495 let mut extra = a.extra;
496 extra.extend(b.extra);
497 InviteList {
498 entries: entries.into_values().collect(),
499 tombstones: tombstones.into_values().collect(),
500 extra,
501 }
502}
503
504pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
508 use nostr_sdk::nips::nip44::{encrypt, Version};
509 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
510 let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
511 EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
512 .sign_with_keys(my_keys)
513 .map_err(|e| InviteError::Crypto(e.to_string()))
514}
515
516pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
520 use nostr_sdk::nips::nip44::decrypt;
521 if event.kind.as_u16() != kind::INVITE_LIST {
522 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
523 }
524 let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
525 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
526}
527
528pub fn build_registry_content(signers: &[PublicKey]) -> String {
536 let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
537 serde_json::to_string(&hexes).expect("Vec<String> always serializes")
538}
539
540pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
542 let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
543 hexes
544 .iter()
545 .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
546 .collect()
547}
548
549pub fn build_direct_invite(
560 inviter_keys: &Keys,
561 recipient: &PublicKey,
562 bundle: &CommunityInvite,
563) -> Result<Event, InviteError> {
564 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
565
566 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
568 .custom_created_at(Timestamp::now())
569 .build(inviter_keys.public_key());
570 rumor.ensure_id();
571
572 let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
573 .map_err(|e| InviteError::Crypto(e.to_string()))?;
574 let seal = EventBuilder::new(Kind::Seal, seal_content)
575 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
576 .sign_with_keys(inviter_keys)
577 .map_err(|e| InviteError::Crypto(e.to_string()))?;
578
579 let ephemeral = Keys::generate();
580 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
581 .map_err(|e| InviteError::Crypto(e.to_string()))?;
582 let mut tags = vec![
583 Tag::public_key(*recipient),
584 Tag::custom(TagKind::Custom("k".into()), [kind::DIRECT_INVITE.to_string()]),
585 ];
586 if let Some(ms) = bundle.expires_at {
587 tags.push(Tag::custom(TagKind::Custom("expiration".into()), [(ms / 1000).to_string()]));
588 }
589 EventBuilder::new(Kind::GiftWrap, wrap_content)
590 .tags(tags)
591 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
592 .sign_with_keys(&ephemeral)
593 .map_err(|e| InviteError::Crypto(e.to_string()))
594}
595
596pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
603 if wrap.kind != Kind::GiftWrap {
604 return Err(InviteError::BadEvent("not a gift wrap"));
605 }
606 let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
607 .map_err(|e| InviteError::Crypto(e.to_string()))?;
608 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
609 if seal.kind != Kind::Seal {
610 return Err(InviteError::BadEvent("inner is not a seal"));
611 }
612 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
613
614 let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
615 .map_err(|e| InviteError::Crypto(e.to_string()))?;
616 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
617 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
618 return Err(InviteError::BadEvent("rumor is not a direct invite"));
619 }
620 if rumor.pubkey != seal.pubkey {
621 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
622 }
623 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
624 Ok((seal.pubkey, bundle))
625}
626
627fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
630 crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
631}
632
633fn dict_id(url: &str) -> Option<u8> {
634 RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
635}
636
637fn dict_url(id: u8) -> Option<&'static str> {
638 RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
639}
640
641fn d_empty() -> Tag {
642 Tag::custom(TagKind::Custom("d".into()), [""])
643}
644
645fn vsk_tag(value: &str) -> Tag {
646 Tag::custom(TagKind::Custom("vsk".into()), [value])
647}
648
649fn first_tag(event: &Event, name: &str) -> Option<String> {
650 event.tags.iter().find_map(|t| {
651 let s = t.as_slice();
652 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
653 })
654}
655
656pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
660 let ck = ConversationKey::new(*bundle_key);
661 let ct = encrypt_to_bytes(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
662 Ok(base64_simd::STANDARD.encode_to_string(&ct))
663}
664
665fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
666 let ck = ConversationKey::new(*bundle_key);
667 let ct = base64_simd::STANDARD
668 .decode_to_vec(content.as_bytes())
669 .map_err(|e| InviteError::Crypto(e.to_string()))?;
670 let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
671 String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
672}
673
674fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
675 let trimmed = naddr.trim().trim_start_matches("nostr:");
676 let n19 = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
677 match n19 {
678 nostr_sdk::nips::nip19::Nip19::Coordinate(c)
679 if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
680 {
681 Ok(c.coordinate.public_key)
682 }
683 _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
684 }
685}
686
687#[cfg(test)]
688mod tests {
689 use super::super::control::CommunityIdentity;
690 use super::super::derive::invite_bundle_key;
691 use super::*;
692 use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
693
694 fn hex(bytes: &[u8; 32]) -> String {
695 crate::simd::hex::bytes_to_hex_32(bytes)
696 }
697
698 fn valid_bundle() -> (Keys, CommunityInvite) {
700 let owner = Keys::generate();
701 let id = CommunityIdentity::mint(&owner.public_key());
702 let bundle = CommunityInvite {
703 community_id: hex(&id.community_id.0),
704 owner: hex(&id.owner_xonly),
705 owner_salt: hex(&id.owner_salt),
706 community_root: hex(&random_32()),
707 root_epoch: 0,
708 channels: vec![],
709 relays: vec!["wss://a.example".into(), "wss://b.example".into()],
710 name: "Test community".into(),
711 icon: None,
712 expires_at: None,
713 creator_npub: None,
714 label: None,
715 extra: Default::default(),
716 };
717 (owner, bundle)
718 }
719
720 fn token16() -> [u8; TOKEN_LEN] {
721 std::array::from_fn(|i| i as u8) }
723
724 #[test]
727 fn validate_accepts_a_correct_owner_salt_id_triple() {
728 let (_owner, bundle) = valid_bundle();
729 assert!(bundle.validate().is_ok());
730 }
731
732 #[test]
733 fn validate_rejects_a_forged_owner() {
734 let (_owner, mut bundle) = valid_bundle();
735 let attacker = Keys::generate();
737 bundle.owner = hex(&attacker.public_key().to_bytes());
738 assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
739 }
740
741 #[test]
742 fn from_json_rejects_over_the_channel_cap() {
743 let (_owner, mut bundle) = valid_bundle();
744 bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
745 .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
746 .collect();
747 let json = serde_json::to_string(&bundle).unwrap();
748 assert!(matches!(
749 CommunityInvite::from_bundle_json(&json),
750 Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
751 ));
752 }
753
754 #[test]
755 fn from_json_truncates_relays_to_the_community_cap() {
756 let (_owner, mut bundle) = valid_bundle();
757 bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
758 let json = serde_json::to_string(&bundle).unwrap();
759 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
760 assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
761 }
762
763 #[test]
764 fn unknown_bundle_fields_round_trip() {
765 let (_owner, bundle) = valid_bundle();
768 let mut value = serde_json::to_value(&bundle).unwrap();
769 let obj = value.as_object_mut().unwrap();
770 obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
771 obj.insert("refounder".into(), serde_json::json!("deadbeef"));
772 obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
773 let json = serde_json::to_string(&value).unwrap();
774
775 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
776 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
777 assert_eq!(out["held_roots"][0]["epoch"], 2);
778 assert_eq!(out["refounder"], "deadbeef");
779 assert_eq!(out["future_field"]["deep"][1], 2);
780 }
781
782 #[test]
783 fn expiry_is_a_join_gate_not_a_render_gate() {
784 let (_owner, mut bundle) = valid_bundle();
785 bundle.expires_at = Some(1_000_000);
786 assert!(!bundle.expired(999_999));
787 assert!(bundle.expired(1_000_001));
788 }
789
790 #[test]
793 fn fragment_golden_stock_set() {
794 let token = token16();
798 let frag = encode_fragment(&token, &stock_relays()).unwrap();
799 assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
800 let (t, relays) = decode_fragment(&frag).unwrap();
801 assert_eq!(t, token);
802 assert_eq!(relays, stock_relays());
803 }
804
805 #[test]
806 fn fragment_golden_dictionary_id_mix() {
807 let token = token16();
811 let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
812 let frag = encode_fragment(&token, &relays).unwrap();
813 assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
814 let (t, out) = decode_fragment(&frag).unwrap();
815 assert_eq!(t, token);
816 assert_eq!(out, relays);
817 }
818
819 #[test]
820 fn fragment_golden_wss_implied_literal() {
821 let token = token16();
825 let relays = vec!["wss://x.y".to_string()];
826 let frag = encode_fragment(&token, &relays).unwrap();
827 assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
828 let (t, out) = decode_fragment(&frag).unwrap();
829 assert_eq!(t, token);
830 assert_eq!(out, relays);
831 }
832
833 #[test]
834 fn fragment_golden_verbatim_literal() {
835 let token = token16();
839 let relays = vec!["ws://h".to_string()];
840 let frag = encode_fragment(&token, &relays).unwrap();
841 assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
842 let (t, out) = decode_fragment(&frag).unwrap();
843 assert_eq!(t, token);
844 assert_eq!(out, relays);
845 }
846
847 #[test]
848 fn fragment_rejects_wrong_version_both_directions() {
849 let token = token16();
850 for bad in [3u8, 5u8] {
851 let mut bytes = vec![bad, FLAG_STOCK_SET];
852 bytes.extend_from_slice(&token);
853 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
854 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
855 }
856 }
857
858 #[test]
859 fn fragment_rejects_trailing_bytes() {
860 let token = token16();
861 let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
862 bytes.extend_from_slice(&token);
863 bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
865 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
866 }
867
868 #[test]
869 fn fragment_rejects_count_over_three() {
870 let token = token16();
871 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
873 bytes.extend_from_slice(&token);
874 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
875 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
876 }
877
878 #[test]
879 fn fragment_skips_an_unknown_dictionary_id() {
880 let token = token16();
881 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
883 bytes.extend_from_slice(&token);
884 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
885 let (t, relays) = decode_fragment(&frag).unwrap();
886 assert_eq!(t, token);
887 assert!(relays.is_empty());
888 }
889
890 #[test]
891 fn fragment_caps_bootstrap_relays_at_three() {
892 let token = token16();
893 let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
894 let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
895 assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
896 }
897
898 #[test]
901 fn bundle_event_round_trips_live() {
902 let (_owner, bundle) = valid_bundle();
903 let link = Keys::generate();
904 let token = random_32();
905 let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
906 let event = build_bundle_event(&link, &bundle, &key).unwrap();
907 assert_eq!(event.pubkey, link.public_key());
908
909 match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
910 BundleState::Live(b) => {
911 assert_eq!(b.community_id, bundle.community_id);
912 assert_eq!(b.name, "Test community");
913 }
914 BundleState::Revoked => panic!("expected Live"),
915 }
916 }
917
918 #[test]
919 fn revocation_reads_as_revoked() {
920 let link = Keys::generate();
921 let tomb = build_revocation(&link).unwrap();
922 let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
923 assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
924 }
925
926 #[test]
927 fn bundle_event_wrong_key_fails() {
928 let (_owner, bundle) = valid_bundle();
929 let link = Keys::generate();
930 let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
931 let event = build_bundle_event(&link, &bundle, &key).unwrap();
932 let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
933 assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
934 }
935
936 #[test]
937 fn bundle_event_from_a_different_author_is_rejected() {
938 let (_owner, bundle) = valid_bundle();
941 let real = Keys::generate();
942 let squatter = Keys::generate();
943 let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
944 let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
945 assert!(matches!(
946 parse_bundle_event(&event, &real.public_key(), &key),
947 Err(InviteError::BadEvent("author is not the link signer"))
948 ));
949 }
950
951 #[test]
952 fn bundle_event_with_a_tampered_signature_is_rejected() {
953 let (_owner, bundle) = valid_bundle();
954 let link = Keys::generate();
955 let other = Keys::generate();
956 let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
957 let event = build_bundle_event(&link, &bundle, &key).unwrap();
958 let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
960 json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
961 let Ok(forged) = Event::from_json(json.to_string()) else { return };
962 assert!(matches!(
963 parse_bundle_event(&forged, &other.public_key(), &key),
964 Err(InviteError::BadEvent("signature invalid"))
965 ));
966 }
967
968 fn entry(token: &str, community: &str) -> InviteEntry {
971 InviteEntry {
972 token: token.into(),
973 signer_sk: "bb".repeat(32),
974 community_id: community.into(),
975 url: "https://x/invite/naddr1xyz#frag".into(),
976 label: None,
977 created_at: 1000,
978 expires_at: None,
979 extra: Default::default(),
980 }
981 }
982
983 #[test]
984 fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
985 let e = entry(&"aa".repeat(16), &"cc".repeat(32));
986 let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
987 assert_eq!(a.entries.len(), 1);
988
989 let mut mutated = e.clone();
991 mutated.label = Some("changed".into());
992 let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
993 assert_eq!(still.entries[0].label, None);
994
995 let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
997 let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
998 assert!(b.entries.is_empty());
999 assert_eq!(b.tombstones.len(), 1);
1000
1001 let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1003 assert!(c.entries.is_empty());
1004 }
1005
1006 #[test]
1007 fn invite_list_tombstones_union() {
1008 let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1009 let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1010 let merged = merge_invite_lists(
1011 InviteList { tombstones: vec![t1], ..Default::default() },
1012 InviteList { tombstones: vec![t2], ..Default::default() },
1013 );
1014 assert_eq!(merged.tombstones.len(), 2);
1015 }
1016
1017 #[test]
1018 fn invite_list_unknown_fields_round_trip() {
1019 let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1020 let list: InviteList = serde_json::from_str(wire).unwrap();
1021 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1022 assert_eq!(out["schema"], "future");
1023 assert_eq!(out["note"]["x"], 1);
1024 }
1025
1026 #[test]
1029 fn direct_invite_round_trips_to_inviter_and_bundle() {
1030 let inviter = Keys::generate();
1031 let recipient = Keys::generate();
1032 let (_owner, bundle) = valid_bundle();
1033
1034 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1035 assert_eq!(wrap.kind, Kind::GiftWrap);
1036 assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1037 assert!(wrap.tags.iter().any(|t| {
1038 let s = t.as_slice();
1039 s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1040 }));
1041
1042 let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1043 assert_eq!(sender, inviter.public_key());
1044 assert_eq!(out.community_id, bundle.community_id);
1045 assert_eq!(out.name, "Test community");
1046 }
1047
1048 #[test]
1049 fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1050 let inviter = Keys::generate();
1053 let claimed = Keys::generate();
1054 let recipient = Keys::generate();
1055 let (_owner, bundle) = valid_bundle();
1056
1057 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1059 let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1060 let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1061 seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1062 let ephemeral = Keys::generate();
1064 let wrap_ct =
1065 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1066 .unwrap();
1067 let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1068 .tags([Tag::public_key(recipient.public_key())])
1069 .sign_with_keys(&ephemeral)
1070 .unwrap();
1071 assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1072 }
1073
1074 #[test]
1075 fn direct_invite_rejects_a_non_invite_rumor_kind() {
1076 let inviter = Keys::generate();
1077 let recipient = Keys::generate();
1078 let (_owner, bundle) = valid_bundle();
1079 let json = serde_json::to_string(&bundle).unwrap();
1080
1081 let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1083 .custom_created_at(Timestamp::now())
1084 .build(inviter.public_key());
1085 rumor.ensure_id();
1086 let seal_ct =
1087 nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1088 let seal = EventBuilder::new(Kind::Seal, seal_ct).sign_with_keys(&inviter).unwrap();
1089 let ephemeral = Keys::generate();
1090 let wrap_ct =
1091 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1092 let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1093 .tags([Tag::public_key(recipient.public_key())])
1094 .sign_with_keys(&ephemeral)
1095 .unwrap();
1096 assert!(matches!(
1097 unwrap_direct_invite(&wrap, &recipient),
1098 Err(InviteError::BadEvent("rumor is not a direct invite"))
1099 ));
1100 }
1101
1102 #[test]
1103 fn direct_invite_stamps_nip40_expiration_in_seconds() {
1104 let inviter = Keys::generate();
1105 let recipient = Keys::generate();
1106 let (_owner, mut bundle) = valid_bundle();
1107 let expires_ms = 1_735_689_600_000u64;
1108 bundle.expires_at = Some(expires_ms);
1109
1110 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1111 let exp = wrap.tags.iter().find_map(|t| {
1112 let s = t.as_slice();
1113 (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1114 });
1115 assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1116 }
1117
1118 #[test]
1119 fn direct_invite_rejects_a_forged_owner_bundle() {
1120 let inviter = Keys::generate();
1121 let recipient = Keys::generate();
1122 let (_owner, mut bundle) = valid_bundle();
1123 bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1125 assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1126 }
1127
1128 #[test]
1131 fn invite_link_round_trips_full_url_and_bare_form() {
1132 let link = Keys::generate();
1133 let token = token16();
1134 let relays = vec!["wss://a.example".to_string()];
1135 let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1136 assert!(url.contains("/invite/naddr1"));
1137
1138 let parsed = parse_invite_link(&url).unwrap();
1139 assert_eq!(parsed.link_signer, link.public_key());
1140 assert_eq!(parsed.token, token);
1141 assert_eq!(parsed.bootstrap_relays, relays);
1142
1143 let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1144 assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1145 }
1146
1147 #[test]
1148 fn invite_link_rejects_non_invites() {
1149 assert!(parse_invite_link("hello world").is_err());
1150 assert!(parse_invite_link("wss://relay.example.com").is_err());
1151 assert!(parse_invite_link("https://x/invite/#frag").is_err());
1152 }
1153
1154 #[test]
1155 fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1156 let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1162 assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1163 assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1165 let token = [0x07u8; TOKEN_LEN];
1167 let signer = Keys::generate();
1168 let naddr = bundle_naddr(&signer.public_key()).unwrap();
1169 let frag = encode_fragment(&token, &[]).unwrap();
1170 assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1171 }
1172
1173 #[test]
1176 fn registry_content_round_trips_and_leaks_no_secrets() {
1177 let a = Keys::generate();
1178 let b = Keys::generate();
1179 let content = build_registry_content(&[a.public_key(), b.public_key()]);
1180
1181 let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1183 assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1184 assert!(!content.contains("invite"));
1186 assert!(!content.contains("token"));
1187 assert!(!content.contains("://"));
1188
1189 let back = parse_registry_content(&content).unwrap();
1190 assert_eq!(back, vec![a.public_key(), b.public_key()]);
1191 }
1192}