use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use super::derive::{public_invite_key, public_invite_locator, public_invite_signer};
use super::invite::{build_invite, CommunityInvite};
use super::{cipher, Community, CommunityImage};
use crate::stored_event::event_kind;
pub const INVITE_URL_BASE: &str = "https://vectorapp.io/invite";
const MAX_URL_RELAYS: usize = 32;
const TAG_VERSION: &str = "v";
const TAG_SUBKIND: &str = "vsk";
const PROTOCOL_VERSION: &str = "1";
const VSK_PUBLIC_INVITE: &str = "6";
const VSK_PUBLIC_INVITE_REVOKED: &str = "9";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PublicInvitePreview {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<CommunityImage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicInviteBundle {
pub preview: PublicInvitePreview,
pub join: CommunityInvite,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creator_npub: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl PublicInviteBundle {
pub fn is_expired(&self, now_secs: u64) -> bool {
self.expires_at.map_or(false, |e| now_secs >= e)
}
}
#[derive(Debug)]
pub enum PublicInviteError {
Json(String),
Cipher(String),
Sign(String),
UnexpectedSigner,
BadSignature,
WrongVersion(Option<String>),
WrongSubKind(Option<String>),
BadUrl(String),
Expired,
Revoked,
}
impl std::fmt::Display for PublicInviteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PublicInviteError::Json(e) => write!(f, "json: {e}"),
PublicInviteError::Cipher(e) => write!(f, "cipher: {e}"),
PublicInviteError::Sign(e) => write!(f, "sign: {e}"),
PublicInviteError::UnexpectedSigner => write!(f, "bundle not signed by the invite token"),
PublicInviteError::BadSignature => write!(f, "bundle signature invalid"),
PublicInviteError::WrongVersion(v) => write!(f, "unsupported invite version: {v:?}"),
PublicInviteError::WrongSubKind(v) => write!(f, "not a public-invite bundle: {v:?}"),
PublicInviteError::BadUrl(e) => write!(f, "bad invite url: {e}"),
PublicInviteError::Expired => write!(f, "invite has expired"),
PublicInviteError::Revoked => write!(f, "this invite was revoked"),
}
}
}
impl std::error::Error for PublicInviteError {}
pub fn new_token() -> [u8; 32] {
super::random_32()
}
fn preview_of(community: &Community) -> PublicInvitePreview {
PublicInvitePreview {
name: community.name.clone(),
description: community.description.clone(),
icon: community.icon.clone(),
}
}
pub fn build_public_invite_event(
community: &Community,
token: &[u8; 32],
expires_at: Option<u64>,
creator_npub: Option<String>,
label: Option<String>,
) -> Result<Event, PublicInviteError> {
let bundle = PublicInviteBundle {
preview: preview_of(community),
join: build_invite(community),
expires_at,
creator_npub,
label,
};
let json = serde_json::to_string(&bundle).map_err(|e| PublicInviteError::Json(e.to_string()))?;
let content = cipher::seal(&public_invite_key(token), json.as_bytes())
.map_err(PublicInviteError::Cipher)?;
let signer = Keys::new(public_invite_signer(token));
let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), content)
.tags([
Tag::identifier(locator),
Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&signer)
.map_err(|e| PublicInviteError::Sign(e.to_string()))
}
pub fn build_public_invite_tombstone(token: &[u8; 32]) -> Result<Event, PublicInviteError> {
let signer = Keys::new(public_invite_signer(token));
let locator = crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token));
EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "")
.tags([
Tag::identifier(locator),
Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE_REVOKED.to_string()]),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&signer)
.map_err(|e| PublicInviteError::Sign(e.to_string()))
}
pub fn locator_hex(token: &[u8; 32]) -> String {
crate::simd::hex::bytes_to_hex_32(&public_invite_locator(token))
}
pub fn signer_pubkey(token: &[u8; 32]) -> PublicKey {
Keys::new(public_invite_signer(token)).public_key()
}
pub fn parse_public_invite_event(
event: &Event,
token: &[u8; 32],
) -> Result<PublicInviteBundle, PublicInviteError> {
let version = find_tag(event, TAG_VERSION);
if version.as_deref() != Some(PROTOCOL_VERSION) {
return Err(PublicInviteError::WrongVersion(version));
}
if event.pubkey != signer_pubkey(token) {
return Err(PublicInviteError::UnexpectedSigner);
}
event.verify().map_err(|_| PublicInviteError::BadSignature)?;
let subkind = find_tag(event, TAG_SUBKIND);
match subkind.as_deref() {
Some(VSK_PUBLIC_INVITE) => {}
Some(VSK_PUBLIC_INVITE_REVOKED) => return Err(PublicInviteError::Revoked),
other => return Err(PublicInviteError::WrongSubKind(other.map(|s| s.to_string()))),
}
let plaintext =
cipher::open(&public_invite_key(token), &event.content).map_err(PublicInviteError::Cipher)?;
serde_json::from_slice(&plaintext).map_err(|e| PublicInviteError::Json(e.to_string()))
}
fn find_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == name).then(|| s[1].clone())
})
}
#[derive(Serialize, Deserialize)]
struct UrlFragment {
v: u8,
relays: Vec<String>,
t: String,
}
const URL_V2: u8 = 2;
const V2_FLAG_DEFAULT_RELAYS: u8 = 0b0000_0001;
const MAX_V2_BOOTSTRAP_RELAYS: usize = 3;
const RELAY_DICTIONARY: &[&str] = &[
"wss://jskitty.com/nostr", "wss://asia.vectorapp.io/nostr", "wss://nostr.computingcache.com", "wss://relay.damus.io", ];
fn norm_relay(r: &str) -> String {
r.trim().trim_end_matches('/').to_ascii_lowercase()
}
fn is_default_relay_set(relays: &[String]) -> bool {
let defaults: std::collections::HashSet<String> =
crate::state::TRUSTED_RELAYS.iter().map(|r| norm_relay(r)).collect();
let theirs: std::collections::HashSet<String> = relays.iter().map(|r| norm_relay(r)).collect();
theirs == defaults
}
fn dictionary_id(relay: &str) -> Option<u8> {
let n = norm_relay(relay);
RELAY_DICTIONARY.iter().position(|d| norm_relay(d) == n).map(|i| (i + 1) as u8)
}
pub fn encode_invite_url(relays: &[String], token: &[u8; 32]) -> String {
let mut payload: Vec<u8> = Vec::with_capacity(34);
payload.push(URL_V2);
if is_default_relay_set(relays) {
payload.push(V2_FLAG_DEFAULT_RELAYS);
} else {
payload.push(0);
let boot: Vec<&String> = relays
.iter()
.filter(|r| r.strip_prefix("wss://").unwrap_or(r).len() <= 255)
.take(MAX_V2_BOOTSTRAP_RELAYS)
.collect();
payload.push(boot.len() as u8);
for r in boot {
match dictionary_id(r) {
Some(id) => payload.push(id),
None => {
let (kind, s) = match r.strip_prefix("wss://") {
Some(host) => (0u8, host),
None => (255u8, r.as_str()),
};
payload.push(kind);
payload.push(s.len() as u8);
payload.extend_from_slice(s.as_bytes());
}
}
}
}
payload.extend_from_slice(token);
let b64 = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
format!("{INVITE_URL_BASE}#{b64}")
}
pub fn parse_invite_url(url: &str) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
let fragment = url.rsplit_once('#').map(|(_, f)| f).unwrap_or(url);
if fragment.is_empty() {
return Err(PublicInviteError::BadUrl("no fragment".into()));
}
let raw = base64_simd::URL_SAFE_NO_PAD
.decode_to_vec(fragment.as_bytes())
.map_err(|e| PublicInviteError::BadUrl(format!("base64: {e}")))?;
match raw.first() {
Some(&URL_V2) => parse_v2_fragment(&raw),
Some(&b'{') => parse_v1_fragment(&raw),
_ => Err(PublicInviteError::BadUrl("unrecognized fragment format".into())),
}
}
fn parse_v1_fragment(json: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
let frag: UrlFragment =
serde_json::from_slice(json).map_err(|e| PublicInviteError::BadUrl(format!("json: {e}")))?;
if frag.v != 1 {
return Err(PublicInviteError::BadUrl(format!("unsupported url version {}", frag.v)));
}
if frag.relays.len() > MAX_URL_RELAYS {
return Err(PublicInviteError::BadUrl(format!(
"invite url declares too many relays ({})",
frag.relays.len()
)));
}
let token = crate::simd::hex::hex_to_bytes_32_checked(&frag.t)
.ok_or_else(|| PublicInviteError::BadUrl("token must be 64 hex chars".into()))?;
Ok((frag.relays, token))
}
fn parse_v2_fragment(raw: &[u8]) -> Result<(Vec<String>, [u8; 32]), PublicInviteError> {
let bad = |m: &str| PublicInviteError::BadUrl(m.into());
let flags = *raw.get(1).ok_or_else(|| bad("truncated v2 fragment"))?;
let mut pos = 2usize;
let relays: Vec<String> = if flags & V2_FLAG_DEFAULT_RELAYS != 0 {
crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect()
} else {
let count = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay count"))? as usize;
pos += 1;
if count == 0 || count > MAX_URL_RELAYS {
return Err(bad("bad v2 relay count"));
}
let mut out = Vec::with_capacity(count.min(MAX_V2_BOOTSTRAP_RELAYS));
for _ in 0..count {
let id = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay entry"))?;
pos += 1;
if id == 0 || id == 255 {
let len = *raw.get(pos).ok_or_else(|| bad("truncated v2 relay literal"))? as usize;
pos += 1;
let end = pos.checked_add(len).ok_or_else(|| bad("bad v2 relay length"))?;
let host = raw.get(pos..end).ok_or_else(|| bad("truncated v2 relay literal"))?;
let host =
std::str::from_utf8(host).map_err(|_| bad("v2 relay literal not utf-8"))?;
out.push(if id == 0 { format!("wss://{host}") } else { host.to_string() });
pos = end;
} else if let Some(relay) = RELAY_DICTIONARY.get(id as usize - 1) {
out.push(relay.to_string());
}
}
if out.is_empty() {
return Err(bad("no resolvable bootstrap relays"));
}
out
};
let token_bytes = raw.get(pos..).ok_or_else(|| bad("truncated v2 token"))?;
if token_bytes.len() != 32 {
return Err(bad("v2 token must be exactly 32 bytes"));
}
let mut token = [0u8; 32];
token.copy_from_slice(token_bytes);
Ok((relays, token))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn v2_url_default_relay_set_roundtrips_and_is_tiny() {
let relays: Vec<String> =
crate::state::TRUSTED_RELAYS.iter().map(|s| s.to_string()).collect();
let token = new_token();
let url = encode_invite_url(&relays, &token);
assert!(url.len() <= 80, "default-set v2 url should be ~74 chars, got {}", url.len());
let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
assert_eq!(parsed_token, token);
assert_eq!(parsed_relays, relays);
}
#[test]
fn v2_url_dictionary_and_literal_relays_roundtrip() {
let relays = vec![
"wss://relay.damus.io".to_string(),
"wss://my.custom.relay/nostr".to_string(),
"r1".to_string(),
];
let token = new_token();
let url = encode_invite_url(&relays, &token);
let (parsed_relays, parsed_token) = parse_invite_url(&url).unwrap();
assert_eq!(parsed_token, token);
assert_eq!(parsed_relays, relays);
}
#[test]
fn v1_json_fragment_still_parses() {
let token = new_token();
let json = format!(
"{{\"v\":1,\"relays\":[\"wss://r1\",\"wss://r2\"],\"t\":\"{}\"}}",
crate::simd::hex::bytes_to_hex_32(&token)
);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
let (relays, parsed) = parse_invite_url(&format!("{INVITE_URL_BASE}#{frag}")).unwrap();
assert_eq!(parsed, token);
assert_eq!(relays, vec!["wss://r1".to_string(), "wss://r2".to_string()]);
}
#[test]
fn v2_unknown_dictionary_id_is_skipped_not_fatal() {
let token = new_token();
let mut payload = vec![2u8, 0, 2, 250, 1]; payload.extend_from_slice(&token);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
let (relays, parsed) = parse_invite_url(&frag).unwrap();
assert_eq!(parsed, token);
assert_eq!(relays, vec!["wss://jskitty.com/nostr".to_string()]);
}
#[test]
fn v2_garbage_and_truncations_error_without_panic() {
for frag_bytes in [
vec![2u8], vec![2u8, 1], vec![2u8, 0, 1, 0, 200], vec![2u8, 1, 0xAA], vec![9u8, 1, 2, 3], ] {
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&frag_bytes);
assert!(parse_invite_url(&frag).is_err());
}
}
#[test]
fn bundle_round_trips_with_token() {
let mut c = Community::create("Cool Place", "general", vec!["wss://r1".into()]);
c.description = Some("the coolest".into());
let token = new_token();
let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
assert_eq!(event.pubkey, signer_pubkey(&token));
assert_eq!(find_tag(&event, "d").as_deref(), Some(locator_hex(&token).as_str()));
let bundle = parse_public_invite_event(&event, &token).unwrap();
assert_eq!(bundle.preview.name, "Cool Place");
assert_eq!(bundle.preview.description.as_deref(), Some("the coolest"));
let member = super::super::invite::accept_invite(&bundle.join).unwrap();
assert_eq!(member.id, c.id);
assert_eq!(member.channels[0].key.as_bytes(), c.channels[0].key.as_bytes());
}
#[test]
fn tombstone_replaces_the_bundle_at_the_same_coordinate() {
let c = Community::create("HQ", "general", vec!["wss://r1".into()]);
let token = new_token();
let bundle = build_public_invite_event(&c, &token, None, None, None).unwrap();
let tomb = build_public_invite_tombstone(&token).unwrap();
assert_eq!(tomb.kind, bundle.kind);
assert_eq!(tomb.pubkey, bundle.pubkey);
assert_eq!(find_tag(&tomb, "d"), find_tag(&bundle, "d"));
assert!(tomb.content.is_empty());
assert!(
matches!(parse_public_invite_event(&tomb, &token), Err(PublicInviteError::Revoked)),
"tombstone parses as an explicit Revoked, not a bundle",
);
}
#[test]
fn wrong_token_cannot_read_bundle() {
let c = Community::create("HQ", "general", vec![]);
let token = new_token();
let event = build_public_invite_event(&c, &token, None, None, None).unwrap();
let other = new_token();
let err = parse_public_invite_event(&event, &other);
assert!(matches!(err, Err(PublicInviteError::UnexpectedSigner)), "got {err:?}");
}
#[test]
fn impostor_at_locator_is_rejected() {
let c = Community::create("HQ", "general", vec![]);
let token = new_token();
let attacker = Keys::generate();
let impostor = EventBuilder::new(Kind::Custom(event_kind::APPLICATION_SPECIFIC), "x")
.tags([
Tag::identifier(locator_hex(&token)),
Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [VSK_PUBLIC_INVITE.to_string()]),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&attacker)
.unwrap();
let _ = &c;
assert!(matches!(
parse_public_invite_event(&impostor, &token),
Err(PublicInviteError::UnexpectedSigner)
));
}
#[test]
fn expiry_is_reported() {
let c = Community::create("HQ", "general", vec![]);
let token = new_token();
let event = build_public_invite_event(&c, &token, Some(1000), None, None).unwrap();
let bundle = parse_public_invite_event(&event, &token).unwrap();
assert!(!bundle.is_expired(999));
assert!(bundle.is_expired(1000), "expiry is inclusive");
assert!(bundle.is_expired(2000));
}
#[test]
fn url_round_trips() {
let relays = vec!["wss://a".to_string(), "wss://b".to_string()];
let token = new_token();
let url = encode_invite_url(&relays, &token);
assert!(url.starts_with(INVITE_URL_BASE));
assert!(url.contains('#'));
let (r, t) = parse_invite_url(&url).unwrap();
assert_eq!(r, relays);
assert_eq!(t, token);
let frag = url.rsplit_once('#').unwrap().1;
assert_eq!(parse_invite_url(frag).unwrap().1, token);
}
#[test]
fn malformed_url_errors() {
assert!(parse_invite_url("https://vectorapp.io/invite#").is_err());
assert!(parse_invite_url("https://vectorapp.io/invite#not!!base64").is_err());
}
#[test]
fn url_with_too_many_relays_is_rejected() {
let token = new_token();
let many: Vec<String> = (0..MAX_URL_RELAYS + 1).map(|i| format!("wss://r{i}")).collect();
let (relays, _) = parse_invite_url(&encode_invite_url(&many, &token)).unwrap();
assert!(relays.len() <= MAX_V2_BOOTSTRAP_RELAYS);
let mut payload = vec![URL_V2, 0, (MAX_URL_RELAYS + 1) as u8];
payload.extend_from_slice(&token);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&payload);
assert!(parse_invite_url(&frag).is_err());
let json = format!(
"{{\"v\":1,\"relays\":[{}],\"t\":\"{}\"}}",
(0..MAX_URL_RELAYS + 1).map(|i| format!("\"wss://r{i}\"")).collect::<Vec<_>>().join(","),
crate::simd::hex::bytes_to_hex_32(&token)
);
let frag = base64_simd::URL_SAFE_NO_PAD.encode_to_string(json.as_bytes());
assert!(parse_invite_url(&frag).is_err());
}
}