add invites and community list

This commit is contained in:
2026-09-17 07:49:07 +07:00
parent ea9abae554
commit 79a4dd387d
6 changed files with 1449 additions and 30 deletions
+4 -3
View File
@@ -13,7 +13,7 @@ pub const KIND_CONTROL: u16 = 3308;
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
/// Entity types an edition can address (CORD-02 Appendix B).
/// Entity types an edition can address.
pub mod vsk {
pub const COMMUNITY_METADATA: &str = "0";
pub const ROLE: &str = "1";
@@ -27,11 +27,12 @@ pub mod vsk {
pub const PINS: &str = "11";
}
const TAG_SUBKIND: &str = "vsk";
pub const TAG_SUBKIND: &str = "vsk";
pub const TAG_CITATION: &str = "vac";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
pub const TAG_CITATION: &str = "vac";
#[derive(Debug)]
pub enum EditionError {
+810
View File
@@ -0,0 +1,810 @@
use std::fmt;
use data_encoding::BASE64URL_NOPAD;
use nostr::nips::nip01::Coordinate;
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
use nostr::nips::nip44::v2::ConversationKey;
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::control::{ImageRef, MAX_RELAYS};
use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::edition::{TAG_SUBKIND, vsk};
use crate::stream::{self, StreamError};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_DIRECT_INVITE: u16 = 3313;
pub const FRAGMENT_VERSION: u8 = 4;
pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
const FLAG_STOCK_SET: u8 = 0x01;
const INVITE_PATH: &str = "/invite/";
const TAG_IDENTIFIER: &str = "d";
const TAG_EXPIRATION: &str = "expiration";
const RELAY_DICT: [&str; 4] = [
"wss://jskitty.com/nostr",
"wss://asia.vectorapp.io/nostr",
"wss://relay.ditto.pub",
"wss://relay.dreamith.to",
];
#[derive(Debug)]
pub enum InviteError {
Stream(StreamError),
Json(String),
BadHex(&'static str),
TooManyChannels(usize),
EpochTooLarge(u64),
OwnerMismatch,
BadFragment(&'static str),
BadVersion(u8),
BadLink(&'static str),
BadEvent(&'static str),
Crypto(String),
}
impl fmt::Display for InviteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InviteError::Stream(error) => write!(f, "stream: {error}"),
InviteError::Json(error) => write!(f, "json: {error}"),
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
InviteError::TooManyChannels(count) => {
write!(
f,
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
)
}
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
InviteError::OwnerMismatch => {
write!(f, "bundle owner does not reproduce its community_id")
}
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
InviteError::BadVersion(version) => {
write!(f, "unsupported invite fragment version {version}")
}
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
}
}
}
impl std::error::Error for InviteError {}
impl From<StreamError> for InviteError {
fn from(error: StreamError) -> Self {
InviteError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelGrant {
pub id: ChannelId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub epoch: Epoch,
#[serde(default)]
pub name: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityInvite {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
/// Unix **ms**: past it the preview still renders, joining refuses.
#[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<PublicKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityInvite {
/// Parse, bound and validate a decrypted bundle, whichever lane carried it.
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
let mut invite: Self =
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(invite.channels.len()));
}
invite.relays.truncate(MAX_RELAYS);
invite.validate()?;
Ok(invite)
}
pub fn validate(&self) -> Result<(), InviteError> {
if self.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(self.channels.len()));
}
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
if epoch.0 > MAX_BUNDLE_EPOCH {
return Err(InviteError::EpochTooLarge(epoch.0));
}
}
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
hex32(&self.community_root, "community_root")?;
for channel in &self.channels {
if let Some(key) = &channel.key {
hex32(key, "channel key")?;
}
}
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
return Err(InviteError::OwnerMismatch);
}
Ok(())
}
pub fn expired(&self, now_ms: u64) -> bool {
self.expires_at.is_some_and(|expires| now_ms > expires)
}
}
#[derive(Debug, Clone)]
pub enum BundleState {
Live(Box<CommunityInvite>),
Revoked,
}
pub fn build_bundle_event(
link_signer: &Keys,
invite: &CommunityInvite,
bundle_key: &[u8; 32],
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let content = seal_bundle(bundle_key, &json)?;
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn parse_bundle_event(
event: &Event,
expected_signer: &PublicKey,
bundle_key: &[u8; 32],
) -> Result<BundleState, InviteError> {
if event.kind.as_u16() != KIND_BUNDLE {
return Err(InviteError::BadEvent("wrong kind"));
}
if event.pubkey != *expected_signer {
return Err(InviteError::BadEvent("author is not the link signer"));
}
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
return Err(InviteError::BadEvent(
"bundle is not at the link's coordinate",
));
}
event
.verify()
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
match first_tag(event, TAG_SUBKIND).as_deref() {
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
Some(vsk::INVITE_LIVE) => {}
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
}
let json = open_bundle(bundle_key, &event.content)?;
Ok(BundleState::Live(Box::new(
CommunityInvite::from_bundle_json(&json)?,
)))
}
pub fn stock_relays() -> Vec<String> {
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
}
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
let stock = relays == RELAY_DICT;
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
bytes.push(FRAGMENT_VERSION);
if stock {
bytes.push(FLAG_STOCK_SET);
} else {
bytes.push(0x00);
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
bytes.push(bounded.len() as u8);
for relay in bounded {
match dict_id(relay) {
Some(id) => bytes.push(id),
None => {
let (lead, literal) = match relay.strip_prefix("wss://") {
Some(host) => (0x00, host),
None => (0xff, relay.as_str()),
};
if literal.len() > u8::MAX as usize {
return Err(InviteError::BadFragment("relay too long"));
}
bytes.extend_from_slice(&[lead, literal.len() as u8]);
bytes.extend_from_slice(literal.as_bytes());
}
}
}
}
bytes.extend_from_slice(token);
Ok(BASE64URL_NOPAD.encode(&bytes))
}
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
let bytes = BASE64URL_NOPAD
.decode(fragment.trim().as_bytes())
.map_err(|_| InviteError::BadFragment("not base64url"))?;
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
if version != FRAGMENT_VERSION {
return Err(InviteError::BadVersion(version));
}
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
let mut offset = 2;
let mut relays = Vec::new();
if flags & FLAG_STOCK_SET != 0 {
relays = stock_relays();
} else {
let count = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
if count > MAX_BOOTSTRAP_RELAYS {
return Err(InviteError::BadFragment("too many bootstrap relays"));
}
for _ in 0..count {
let lead = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))?;
offset += 1;
if (1..=254).contains(&lead) {
if let Some(url) = dict_url(lead) {
relays.push(url.to_string());
}
continue;
}
let len = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
let end = offset
.checked_add(len)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
let text = std::str::from_utf8(raw)
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
relays.push(match lead {
0x00 => format!("wss://{text}"),
0xff => text.to_string(),
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
});
offset = end;
}
}
let end = offset
.checked_add(TOKEN_LEN)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
if end != bytes.len() {
return Err(InviteError::BadFragment("trailing bytes"));
}
let mut token = [0u8; TOKEN_LEN];
token.copy_from_slice(raw);
Ok((token, relays))
}
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
let coordinate = Coordinate {
kind: Kind::Custom(KIND_BUNDLE),
public_key: *link_signer,
identifier: String::new(),
};
Nip19::Coordinate(Nip19Coordinate {
coordinate,
relays: Vec::new(),
})
.to_bech32()
.map_err(|_| InviteError::BadLink("invalid naddr"))
}
pub fn build_invite_url(
base: &str,
link_signer: &PublicKey,
token: &[u8; TOKEN_LEN],
relays: &[String],
) -> Result<String, InviteError> {
let naddr = bundle_naddr(link_signer)?;
let fragment = encode_fragment(token, relays)?;
Ok(format!(
"{}{INVITE_PATH}{naddr}#{fragment}",
base.trim_end_matches('/')
))
}
#[derive(Debug, Clone)]
pub struct ParsedInviteLink {
/// The bundle coordinate's author.
pub link_signer: PublicKey,
pub token: [u8; TOKEN_LEN],
pub bootstrap_relays: Vec<String>,
/// The bare naddr as it appeared in the link, for the fetch.
pub naddr: String,
}
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
let (locator, fragment) = input
.trim()
.split_once('#')
.ok_or(InviteError::BadLink("no fragment"))?;
if fragment.is_empty() {
return Err(InviteError::BadLink("empty fragment"));
}
let naddr = match locator.find(INVITE_PATH) {
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
None => locator.trim_start_matches("nostr:"),
};
let link_signer = signer_from_naddr(naddr)?;
let (token, bootstrap_relays) = decode_fragment(fragment)?;
Ok(ParsedInviteLink {
link_signer,
token,
bootstrap_relays,
naddr: naddr.to_string(),
})
}
pub fn build_direct_invite(
inviter: &Keys,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
.finalize_unsigned(inviter.public_key());
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
if let Some(expires_at) = invite.expires_at {
tags.push(Tag::custom(
TAG_EXPIRATION,
[(expires_at / 1000).to_string()],
));
}
GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags)
.finalize(inviter)
.map_err(crypto_error)
}
pub fn unwrap_direct_invite(
wrap: &Event,
recipient: &Keys,
) -> Result<(PublicKey, CommunityInvite), InviteError> {
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
}
Ok((
unwrapped.sender,
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
))
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(stream::seal_bytes(
&ConversationKey::new(*bundle_key),
json.as_bytes(),
)?)
}
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
let plaintext = stream::open_bytes(&ConversationKey::new(*bundle_key), content)?;
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
}
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
Ok(Nip19::Coordinate(coordinate))
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
&& coordinate.coordinate.identifier.is_empty() =>
{
Ok(coordinate.coordinate.public_key)
}
_ => Err(InviteError::BadLink(
"naddr is not an invite-bundle coordinate",
)),
}
}
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
}
fn dict_id(relay: &str) -> Option<u8> {
RELAY_DICT
.iter()
.position(|known| *known == relay)
.map(|index| index as u8 + 1)
}
fn dict_url(id: u8) -> Option<&'static str> {
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
}
fn empty_identifier() -> Tag {
Tag::identifier("")
}
fn subkind_tag(value: &str) -> Tag {
Tag::custom(TAG_SUBKIND, [value])
}
fn first_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|tag| {
let fields = tag.as_slice();
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
})
}
fn json_error(error: serde_json::Error) -> InviteError {
InviteError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> InviteError {
InviteError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use data_encoding::HEXLOWER;
use super::*;
use crate::derive::{community_id_of, invite_bundle_key};
const SALT: [u8; 32] = [0x33u8; 32];
fn bundle() -> CommunityInvite {
let owner = Keys::generate();
CommunityInvite {
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
owner: owner.public_key(),
owner_salt: HEXLOWER.encode(&SALT),
community_root: "44".repeat(32),
root_epoch: Epoch(0),
control_pk: None,
channels: vec![ChannelGrant {
id: ChannelId::from_bytes([0x9cu8; 32]),
key: Some("55".repeat(32)),
epoch: Epoch(1),
name: "lounge".to_owned(),
extra: Extra::default(),
}],
relays: vec!["wss://relay.example".to_owned()],
name: "Test community".to_owned(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Extra::default(),
}
}
fn token16() -> [u8; TOKEN_LEN] {
std::array::from_fn(|i| i as u8)
}
#[test]
fn fragment_goldens_pin_the_wire_layout() {
let token = token16();
// [04 version][01 stock flag][token 00..0f]
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&stock).expect("decodes"),
(token, stock_relays())
);
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
let encoded = encode_fragment(&token, &mixed).expect("encodes");
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
let verbatim = vec!["ws://h".to_owned()];
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&encoded).expect("decodes"),
(token, verbatim)
);
}
#[test]
fn a_fragment_is_strict_about_framing_and_counts() {
let token = token16();
for version in [3u8, 5] {
let mut bytes = vec![version, FLAG_STOCK_SET];
bytes.extend_from_slice(&token);
let encoded = BASE64URL_NOPAD.encode(&bytes);
assert!(
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
"a legacy and a future version are both refused"
);
}
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
trailing.extend_from_slice(&token);
trailing.push(0xff);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
Err(InviteError::BadFragment(_))
));
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
over.extend_from_slice(&token);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
Err(InviteError::BadFragment(_))
));
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
unknown.extend_from_slice(&token);
let (decoded, relays) =
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
assert_eq!(decoded, token);
assert!(relays.is_empty());
let relays: Vec<String> = (0..4).map(|i| format!("wss://r{i}.example")).collect();
let (_, capped) =
decode_fragment(&encode_fragment(&token, &relays).expect("encodes")).expect("decodes");
assert_eq!(capped.len(), MAX_BOOTSTRAP_RELAYS);
}
#[test]
fn a_link_round_trips_and_refuses_a_non_invite() {
let link_signer = Keys::generate();
let token = token16();
let relays = vec!["wss://a.example".to_owned()];
let url = build_invite_url(
"https://vectorapp.io/",
&link_signer.public_key(),
&token,
&relays,
)
.expect("builds");
let parsed = parse_link(&url).expect("parses");
assert_eq!(parsed.link_signer, link_signer.public_key());
assert_eq!(parsed.token, token);
assert_eq!(parsed.bootstrap_relays, relays);
let fragment = url.split('#').nth(1).expect("carries a fragment");
let bare = format!("{}#{fragment}", parsed.naddr);
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
assert_eq!(reparsed.link_signer, link_signer.public_key());
assert_eq!(reparsed.token, token);
assert!(
parse_link("https://x/invite/#frag").is_err(),
"the naddr is not optional"
);
assert!(
parse_link("wss://relay.example.com").is_err(),
"nor the fragment"
);
}
#[test]
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
assert_eq!(event.pubkey, link_signer.public_key());
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
BundleState::Live(opened) => {
assert_eq!(opened.community_id, invite.community_id);
assert_eq!(opened.channels.len(), 1);
}
BundleState::Revoked => panic!("expected a live bundle"),
}
let revocation = build_revocation(&link_signer).expect("builds");
assert!(matches!(
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
Ok(BundleState::Revoked)
));
// The token is the only way in, and a squatter is a different coordinate.
assert!(
parse_bundle_event(
&event,
&link_signer.public_key(),
&invite_bundle_key(&[8u8; TOKEN_LEN])
)
.is_err()
);
let squatter = Keys::generate();
assert!(matches!(
parse_bundle_event(&event, &squatter.public_key(), &key),
Err(InviteError::BadEvent(_))
));
}
#[test]
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
let json = serde_json::to_string(&invite).expect("serializes");
let content = seal_bundle(&key, &json).expect("seals");
// The fetch filters on the author, so the empty `d` is pinned here: a
// signature-valid event of the same author at another `d` is not the bundle.
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
.finalize(&link_signer)
.expect("signs");
assert!(matches!(
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
Err(InviteError::BadEvent(_))
));
let mut forged = bundle();
forged.owner = Keys::generate().public_key();
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
assert!(matches!(
build_bundle_event(&link_signer, &forged, &key),
Err(InviteError::OwnerMismatch)
));
let mut malformed = bundle();
malformed.community_root = "not hex".to_owned();
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
let mut crowded = bundle();
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
.map(|_| ChannelGrant {
id: ChannelId::from_bytes([0x01; 32]),
key: None,
epoch: Epoch(0),
name: String::new(),
extra: Extra::default(),
})
.collect();
assert!(matches!(
crowded.validate(),
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
));
}
#[test]
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let invite = bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(
wrap.pubkey,
inviter.public_key(),
"the wrap author is ephemeral"
);
assert!(
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
"the k tag is what makes an invite indexable"
);
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open...
let stranger = Keys::generate();
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
// ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::chat::KIND_MESSAGE), "hello")
.finalize_unsigned(recipient.public_key());
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
.finalize(&recipient)
.expect("wraps");
assert!(matches!(
unwrap_direct_invite(&wrap, &recipient),
Err(InviteError::BadEvent(_))
));
}
}
+2
View File
@@ -3,6 +3,8 @@ pub mod control;
pub mod derive;
pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod roles;
pub mod store;
pub mod stream;
+480
View File
@@ -0,0 +1,480 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use nostr::nips::nip44::{self, Version};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::invite::{ChannelGrant, CommunityInvite};
use crate::stream::NIP44_MAX_PLAINTEXT;
use crate::{CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302;
pub const MAX_MEMBERSHIPS: usize = 50;
#[derive(Debug)]
pub enum ListError {
Kind(u16),
Crypto(String),
Json(String),
TooManyMemberships(usize),
Oversize(usize),
}
impl fmt::Display for ListError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
ListError::Crypto(error) => write!(f, "crypto: {error}"),
ListError::Json(error) => write!(f, "json: {error}"),
ListError::TooManyMemberships(count) => {
write!(
f,
"list carries {count} memberships (cap {MAX_MEMBERSHIPS})"
)
}
ListError::Oversize(len) => {
write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
}
}
}
impl std::error::Error for ListError {}
/// A membership's keys.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// Present only when the holder is staff.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
pub name: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityListEntry {
pub community_id: CommunityId,
pub seed: JoinMaterial,
pub current: JoinMaterial,
pub added_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tombstone {
pub community_id: CommunityId,
pub removed_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
/// A member's own memberships.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<Tombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool {
let added = self
.entries
.iter()
.find(|entry| entry.community_id == *community_id)
.map(|entry| entry.added_at);
match added {
None => false,
Some(added) => self
.tombstones
.iter()
.find(|tombstone| tombstone.community_id == *community_id)
.is_none_or(|tombstone| added > tombstone.removed_at),
}
}
pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(ListError::Oversize(json.len()));
}
Ok(())
}
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
JoinMaterial {
community_id: invite.community_id,
owner: invite.owner,
owner_salt: invite.owner_salt.clone(),
community_root: invite.community_root.clone(),
root_epoch: invite.root_epoch,
control_pk: invite.control_pk,
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)),
channels: invite.channels.clone(),
relays: invite.relays.clone(),
name: invite.name.clone(),
extra: Extra::default(),
}
}
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.community_id) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry),
}
}
let mut tombstones: BTreeMap<CommunityId, Tombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.community_id) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
let held = slot.get_mut();
held.removed_at = held.removed_at.max(tombstone.removed_at);
union(&mut held.extra, tombstone.extra);
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
CommunityList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
serde_json::from_str(&json).map_err(json_error)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Snapshot {
Seed,
Current,
}
fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
held.added_at = held.added_at.max(incoming.added_at);
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
union(&mut held.extra, incoming.extra);
}
fn pick<'a>(
held: &'a JoinMaterial,
incoming: &'a JoinMaterial,
which: Snapshot,
) -> &'a JoinMaterial {
let preferred = match which {
Snapshot::Seed => incoming.root_epoch < held.root_epoch,
Snapshot::Current => incoming.root_epoch > held.root_epoch,
};
if preferred {
return incoming;
}
if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) {
return incoming;
}
held
}
fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
fn json_error(error: serde_json::Error) -> ListError {
ListError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> ListError {
ListError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn id(byte: u8) -> CommunityId {
CommunityId::from_bytes([byte; 32])
}
fn material(
community_id: CommunityId,
owner: PublicKey,
name: &str,
epoch: u64,
) -> JoinMaterial {
JoinMaterial {
community_id,
owner,
owner_salt: "33".repeat(32),
community_root: "44".repeat(32),
root_epoch: Epoch(epoch),
control_pk: None,
control_root: None,
channels: vec![],
relays: vec!["wss://relay.example".to_owned()],
name: name.to_owned(),
extra: Extra::default(),
}
}
fn entry(
community_id: CommunityId,
seed: JoinMaterial,
current: JoinMaterial,
added_at: u64,
) -> CommunityListEntry {
CommunityListEntry {
community_id,
seed,
current,
added_at,
extra: Extra::default(),
}
}
fn list(entries: Vec<CommunityListEntry>) -> CommunityList {
CommunityList {
entries,
..Default::default()
}
}
fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList {
CommunityList {
tombstones: vec![Tombstone {
community_id,
removed_at,
extra: Extra::default(),
}],
..Default::default()
}
}
#[test]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key();
let older = material(id(0x11), owner, "Room", 1);
let newer = material(id(0x11), owner, "Room", 3);
let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]);
let b = list(vec![entry(id(0x11), newer, older, 5_000)]);
for merged in [merge(a.clone(), b.clone()), merge(b, a)] {
let merged = merged.entries.first().expect("one membership");
assert_eq!(
merged.seed.root_epoch,
Epoch(1),
"seed anchors the earliest epoch held"
);
assert_eq!(merged.current.root_epoch, Epoch(3));
assert_eq!(merged.added_at, 5_000);
}
// An epoch tie breaks on the whole snapshot's bytes, and does so for both
// orders, so two devices never flap competing republishes.
let alpha = material(id(0x11), owner, "Alpha", 2);
let beta = material(id(0x11), owner, "Beta", 2);
let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]);
let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]);
let first = merge(a.clone(), b.clone());
assert_eq!(first, merge(b, a));
assert_eq!(first.entries[0].current.name, "Alpha");
}
#[test]
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() {
let owner = Keys::generate().public_key();
let joined = entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
5_000,
);
let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000));
assert!(!left.is_live(&id(0x11)));
assert_eq!(
left.entries.len(),
1,
"a retired entry stays in the document"
);
// A stale device re-merging the entry cannot resurrect it.
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
// A re-join genuinely newer than the removal does.
let rejoined = list(vec![entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
7_000,
)]);
let live = merge(left, rejoined);
assert!(live.is_live(&id(0x11)));
// And the older removal is not re-applied on top of it.
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11)));
}
#[test]
fn a_second_device_reconstructs_membership_from_13302() {
let me = Keys::generate();
let owner = Keys::generate().public_key();
let mine = CommunityList {
entries: vec![
entry(
id(0x11),
material(id(0x11), owner, "Room", 1),
material(id(0x11), owner, "Room", 4),
AT,
),
entry(
id(0x22),
material(id(0x22), owner, "Other", 0),
material(id(0x22), owner, "Other", 0),
AT + 1,
),
],
tombstones: vec![Tombstone {
community_id: id(0x33),
removed_at: AT,
extra: Extra::default(),
}],
extra: Extra::default(),
};
let event = build_list_event(&me, &mine).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine);
assert!(
!parse_list_event(&me, &event)
.expect("parses")
.is_live(&id(0x33))
);
// Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate();
assert!(parse_list_event(&stranger, &event).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them.
let mut held = mine.clone();
held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
held.entries[0]
.current
.extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt =
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing.
let crowded = list(
(0..=MAX_MEMBERSHIPS)
.map(|index| {
let community_id = CommunityId::from_bytes([index as u8; 32]);
entry(
community_id,
material(community_id, owner, "Room", 0),
material(community_id, owner, "Room", 0),
AT,
)
})
.collect(),
);
assert!(matches!(
build_list_event(&me, &crowded),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
));
let oversized = list(vec![entry(
id(0x11),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
material(id(0x11), owner, "Room", 0),
AT,
)]);
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
}
const AT: u64 = 1_719_800_000_000;
}
+15 -7
View File
@@ -180,10 +180,23 @@ pub fn seal_content(
match form {
SealForm::Plaintext => Ok(json),
SealForm::Encrypted => Ok(BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?)),
SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()),
}
}
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
}
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
let payload = BASE64
.decode(content.as_bytes())
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
decrypt_to_bytes(conversation, &payload)
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub fn build_seal(
rumor: &UnsignedEvent,
form: SealForm,
@@ -357,12 +370,7 @@ fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>,
}
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
let payload = BASE64
.decode(content.as_bytes())
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
let plaintext = decrypt_to_bytes(conversation, &payload)
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
let plaintext = open_bytes(conversation, content)?;
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
}