1use nostr_sdk::prelude::*;
20use serde::{Deserialize, Serialize};
21
22use super::derive::{public_invite_key, public_invite_locator, public_invite_signer};
23use super::invite::{build_invite, CommunityInvite};
24use super::{cipher, Community, CommunityImage};
25use crate::stored_event::event_kind;
26
27pub const INVITE_URL_BASE: &str = "https://vectorapp.io/invite";
30
31const MAX_URL_RELAYS: usize = 32;
35
36const TAG_VERSION: &str = "v";
37const TAG_SUBKIND: &str = "vsk";
38const PROTOCOL_VERSION: &str = "1";
39const VSK_PUBLIC_INVITE: &str = "6";
42const VSK_PUBLIC_INVITE_REVOKED: &str = "9";
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct PublicInvitePreview {
51 pub name: String,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub description: Option<String>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub icon: Option<CommunityImage>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PublicInviteBundle {
63 pub preview: PublicInvitePreview,
64 pub join: CommunityInvite,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub expires_at: Option<u64>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub creator_npub: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub label: Option<String>,
75}
76
77impl PublicInviteBundle {
78 pub fn is_expired(&self, now_secs: u64) -> bool {
79 self.expires_at.map_or(false, |e| now_secs >= e)
80 }
81}
82
83#[derive(Debug)]
85pub enum PublicInviteError {
86 Json(String),
87 Cipher(String),
88 Sign(String),
89 UnexpectedSigner,
91 BadSignature,
92 WrongVersion(Option<String>),
93 WrongSubKind(Option<String>),
94 BadUrl(String),
95 Expired,
96 Revoked,
98}
99
100impl std::fmt::Display for PublicInviteError {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 match self {
103 PublicInviteError::Json(e) => write!(f, "json: {e}"),
104 PublicInviteError::Cipher(e) => write!(f, "cipher: {e}"),
105 PublicInviteError::Sign(e) => write!(f, "sign: {e}"),
106 PublicInviteError::UnexpectedSigner => write!(f, "bundle not signed by the invite token"),
107 PublicInviteError::BadSignature => write!(f, "bundle signature invalid"),
108 PublicInviteError::WrongVersion(v) => write!(f, "unsupported invite version: {v:?}"),
109 PublicInviteError::WrongSubKind(v) => write!(f, "not a public-invite bundle: {v:?}"),
110 PublicInviteError::BadUrl(e) => write!(f, "bad invite url: {e}"),
111 PublicInviteError::Expired => write!(f, "invite has expired"),
112 PublicInviteError::Revoked => write!(f, "this invite was revoked"),
113 }
114 }
115}
116
117impl std::error::Error for PublicInviteError {}
118
119pub fn new_token() -> [u8; 32] {
121 super::random_32()
122}
123
124fn preview_of(community: &Community) -> PublicInvitePreview {
126 PublicInvitePreview {
127 name: community.name.clone(),
128 description: community.description.clone(),
129 icon: community.icon.clone(),
130 }
131}
132
133pub fn build_public_invite_event(
137 community: &Community,
138 token: &[u8; 32],
139 expires_at: Option<u64>,
140 creator_npub: Option<String>,
141 label: Option<String>,
142) -> Result<Event, PublicInviteError> {
143 let bundle = PublicInviteBundle {
144 preview: preview_of(community),
145 join: build_invite(community),
146 expires_at,
147 creator_npub,
148 label,
149 };
150 let json = serde_json::to_string(&bundle).map_err(|e| PublicInviteError::Json(e.to_string()))?;
151 let content = cipher::seal(&public_invite_key(token), json.as_bytes())
152 .map_err(PublicInviteError::Cipher)?;
153 let signer = Keys::new(public_invite_signer(token));
154 let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
155 EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
156 .tags([
157 Tag::identifier(locator),
158 Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
159 Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
160 ])
161 .sign_with_keys(&signer)
162 .map_err(|e| PublicInviteError::Sign(e.to_string()))
163}
164
165pub fn build_public_invite_tombstone(token: &[u8; 32]) -> Result<Event, PublicInviteError> {
172 let signer = Keys::new(public_invite_signer(token));
173 let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
174 EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
175 .tags([
176 Tag::identifier(locator),
177 Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE_REVOKED.to_string()]),
178 Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
179 ])
180 .sign_with_keys(&signer)
181 .map_err(|e| PublicInviteError::Sign(e.to_string()))
182}
183
184pub fn locator_hex(token: &[u8; 32]) -> String {
187 crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token))
188}
189
190pub fn signer_pubkey(token: &[u8; 32]) -> PublicKey {
192 Keys::new(public_invite_signer(token)).public_key()
193}
194
195pub fn parse_public_invite_event(
201 event: &Event,
202 token: &[u8; 32],
203) -> Result<PublicInviteBundle, PublicInviteError> {
204 let version = find_tag(event, TAG_VERSION);
205 if version.as_deref() != Some(PROTOCOL_VERSION) {
206 return Err(PublicInviteError::WrongVersion(version));
207 }
208 if event.pubkey != signer_pubkey(token) {
211 return Err(PublicInviteError::UnexpectedSigner);
212 }
213 event.verify().map_err(|_| PublicInviteError::BadSignature)?;
214 let subkind = find_tag(event, TAG_SUBKIND);
215 match subkind.as_deref() {
216 Some(VSK_PUBLIC_INVITE) => {}
217 Some(VSK_PUBLIC_INVITE_REVOKED) => return Err(PublicInviteError::Revoked),
219 other => return Err(PublicInviteError::WrongSubKind(other.map(|s| s.to_string()))),
220 }
221 let plaintext =
222 cipher::open(&public_invite_key(token), &event.content).map_err(PublicInviteError::Cipher)?;
223 serde_json::from_slice(&plaintext).map_err(|e| PublicInviteError::Json(e.to_string()))
224}
225
226fn find_tag(event: &Event, name: &str) -> Option<String> {
227 event.tags.iter().find_map(|t| {
228 let s = t.as_slice();
229 (s.len() >= 2 && s[0] == name).then(|| s[1].clone())
230 })
231}
232
233#[derive(Serialize, Deserialize)]
236struct UrlFragment {
237 v: u8,
238 relays: Vec<String>,
240 t: String,
242}
243
244const URL_V2: u8 = 2;
247const V2_FLAG_DEFAULT_RELAYS: u8 = 0b0000_0001;
250const MAX_V2_BOOTSTRAP_RELAYS: usize = 3;
253
254const RELAY_DICTIONARY: &[&str] = &[
259 "wss://jskitty.com/nostr", "wss://asia.vectorapp.io/nostr", "wss://nostr.computingcache.com", "wss://relay.damus.io", ];
264
265fn norm_relay(r: &str) -> String {
267 r.trim().trim_end_matches('/').to_ascii_lowercase()
268}
269
270fn is_default_relay_set(relays: &[String]) -> bool {
271 let defaults: std::collections::HashSet<String> =
272 crate::state::TRUSTED_RELAYS.iter().map(|r| norm_relay(r)).collect();
273 let theirs: std::collections::HashSet<String> = relays.iter().map(|r| norm_relay(r)).collect();
274 theirs == defaults
275}
276
277fn dictionary_id(relay: &str) -> Option<u8> {
278 let n = norm_relay(relay);
279 RELAY_DICTIONARY.iter().position(|d| norm_relay(d) == n).map(|i| (i + 1) as u8)
280}
281
282pub fn encode_invite_url(relays: &[String], token: &[u8; 32]) -> String {
287 let mut payload: Vec<u8> = Vec::with_capacity(34);
288 payload.push(URL_V2);
289 if is_default_relay_set(relays) {
290 payload.push(V2_FLAG_DEFAULT_RELAYS);
291 } else {
292 payload.push(0);
293 let boot: Vec<&String> = relays
296 .iter()
297 .filter(|r| r.strip_prefix("wss://").unwrap_or(r).len() <= 255)
298 .take(MAX_V2_BOOTSTRAP_RELAYS)
299 .collect();
300 payload.push(boot.len() as u8);
301 for r in boot {
302 match dictionary_id(r) {
303 Some(id) => payload.push(id),
304 None => {
305 let (kind, s) = match r.strip_prefix("wss://") {
309 Some(host) => (0u8, host),
310 None => (255u8, r.as_str()),
311 };
312 payload.push(kind);
313 payload.push(s.len() as u8);
314 payload.extend_from_slice(s.as_bytes());
315 }
316 }
317 }
318 }
319 payload.extend_from_slice(token);
320 let b64 = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
321 format!("{INVITE_URL_BASE}#{b64}")
322}
323
324pub fn parse_invite_url(url: &str) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
328 let fragment = url.rsplit_once('#').map(|(_, f)| f).unwrap_or(url);
329 if fragment.is_empty() {
330 return Err(PublicInviteError::BadUrl("no fragment".into()));
331 }
332 let raw = base64_simd::URL_SAFE_NO_PAD
333 .decode_to_vec(fragment.as_bytes())
334 .map_err(|e| PublicInviteError::BadUrl(format!("base64: {e}")))?;
335 match raw.first() {
336 Some(&URL_V2) => parse_v2_fragment(&raw),
337 Some(&b'{') => parse_v1_fragment(&raw),
338 _ => Err(PublicInviteError::BadUrl("unrecognized fragment format".into())),
339 }
340}
341
342fn parse_v1_fragment(json: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
343 let frag: UrlFragment =
344 serde_json::from_slice(json).map_err(|e| PublicInviteError::BadUrl(format!("json: {e}")))?;
345 if frag.v != 1 {
346 return Err(PublicInviteError::BadUrl(format!("unsupported url version {}", frag.v)));
347 }
348 if frag.relays.len() > MAX_URL_RELAYS {
349 return Err(PublicInviteError::BadUrl(format!(
350 "invite url declares too many relays ({})",
351 frag.relays.len()
352 )));
353 }
354 let token = crate::simd::hex::hex_to_bytes_32_checked(&frag.t)
356 .ok_or_else(|| PublicInviteError::BadUrl("token must be 64 hex chars".into()))?;
357 Ok((frag.relays, token))
358}
359
360fn parse_v2_fragment(raw: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
361 let bad = |m: &str| PublicInviteError::BadUrl(m.into());
362 let flags = *raw.get(1).ok_or_else(|| bad("truncated v2 fragment"))?;
363 let mut pos = 2usize;
364 let relays: Vec<String> = if flags & V2_FLAG_DEFAULT_RELAYS != 0 {
365 crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect()
366 } else {
367 let count = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay count"))? as usize;
370 pos += 1;
371 if count == 0 || count > MAX_URL_RELAYS {
372 return Err(bad("bad v2 relay count"));
373 }
374 let mut out = Vec::with_capacity(count.min(MAX_V2_BOOTSTRAP_RELAYS));
375 for _ in 0..count {
376 let id = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay entry"))?;
377 pos += 1;
378 if id == 0 || id == 255 {
379 let len = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay literal"))? as usize;
380 pos += 1;
381 let end = pos.checked_add(len).ok_or_else(|| bad("bad v2 relay length"))?;
382 let host = raw.get(pos..end).ok_or_else(|| bad("truncated v2 relay literal"))?;
383 let host =
384 std::str::from_utf8(host).map_err(|_| bad("v2 relay literal not utf-8"))?;
385 out.push(if id == 0 { format!("wss://{host}") } else { host.to_string() });
386 pos = end;
387 } else if let Some(relay) = RELAY_DICTIONARY.get(id as usize - 1) {
388 out.push(relay.to_string());
389 }
390 }
393 if out.is_empty() {
394 return Err(bad("no resolvable bootstrap relays"));
395 }
396 out
397 };
398 let token_bytes = raw.get(pos..).ok_or_else(|| bad("truncated v2 token"))?;
399 if token_bytes.len() != 32 {
400 return Err(bad("v2 token must be exactly 32 bytes"));
401 }
402 let mut token = [0u8; 32];
403 token.copy_from_slice(token_bytes);
404 Ok((relays, token))
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn v2_url_default_relay_set_roundtrips_and_is_tiny() {
413 let relays: Vec<String> =
414 crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect();
415 let token = new_token();
416 let url = encode_invite_url(&relays, &token);
417 assert!(url.len() <= 80, "default-set v2 url should be ~74 chars, got {}", url.len());
418 let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
419 assert_eq!(parsed_token, token);
420 assert_eq!(parsed_relays, relays);
421 }
422
423 #[test]
424 fn v2_url_dictionary_and_literal_relays_roundtrip() {
425 let relays = vec![
427 "wss://relay.damus.io".to_string(),
428 "wss://my.custom.relay/nostr".to_string(),
429 "r1".to_string(),
430 ];
431 let token = new_token();
432 let url = encode_invite_url(&relays, &token);
433 let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
434 assert_eq!(parsed_token, token);
435 assert_eq!(parsed_relays, relays);
436 }
437
438 #[test]
439 fn v1_json_fragment_still_parses() {
440 let token = new_token();
442 let json = format!(
443 "{{\"v\":1,\"relays\":[\"wss://r1\",\"wss://r2\"],\"t\":\"{}\"}}",
444 crate::simd::hex::bytes_to_hex_32(&token)
445 );
446 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
447 let (relays, parsed) = parse_invite_url(&format!("{INVITE_URL_BASE}#{frag}")).unwrap();
448 assert_eq!(parsed, token);
449 assert_eq!(relays, vec!["wss://r1".to_string(), "wss://r2".to_string()]);
450 }
451
452 #[test]
453 fn v2_unknown_dictionary_id_is_skipped_not_fatal() {
454 let token = new_token();
457 let mut payload = vec![2u8, 0, 2, 250, 1]; payload.extend_from_slice(&token);
459 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
460 let (relays, parsed) = parse_invite_url(&frag).unwrap();
461 assert_eq!(parsed, token);
462 assert_eq!(relays, vec!["wss://jskitty.com/nostr".to_string()]);
463 }
464
465 #[test]
466 fn v2_garbage_and_truncations_error_without_panic() {
467 for frag_bytes in [
468 vec![2u8], vec![2u8, 1], vec![2u8, 0, 1, 0, 200], vec![2u8, 1, 0xAA], vec![9u8, 1, 2, 3], ] {
474 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&frag_bytes);
475 assert!(parse_invite_url(&frag).is_err());
476 }
477 }
478
479 #[test]
480 fn bundle_round_trips_with_token() {
481 let mut c = Community::create("Cool Place", "general", vec!["wss://r1".into()]);
482 c.description = Some("the coolest".into());
483 let token = new_token();
484 let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
485
486 assert_eq!(event.pubkey, signer_pubkey(&token));
488 assert_eq!(find_tag(&event, "d").as_deref(), Some(locator_hex(&token).as_str()));
489
490 let bundle = parse_public_invite_event(&event, &token).unwrap();
491 assert_eq!(bundle.preview.name, "Cool Place");
492 assert_eq!(bundle.preview.description.as_deref(), Some("the coolest"));
493 let member = super::super::invite::accept_invite(&bundle.join).unwrap();
495 assert_eq!(member.id, c.id);
496 assert_eq!(member.channels[0].key.as_bytes(), c.channels[0].key.as_bytes());
497 }
498
499 #[test]
500 fn tombstone_replaces_the_bundle_at_the_same_coordinate() {
501 let c = Community::create("HQ", "general", vec!["wss://r1".into()]);
504 let token = new_token();
505 let bundle = build_public_invite_event(&c, &token, None, None, None).unwrap();
506 let tomb = build_public_invite_tombstone(&token).unwrap();
507 assert_eq!(tomb.kind, bundle.kind);
509 assert_eq!(tomb.pubkey, bundle.pubkey);
510 assert_eq!(find_tag(&tomb, "d"), find_tag(&bundle, "d"));
511 assert!(tomb.content.is_empty());
514 assert!(
515 matches!(parse_public_invite_event(&tomb, &token), Err(PublicInviteError::Revoked)),
516 "tombstone parses as an explicit Revoked, not a bundle",
517 );
518 }
519
520 #[test]
521 fn wrong_token_cannot_read_bundle() {
522 let c = Community::create("HQ", "general", vec![]);
523 let token = new_token();
524 let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
525
526 let other = new_token();
528 let err = parse_public_invite_event(&event, &other);
529 assert!(matches!(err, Err(PublicInviteError::UnexpectedSigner)), "got {err:?}");
530 }
531
532 #[test]
533 fn impostor_at_locator_is_rejected() {
534 let c = Community::create("HQ", "general", vec![]);
537 let token = new_token();
538 let attacker = Keys::generate();
539 let impostor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "x")
540 .tags([
541 Tag::identifier(locator_hex(&token)),
542 Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
543 Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
544 ])
545 .sign_with_keys(&attacker)
546 .unwrap();
547 let _ = &c;
548 assert!(matches!(
549 parse_public_invite_event(&impostor, &token),
550 Err(PublicInviteError::UnexpectedSigner)
551 ));
552 }
553
554 #[test]
555 fn expiry_is_reported() {
556 let c = Community::create("HQ", "general", vec![]);
557 let token = new_token();
558 let event = build_public_invite_event(&c, &token, Some(1000), None, None).unwrap();
559 let bundle = parse_public_invite_event(&event, &token).unwrap();
560 assert!(!bundle.is_expired(999));
561 assert!(bundle.is_expired(1000), "expiry is inclusive");
562 assert!(bundle.is_expired(2000));
563 }
564
565 #[test]
566 fn url_round_trips() {
567 let relays = vec!["wss://a".to_string(), "wss://b".to_string()];
568 let token = new_token();
569 let url = encode_invite_url(&relays, &token);
570 assert!(url.starts_with(INVITE_URL_BASE));
571 assert!(url.contains('#'));
572 let (r, t) = parse_invite_url(&url).unwrap();
573 assert_eq!(r, relays);
574 assert_eq!(t, token);
575 let frag = url.rsplit_once('#').unwrap().1;
577 assert_eq!(parse_invite_url(frag).unwrap().1, token);
578 }
579
580 #[test]
581 fn malformed_url_errors() {
582 assert!(parse_invite_url("https://vectorapp.io/invite#").is_err());
583 assert!(parse_invite_url("https://vectorapp.io/invite#not!!base64").is_err());
584 }
585
586 #[test]
587 fn url_with_too_many_relays_is_rejected() {
588 let token = new_token();
590 let many: Vec<String> = (0..MAX_URL_RELAYS + 1).map(|i| format!("wss://r{i}")).collect();
592 let (relays, _) = parse_invite_url(&encode_invite_url(&many, &token)).unwrap();
593 assert!(relays.len() <= MAX_V2_BOOTSTRAP_RELAYS);
594 let mut payload = vec![URL_V2, 0, (MAX_URL_RELAYS + 1) as u8];
596 payload.extend_from_slice(&token);
597 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
598 assert!(parse_invite_url(&frag).is_err());
599 let json = format!(
601 "{{\"v\":1,\"relays\":[{}],\"t\":\"{}\"}}",
602 (0..MAX_URL_RELAYS + 1).map(|i| format!("\"wss://r{i}\"")).collect::<Vec<_>>().join(","),
603 crate::simd::hex::bytes_to_hex_32(&token)
604 );
605 let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
606 assert!(parse_invite_url(&frag).is_err());
607 }
608}