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 if !first_tag(event, "d").unwrap_or_default().is_empty() {
263 return Err(InviteError::BadEvent("bundle is not at the link's coordinate"));
264 }
265 event.verify().map_err(|_| InviteError::BadEvent("signature invalid"))?;
266
267 match first_tag(event, "vsk").as_deref() {
268 Some(v) if v == vsk::INVITE_REVOKED => return Ok(BundleState::Revoked),
269 Some(v) if v == vsk::INVITE_LIVE => {}
270 _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
271 }
272
273 let json = open_bundle(bundle_key, &event.content)?;
274 Ok(BundleState::Live(Box::new(CommunityInvite::from_bundle_json(&json)?)))
275}
276
277const INVITE_PATH: &str = "/invite/";
280
281#[derive(Debug, Clone)]
283pub struct ParsedInviteLink {
284 pub link_signer: PublicKey,
286 pub token: [u8; TOKEN_LEN],
287 pub bootstrap_relays: Vec<String>,
288 pub naddr: String,
290}
291
292pub fn stock_relays() -> Vec<String> {
295 RELAY_DICT.iter().map(|s| s.to_string()).collect()
296}
297
298pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
304 let is_stock = relays.len() == RELAY_DICT.len() && relays.iter().zip(RELAY_DICT.iter()).all(|(r, d)| r == d);
305
306 let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
307 bytes.push(FRAGMENT_VERSION);
308 if is_stock {
309 bytes.push(FLAG_STOCK_SET);
310 } else {
311 bytes.push(0x00);
312 let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
313 bytes.push(bounded.len() as u8);
314 for relay in bounded {
315 if let Some(id) = dict_id(relay) {
316 bytes.push(id);
317 } else if let Some(host) = relay.strip_prefix("wss://") {
318 if host.len() > u8::MAX as usize {
319 return Err(InviteError::BadFragment("relay host too long"));
320 }
321 bytes.extend_from_slice(&[0x00, host.len() as u8]);
322 bytes.extend_from_slice(host.as_bytes());
323 } else {
324 if relay.len() > u8::MAX as usize {
325 return Err(InviteError::BadFragment("relay url too long"));
326 }
327 bytes.extend_from_slice(&[0xff, relay.len() as u8]);
328 bytes.extend_from_slice(relay.as_bytes());
329 }
330 }
331 }
332 bytes.extend_from_slice(token);
333 Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes))
334}
335
336pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
341 let bytes = base64_simd::URL_SAFE_NO_PAD
342 .decode_to_vec(fragment.trim().as_bytes())
343 .map_err(|_| InviteError::BadFragment("not base64url"))?;
344
345 if bytes.len() < 2 {
346 return Err(InviteError::BadFragment("truncated"));
347 }
348 let version = bytes[0];
349 if version != FRAGMENT_VERSION {
352 return Err(InviteError::BadVersion(version));
353 }
354 let flags = bytes[1];
355 let mut o = 2usize;
356
357 let mut relays = Vec::new();
358 if flags & FLAG_STOCK_SET != 0 {
359 relays = stock_relays();
360 } else {
361 let count = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
362 o += 1;
363 if count > MAX_BOOTSTRAP_RELAYS {
364 return Err(InviteError::BadFragment("too many bootstrap relays"));
365 }
366 for _ in 0..count {
367 let lead = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))?;
368 o += 1;
369 if (1..=254).contains(&lead) {
370 if let Some(url) = dict_url(lead) {
371 relays.push(url.to_string());
372 }
373 } else {
375 let len = *bytes.get(o).ok_or(InviteError::BadFragment("truncated"))? as usize;
376 o += 1;
377 let end = o.checked_add(len).ok_or(InviteError::BadFragment("truncated"))?;
378 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
379 let text = std::str::from_utf8(raw).map_err(|_| InviteError::BadFragment("relay not utf8"))?;
380 relays.push(if lead == 255 { text.to_string() } else { format!("wss://{text}") });
381 o = end;
382 }
383 }
384 }
385
386 let end = o.checked_add(TOKEN_LEN).ok_or(InviteError::BadFragment("truncated"))?;
387 let raw = bytes.get(o..end).ok_or(InviteError::BadFragment("truncated"))?;
388 let mut token = [0u8; TOKEN_LEN];
389 token.copy_from_slice(raw);
390 if end != bytes.len() {
391 return Err(InviteError::BadFragment("trailing bytes"));
392 }
393 Ok((token, relays))
394}
395
396pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
400 let coord = nostr_sdk::nips::nip01::Coordinate {
401 kind: Kind::Custom(kind::INVITE_BUNDLE),
402 public_key: *link_signer,
403 identifier: String::new(),
404 };
405 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate { coordinate: coord, relays: Vec::new() };
406 nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
407 .to_bech32()
408 .map_err(|e| InviteError::Crypto(e.to_string()))
409}
410
411pub fn build_invite_url(
414 base: &str,
415 link_signer: &PublicKey,
416 token: &[u8; TOKEN_LEN],
417 relays: &[String],
418) -> Result<String, InviteError> {
419 let naddr = bundle_naddr(link_signer)?;
420 let fragment = encode_fragment(token, relays)?;
421 Ok(format!("{}{INVITE_PATH}{naddr}#{fragment}", base.trim_end_matches('/')))
422}
423
424pub fn parse_invite_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
427 let (locator, fragment) = input.trim().split_once('#').ok_or(InviteError::BadLink("no fragment"))?;
428 if fragment.is_empty() {
429 return Err(InviteError::BadLink("empty fragment"));
430 }
431 let naddr = match locator.find(INVITE_PATH) {
433 Some(i) => locator[i + INVITE_PATH.len()..].trim_end_matches('/'),
434 None => locator.trim_start_matches("nostr:"),
435 };
436 let link_signer = signer_from_naddr(naddr)?;
437 let (token, bootstrap_relays) = decode_fragment(fragment)?;
438 Ok(ParsedInviteLink { link_signer, token, bootstrap_relays, naddr: naddr.to_string() })
439}
440
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447pub struct InviteEntry {
448 pub token: String,
449 pub signer_sk: String,
450 pub community_id: String,
451 pub url: String,
452 #[serde(skip_serializing_if = "Option::is_none")]
453 pub label: Option<String>,
454 pub created_at: u64,
455 #[serde(skip_serializing_if = "Option::is_none")]
456 pub expires_at: Option<u64>,
457 #[serde(flatten)]
458 pub extra: serde_json::Map<String, serde_json::Value>,
459}
460
461#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
463pub struct InviteTombstone {
464 pub token: String,
465 pub community_id: String,
466 #[serde(flatten)]
467 pub extra: serde_json::Map<String, serde_json::Value>,
468}
469
470#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
473pub struct InviteList {
474 #[serde(default)]
475 pub entries: Vec<InviteEntry>,
476 #[serde(default)]
477 pub tombstones: Vec<InviteTombstone>,
478 #[serde(flatten)]
479 pub extra: serde_json::Map<String, serde_json::Value>,
480}
481
482pub fn merge_invite_lists(a: InviteList, b: InviteList) -> InviteList {
487 use std::collections::BTreeMap;
488
489 let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
490 for e in a.entries.into_iter().chain(b.entries) {
491 entries.entry(e.token.clone()).or_insert(e);
492 }
493 let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
494 for t in a.tombstones.into_iter().chain(b.tombstones) {
495 tombstones.entry(t.token.clone()).or_insert(t);
496 }
497 for token in tombstones.keys() {
498 entries.remove(token);
499 }
500 let mut extra = a.extra;
501 extra.extend(b.extra);
502 InviteList {
503 entries: entries.into_values().collect(),
504 tombstones: tombstones.into_values().collect(),
505 extra,
506 }
507}
508
509pub fn build_invite_list_event(my_keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
513 use nostr_sdk::nips::nip44::{encrypt, Version};
514 let json = serde_json::to_string(list).map_err(|e| InviteError::Json(e.to_string()))?;
515 let content = encrypt(my_keys.secret_key(), &my_keys.public_key(), json.as_bytes(), Version::V2).map_err(|e| InviteError::Crypto(e.to_string()))?;
516 EventBuilder::new(Kind::Custom(kind::INVITE_LIST), content)
517 .sign_with_keys(my_keys)
518 .map_err(|e| InviteError::Crypto(e.to_string()))
519}
520
521pub fn parse_invite_list_event(event: &Event, my_keys: &Keys) -> Result<InviteList, InviteError> {
525 use nostr_sdk::nips::nip44::decrypt;
526 if event.kind.as_u16() != kind::INVITE_LIST {
527 return Err(InviteError::BadEvent("not a kind-13303 invite list"));
528 }
529 let json = decrypt(my_keys.secret_key(), &my_keys.public_key(), &event.content).map_err(|e| InviteError::Crypto(e.to_string()))?;
530 serde_json::from_str(&json).map_err(|e| InviteError::Json(e.to_string()))
531}
532
533pub fn build_registry_content(signers: &[PublicKey]) -> String {
541 let hexes: Vec<String> = signers.iter().map(|p| p.to_hex()).collect();
542 serde_json::to_string(&hexes).expect("Vec<String> always serializes")
543}
544
545pub fn parse_registry_content(content: &str) -> Result<Vec<PublicKey>, InviteError> {
547 let hexes: Vec<String> = serde_json::from_str(content).map_err(|e| InviteError::Json(e.to_string()))?;
548 hexes
549 .iter()
550 .map(|h| PublicKey::from_hex(h).map_err(|_| InviteError::BadHex("registry signer")))
551 .collect()
552}
553
554pub fn build_direct_invite(
565 inviter_keys: &Keys,
566 recipient: &PublicKey,
567 bundle: &CommunityInvite,
568) -> Result<Event, InviteError> {
569 let json = serde_json::to_string(bundle).map_err(|e| InviteError::Json(e.to_string()))?;
570
571 let mut rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json)
573 .custom_created_at(Timestamp::now())
574 .build(inviter_keys.public_key());
575 rumor.ensure_id();
576
577 let seal_content = nip44::encrypt(inviter_keys.secret_key(), recipient, rumor.as_json(), nip44::Version::default())
578 .map_err(|e| InviteError::Crypto(e.to_string()))?;
579 let seal = EventBuilder::new(Kind::Seal, seal_content)
580 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
581 .sign_with_keys(inviter_keys)
582 .map_err(|e| InviteError::Crypto(e.to_string()))?;
583
584 let ephemeral = Keys::generate();
585 let wrap_content = nip44::encrypt(ephemeral.secret_key(), recipient, seal.as_json(), nip44::Version::default())
586 .map_err(|e| InviteError::Crypto(e.to_string()))?;
587 let mut tags = vec![
588 Tag::public_key(*recipient),
589 Tag::custom(TagKind::Custom("k".into()), [kind::DIRECT_INVITE.to_string()]),
590 ];
591 if let Some(ms) = bundle.expires_at {
592 tags.push(Tag::custom(TagKind::Custom("expiration".into()), [(ms / 1000).to_string()]));
593 }
594 EventBuilder::new(Kind::GiftWrap, wrap_content)
595 .tags(tags)
596 .custom_created_at(Timestamp::tweaked(RANGE_RANDOM_TIMESTAMP_TWEAK))
597 .sign_with_keys(&ephemeral)
598 .map_err(|e| InviteError::Crypto(e.to_string()))
599}
600
601pub fn unwrap_direct_invite(wrap: &Event, recipient_keys: &Keys) -> Result<(PublicKey, CommunityInvite), InviteError> {
608 if wrap.kind != Kind::GiftWrap {
609 return Err(InviteError::BadEvent("not a gift wrap"));
610 }
611 let seal_json = nip44::decrypt(recipient_keys.secret_key(), &wrap.pubkey, &wrap.content)
612 .map_err(|e| InviteError::Crypto(e.to_string()))?;
613 let seal = Event::from_json(&seal_json).map_err(|e| InviteError::Json(e.to_string()))?;
614 if seal.kind != Kind::Seal {
615 return Err(InviteError::BadEvent("inner is not a seal"));
616 }
617 seal.verify().map_err(|_| InviteError::BadEvent("seal signature invalid"))?;
618
619 let rumor_json = nip44::decrypt(recipient_keys.secret_key(), &seal.pubkey, &seal.content)
620 .map_err(|e| InviteError::Crypto(e.to_string()))?;
621 let rumor = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| InviteError::Json(e.to_string()))?;
622 if rumor.kind.as_u16() != kind::DIRECT_INVITE {
623 return Err(InviteError::BadEvent("rumor is not a direct invite"));
624 }
625 if rumor.pubkey != seal.pubkey {
626 return Err(InviteError::BadEvent("rumor author does not match the seal signer"));
627 }
628 let bundle = CommunityInvite::from_bundle_json(&rumor.content)?;
629 Ok((seal.pubkey, bundle))
630}
631
632fn hex32(s: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
635 crate::simd::hex::hex_to_bytes_32_checked(s).ok_or(InviteError::BadHex(field))
636}
637
638fn dict_id(url: &str) -> Option<u8> {
639 RELAY_DICT.iter().position(|u| *u == url).map(|i| (i + 1) as u8)
640}
641
642fn dict_url(id: u8) -> Option<&'static str> {
643 RELAY_DICT.get((id as usize).checked_sub(1)?).copied()
644}
645
646fn d_empty() -> Tag {
647 Tag::custom(TagKind::Custom("d".into()), [""])
648}
649
650fn vsk_tag(value: &str) -> Tag {
651 Tag::custom(TagKind::Custom("vsk".into()), [value])
652}
653
654fn first_tag(event: &Event, name: &str) -> Option<String> {
655 event.tags.iter().find_map(|t| {
656 let s = t.as_slice();
657 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
658 })
659}
660
661pub(crate) fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
665 let ck = ConversationKey::new(*bundle_key);
666 let ct = encrypt_to_bytes(&ck, json.as_bytes()).map_err(|e| InviteError::Crypto(e.to_string()))?;
667 Ok(base64_simd::STANDARD.encode_to_string(&ct))
668}
669
670fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
671 let ck = ConversationKey::new(*bundle_key);
672 let ct = base64_simd::STANDARD
673 .decode_to_vec(content.as_bytes())
674 .map_err(|e| InviteError::Crypto(e.to_string()))?;
675 let pt = decrypt_to_bytes(&ck, &ct).map_err(|e| InviteError::Crypto(e.to_string()))?;
676 String::from_utf8(pt).map_err(|e| InviteError::Crypto(e.to_string()))
677}
678
679fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
680 let trimmed = naddr.trim().trim_start_matches("nostr:");
681 let n19 = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed).map_err(|_| InviteError::BadLink("invalid naddr"))?;
682 match n19 {
683 nostr_sdk::nips::nip19::Nip19::Coordinate(c)
684 if c.coordinate.kind.as_u16() == kind::INVITE_BUNDLE && c.coordinate.identifier.is_empty() =>
685 {
686 Ok(c.coordinate.public_key)
687 }
688 _ => Err(InviteError::BadLink("naddr is not an invite-bundle coordinate")),
689 }
690}
691
692#[cfg(test)]
693mod tests {
694 use super::super::control::CommunityIdentity;
695 use super::super::derive::invite_bundle_key;
696 use super::*;
697 use crate::community::{random_32, MAX_COMMUNITY_RELAYS};
698
699 fn hex(bytes: &[u8; 32]) -> String {
700 crate::simd::hex::bytes_to_hex_32(bytes)
701 }
702
703 fn valid_bundle() -> (Keys, CommunityInvite) {
705 let owner = Keys::generate();
706 let id = CommunityIdentity::mint(&owner.public_key());
707 let bundle = CommunityInvite {
708 community_id: hex(&id.community_id.0),
709 owner: hex(&id.owner_xonly),
710 owner_salt: hex(&id.owner_salt),
711 community_root: hex(&random_32()),
712 root_epoch: 0,
713 channels: vec![],
714 relays: vec!["wss://a.example".into(), "wss://b.example".into()],
715 name: "Test community".into(),
716 icon: None,
717 expires_at: None,
718 creator_npub: None,
719 label: None,
720 extra: Default::default(),
721 };
722 (owner, bundle)
723 }
724
725 fn token16() -> [u8; TOKEN_LEN] {
726 std::array::from_fn(|i| i as u8) }
728
729 #[test]
732 fn validate_accepts_a_correct_owner_salt_id_triple() {
733 let (_owner, bundle) = valid_bundle();
734 assert!(bundle.validate().is_ok());
735 }
736
737 #[test]
738 fn validate_rejects_a_forged_owner() {
739 let (_owner, mut bundle) = valid_bundle();
740 let attacker = Keys::generate();
742 bundle.owner = hex(&attacker.public_key().to_bytes());
743 assert!(matches!(bundle.validate(), Err(InviteError::OwnerMismatch)));
744 }
745
746 #[test]
747 fn from_json_rejects_over_the_channel_cap() {
748 let (_owner, mut bundle) = valid_bundle();
749 bundle.channels = (0..MAX_BUNDLE_CHANNELS + 1)
750 .map(|_| ChannelGrant { id: hex(&random_32()), key: hex(&random_32()), epoch: 0, name: "x".into() })
751 .collect();
752 let json = serde_json::to_string(&bundle).unwrap();
753 assert!(matches!(
754 CommunityInvite::from_bundle_json(&json),
755 Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
756 ));
757 }
758
759 #[test]
760 fn from_json_truncates_relays_to_the_community_cap() {
761 let (_owner, mut bundle) = valid_bundle();
762 bundle.relays = (0..MAX_COMMUNITY_RELAYS + 3).map(|i| format!("wss://r{i}.example")).collect();
763 let json = serde_json::to_string(&bundle).unwrap();
764 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
765 assert_eq!(parsed.relays.len(), MAX_COMMUNITY_RELAYS);
766 }
767
768 #[test]
769 fn unknown_bundle_fields_round_trip() {
770 let (_owner, bundle) = valid_bundle();
773 let mut value = serde_json::to_value(&bundle).unwrap();
774 let obj = value.as_object_mut().unwrap();
775 obj.insert("held_roots".into(), serde_json::json!([{ "epoch": 2, "key": "ab" }]));
776 obj.insert("refounder".into(), serde_json::json!("deadbeef"));
777 obj.insert("future_field".into(), serde_json::json!({ "deep": [1, 2] }));
778 let json = serde_json::to_string(&value).unwrap();
779
780 let parsed = CommunityInvite::from_bundle_json(&json).unwrap();
781 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&parsed).unwrap()).unwrap();
782 assert_eq!(out["held_roots"][0]["epoch"], 2);
783 assert_eq!(out["refounder"], "deadbeef");
784 assert_eq!(out["future_field"]["deep"][1], 2);
785 }
786
787 #[test]
788 fn expiry_is_a_join_gate_not_a_render_gate() {
789 let (_owner, mut bundle) = valid_bundle();
790 bundle.expires_at = Some(1_000_000);
791 assert!(!bundle.expired(999_999));
792 assert!(bundle.expired(1_000_001));
793 }
794
795 #[test]
798 fn fragment_golden_stock_set() {
799 let token = token16();
803 let frag = encode_fragment(&token, &stock_relays()).unwrap();
804 assert_eq!(frag, "BAEAAQIDBAUGBwgJCgsMDQ4P");
805 let (t, relays) = decode_fragment(&frag).unwrap();
806 assert_eq!(t, token);
807 assert_eq!(relays, stock_relays());
808 }
809
810 #[test]
811 fn fragment_golden_dictionary_id_mix() {
812 let token = token16();
816 let relays = vec![RELAY_DICT[1].to_string(), RELAY_DICT[3].to_string()];
817 let frag = encode_fragment(&token, &relays).unwrap();
818 assert_eq!(frag, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
819 let (t, out) = decode_fragment(&frag).unwrap();
820 assert_eq!(t, token);
821 assert_eq!(out, relays);
822 }
823
824 #[test]
825 fn fragment_golden_wss_implied_literal() {
826 let token = token16();
830 let relays = vec!["wss://x.y".to_string()];
831 let frag = encode_fragment(&token, &relays).unwrap();
832 assert_eq!(frag, "BAABAAN4LnkAAQIDBAUGBwgJCgsMDQ4P");
833 let (t, out) = decode_fragment(&frag).unwrap();
834 assert_eq!(t, token);
835 assert_eq!(out, relays);
836 }
837
838 #[test]
839 fn fragment_golden_verbatim_literal() {
840 let token = token16();
844 let relays = vec!["ws://h".to_string()];
845 let frag = encode_fragment(&token, &relays).unwrap();
846 assert_eq!(frag, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
847 let (t, out) = decode_fragment(&frag).unwrap();
848 assert_eq!(t, token);
849 assert_eq!(out, relays);
850 }
851
852 #[test]
853 fn fragment_rejects_wrong_version_both_directions() {
854 let token = token16();
855 for bad in [3u8, 5u8] {
856 let mut bytes = vec![bad, FLAG_STOCK_SET];
857 bytes.extend_from_slice(&token);
858 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
859 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadVersion(v)) if v == bad));
860 }
861 }
862
863 #[test]
864 fn fragment_rejects_trailing_bytes() {
865 let token = token16();
866 let mut bytes = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
867 bytes.extend_from_slice(&token);
868 bytes.push(0xff); let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
870 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("trailing bytes"))));
871 }
872
873 #[test]
874 fn fragment_rejects_count_over_three() {
875 let token = token16();
876 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x04]; bytes.extend_from_slice(&[1, 2, 3, 4]);
878 bytes.extend_from_slice(&token);
879 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
880 assert!(matches!(decode_fragment(&frag), Err(InviteError::BadFragment("too many bootstrap relays"))));
881 }
882
883 #[test]
884 fn fragment_skips_an_unknown_dictionary_id() {
885 let token = token16();
886 let mut bytes = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
888 bytes.extend_from_slice(&token);
889 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes);
890 let (t, relays) = decode_fragment(&frag).unwrap();
891 assert_eq!(t, token);
892 assert!(relays.is_empty());
893 }
894
895 #[test]
896 fn fragment_caps_bootstrap_relays_at_three() {
897 let token = token16();
898 let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
899 let (_t, out) = decode_fragment(&encode_fragment(&token, &relays).unwrap()).unwrap();
900 assert_eq!(out.len(), MAX_BOOTSTRAP_RELAYS);
901 }
902
903 #[test]
906 fn bundle_event_round_trips_live() {
907 let (_owner, bundle) = valid_bundle();
908 let link = Keys::generate();
909 let token = random_32();
910 let key = invite_bundle_key(&token[..TOKEN_LEN].try_into().unwrap());
911 let event = build_bundle_event(&link, &bundle, &key).unwrap();
912 assert_eq!(event.pubkey, link.public_key());
913
914 match parse_bundle_event(&event, &link.public_key(), &key).unwrap() {
915 BundleState::Live(b) => {
916 assert_eq!(b.community_id, bundle.community_id);
917 assert_eq!(b.name, "Test community");
918 }
919 BundleState::Revoked => panic!("expected Live"),
920 }
921 }
922
923 #[test]
924 fn revocation_reads_as_revoked() {
925 let link = Keys::generate();
926 let tomb = build_revocation(&link).unwrap();
927 let key = invite_bundle_key(&[0u8; TOKEN_LEN]);
928 assert!(matches!(parse_bundle_event(&tomb, &link.public_key(), &key), Ok(BundleState::Revoked)));
929 }
930
931 #[test]
932 fn bundle_event_wrong_key_fails() {
933 let (_owner, bundle) = valid_bundle();
934 let link = Keys::generate();
935 let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
936 let event = build_bundle_event(&link, &bundle, &key).unwrap();
937 let wrong = invite_bundle_key(&[8u8; TOKEN_LEN]);
938 assert!(parse_bundle_event(&event, &link.public_key(), &wrong).is_err());
939 }
940
941 #[test]
942 fn bundle_event_from_a_different_author_is_rejected() {
943 let (_owner, bundle) = valid_bundle();
946 let real = Keys::generate();
947 let squatter = Keys::generate();
948 let key = invite_bundle_key(&[3u8; TOKEN_LEN]);
949 let event = build_bundle_event(&squatter, &bundle, &key).unwrap();
950 assert!(matches!(
951 parse_bundle_event(&event, &real.public_key(), &key),
952 Err(InviteError::BadEvent("author is not the link signer"))
953 ));
954 }
955
956 #[test]
960 fn bundle_event_at_a_non_empty_d_is_rejected() {
961 let (_owner, bundle) = valid_bundle();
962 let link = Keys::generate();
963 let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
964 let json = serde_json::to_string(&bundle).unwrap();
965 let content = seal_bundle(&key, &json).unwrap();
966 let event = EventBuilder::new(Kind::Custom(kind::INVITE_BUNDLE), content)
967 .tags([Tag::identifier("elsewhere"), vsk_tag(vsk::INVITE_LIVE)])
968 .sign_with_keys(&link)
969 .unwrap();
970 assert!(matches!(
971 parse_bundle_event(&event, &link.public_key(), &key),
972 Err(InviteError::BadEvent("bundle is not at the link's coordinate"))
973 ));
974 }
975
976 #[test]
977 fn bundle_event_with_a_tampered_signature_is_rejected() {
978 let (_owner, bundle) = valid_bundle();
979 let link = Keys::generate();
980 let other = Keys::generate();
981 let key = invite_bundle_key(&[4u8; TOKEN_LEN]);
982 let event = build_bundle_event(&link, &bundle, &key).unwrap();
983 let mut json: serde_json::Value = serde_json::from_str(&event.as_json()).unwrap();
985 json["pubkey"] = serde_json::Value::String(other.public_key().to_hex());
986 let Ok(forged) = Event::from_json(json.to_string()) else { return };
987 assert!(matches!(
988 parse_bundle_event(&forged, &other.public_key(), &key),
989 Err(InviteError::BadEvent("signature invalid"))
990 ));
991 }
992
993 fn entry(token: &str, community: &str) -> InviteEntry {
996 InviteEntry {
997 token: token.into(),
998 signer_sk: "bb".repeat(32),
999 community_id: community.into(),
1000 url: "https://x/invite/naddr1xyz#frag".into(),
1001 label: None,
1002 created_at: 1000,
1003 expires_at: None,
1004 extra: Default::default(),
1005 }
1006 }
1007
1008 #[test]
1009 fn invite_list_merge_entries_immutable_tombstone_wins_terminally() {
1010 let e = entry(&"aa".repeat(16), &"cc".repeat(32));
1011 let a = merge_invite_lists(InviteList::default(), InviteList { entries: vec![e.clone()], ..Default::default() });
1012 assert_eq!(a.entries.len(), 1);
1013
1014 let mut mutated = e.clone();
1016 mutated.label = Some("changed".into());
1017 let still = merge_invite_lists(a.clone(), InviteList { entries: vec![mutated], ..Default::default() });
1018 assert_eq!(still.entries[0].label, None);
1019
1020 let tomb = InviteTombstone { token: e.token.clone(), community_id: e.community_id.clone(), extra: Default::default() };
1022 let b = merge_invite_lists(a, InviteList { tombstones: vec![tomb], ..Default::default() });
1023 assert!(b.entries.is_empty());
1024 assert_eq!(b.tombstones.len(), 1);
1025
1026 let c = merge_invite_lists(b, InviteList { entries: vec![e], ..Default::default() });
1028 assert!(c.entries.is_empty());
1029 }
1030
1031 #[test]
1032 fn invite_list_tombstones_union() {
1033 let t1 = InviteTombstone { token: "11".repeat(16), community_id: "aa".repeat(32), extra: Default::default() };
1034 let t2 = InviteTombstone { token: "22".repeat(16), community_id: "bb".repeat(32), extra: Default::default() };
1035 let merged = merge_invite_lists(
1036 InviteList { tombstones: vec![t1], ..Default::default() },
1037 InviteList { tombstones: vec![t2], ..Default::default() },
1038 );
1039 assert_eq!(merged.tombstones.len(), 2);
1040 }
1041
1042 #[test]
1043 fn invite_list_unknown_fields_round_trip() {
1044 let wire = r#"{"entries":[],"tombstones":[],"schema":"future","note":{"x":1}}"#;
1045 let list: InviteList = serde_json::from_str(wire).unwrap();
1046 let out: serde_json::Value = serde_json::from_str(&serde_json::to_string(&list).unwrap()).unwrap();
1047 assert_eq!(out["schema"], "future");
1048 assert_eq!(out["note"]["x"], 1);
1049 }
1050
1051 #[test]
1054 fn direct_invite_round_trips_to_inviter_and_bundle() {
1055 let inviter = Keys::generate();
1056 let recipient = Keys::generate();
1057 let (_owner, bundle) = valid_bundle();
1058
1059 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1060 assert_eq!(wrap.kind, Kind::GiftWrap);
1061 assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral");
1062 assert!(wrap.tags.iter().any(|t| {
1063 let s = t.as_slice();
1064 s.len() >= 2 && s[0] == "k" && s[1] == kind::DIRECT_INVITE.to_string()
1065 }));
1066
1067 let (sender, out) = unwrap_direct_invite(&wrap, &recipient).unwrap();
1068 assert_eq!(sender, inviter.public_key());
1069 assert_eq!(out.community_id, bundle.community_id);
1070 assert_eq!(out.name, "Test community");
1071 }
1072
1073 #[test]
1074 fn direct_invite_rejects_a_seal_claiming_a_false_inviter() {
1075 let inviter = Keys::generate();
1078 let claimed = Keys::generate();
1079 let recipient = Keys::generate();
1080 let (_owner, bundle) = valid_bundle();
1081
1082 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1084 let seal_json = nip44::decrypt(recipient.secret_key(), &wrap.pubkey, &wrap.content).unwrap();
1085 let mut seal_val: serde_json::Value = serde_json::from_str(&seal_json).unwrap();
1086 seal_val["pubkey"] = serde_json::Value::String(claimed.public_key().to_hex());
1087 let ephemeral = Keys::generate();
1089 let wrap_ct =
1090 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal_val.to_string(), nip44::Version::default())
1091 .unwrap();
1092 let forged = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1093 .tags([Tag::public_key(recipient.public_key())])
1094 .sign_with_keys(&ephemeral)
1095 .unwrap();
1096 assert!(unwrap_direct_invite(&forged, &recipient).is_err());
1097 }
1098
1099 #[test]
1100 fn direct_invite_rejects_a_non_invite_rumor_kind() {
1101 let inviter = Keys::generate();
1102 let recipient = Keys::generate();
1103 let (_owner, bundle) = valid_bundle();
1104 let json = serde_json::to_string(&bundle).unwrap();
1105
1106 let mut rumor = EventBuilder::new(Kind::Custom(9), json)
1108 .custom_created_at(Timestamp::now())
1109 .build(inviter.public_key());
1110 rumor.ensure_id();
1111 let seal_ct =
1112 nip44::encrypt(inviter.secret_key(), &recipient.public_key(), rumor.as_json(), nip44::Version::default()).unwrap();
1113 let seal = EventBuilder::new(Kind::Seal, seal_ct).sign_with_keys(&inviter).unwrap();
1114 let ephemeral = Keys::generate();
1115 let wrap_ct =
1116 nip44::encrypt(ephemeral.secret_key(), &recipient.public_key(), seal.as_json(), nip44::Version::default()).unwrap();
1117 let wrap = EventBuilder::new(Kind::GiftWrap, wrap_ct)
1118 .tags([Tag::public_key(recipient.public_key())])
1119 .sign_with_keys(&ephemeral)
1120 .unwrap();
1121 assert!(matches!(
1122 unwrap_direct_invite(&wrap, &recipient),
1123 Err(InviteError::BadEvent("rumor is not a direct invite"))
1124 ));
1125 }
1126
1127 #[test]
1128 fn direct_invite_stamps_nip40_expiration_in_seconds() {
1129 let inviter = Keys::generate();
1130 let recipient = Keys::generate();
1131 let (_owner, mut bundle) = valid_bundle();
1132 let expires_ms = 1_735_689_600_000u64;
1133 bundle.expires_at = Some(expires_ms);
1134
1135 let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1136 let exp = wrap.tags.iter().find_map(|t| {
1137 let s = t.as_slice();
1138 (s.len() >= 2 && s[0] == "expiration").then(|| s[1].clone())
1139 });
1140 assert_eq!(exp, Some((expires_ms / 1000).to_string()));
1141 }
1142
1143 #[test]
1144 fn direct_invite_rejects_a_forged_owner_bundle() {
1145 let inviter = Keys::generate();
1146 let recipient = Keys::generate();
1147 let (_owner, mut bundle) = valid_bundle();
1148 bundle.owner = hex(&Keys::generate().public_key().to_bytes()); let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).unwrap();
1150 assert!(matches!(unwrap_direct_invite(&wrap, &recipient), Err(InviteError::OwnerMismatch)));
1151 }
1152
1153 #[test]
1156 fn invite_link_round_trips_full_url_and_bare_form() {
1157 let link = Keys::generate();
1158 let token = token16();
1159 let relays = vec!["wss://a.example".to_string()];
1160 let url = build_invite_url("https://vectorapp.io", &link.public_key(), &token, &relays).unwrap();
1161 assert!(url.contains("/invite/naddr1"));
1162
1163 let parsed = parse_invite_link(&url).unwrap();
1164 assert_eq!(parsed.link_signer, link.public_key());
1165 assert_eq!(parsed.token, token);
1166 assert_eq!(parsed.bootstrap_relays, relays);
1167
1168 let bare = format!("{}#{}", parsed.naddr, url.split('#').nth(1).unwrap());
1169 assert_eq!(parse_invite_link(&bare).unwrap().link_signer, link.public_key());
1170 }
1171
1172 #[test]
1173 fn invite_link_rejects_non_invites() {
1174 assert!(parse_invite_link("hello world").is_err());
1175 assert!(parse_invite_link("wss://relay.example.com").is_err());
1176 assert!(parse_invite_link("https://x/invite/#frag").is_err());
1177 }
1178
1179 #[test]
1180 fn v2_parser_rejects_a_v1_style_url_no_cross_protocol_confusion() {
1181 let v1_url = "https://vectorapp.io/invite#AmR1bW15djFmcmFnbWVudA";
1187 assert!(parse_invite_link(v1_url).is_err(), "v2 must reject a v1-format invite URL");
1188 assert!(parse_invite_link("https://vectorapp.io/invite/#AmR1bW15").is_err());
1190 let token = [0x07u8; TOKEN_LEN];
1192 let signer = Keys::generate();
1193 let naddr = bundle_naddr(&signer.public_key()).unwrap();
1194 let frag = encode_fragment(&token, &[]).unwrap();
1195 assert!(parse_invite_link(&format!("{naddr}#{frag}")).is_ok());
1196 }
1197
1198 #[test]
1201 fn registry_content_round_trips_and_leaks_no_secrets() {
1202 let a = Keys::generate();
1203 let b = Keys::generate();
1204 let content = build_registry_content(&[a.public_key(), b.public_key()]);
1205
1206 let arr: Vec<String> = serde_json::from_str(&content).unwrap();
1208 assert_eq!(arr, vec![a.public_key().to_hex(), b.public_key().to_hex()]);
1209 assert!(!content.contains("invite"));
1211 assert!(!content.contains("token"));
1212 assert!(!content.contains("://"));
1213
1214 let back = parse_registry_content(&content).unwrap();
1215 assert_eq!(back, vec![a.public_key(), b.public_key()]);
1216 }
1217}