add rekeys, refounding and dissolution

This commit is contained in:
2026-09-17 08:16:41 +07:00
parent 79a4dd387d
commit ecd08273eb
8 changed files with 2035 additions and 44 deletions
+89 -1
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
verify_community_id,
invite_links_locator, verify_community_id,
};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
@@ -21,6 +21,7 @@ use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
pub const MAX_RELAYS: usize = 5;
pub const MAX_REGISTRY_LINKS: usize = 64;
pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0);
@@ -334,6 +335,37 @@ impl ControlWriter {
at_secs,
)
}
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
&self,
keys: &Keys,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
.map(PublicKey::to_hex)
.collect();
let content = serde_json::to_string(&entries)?;
self.publish(
keys,
Edition {
subkind: vsk::INVITE_LINKS,
entity: invite_links_locator(community_id, &creator.to_bytes()),
content: &content,
head,
citation,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
@@ -361,10 +393,18 @@ pub struct ControlFold {
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
/// Each creator's live link-signer set.
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
pub floors: Floors,
pub gapped: bool,
}
impl ControlFold {
pub fn is_public(&self) -> bool {
self.registries.values().any(|links| !links.is_empty())
}
}
pub fn fold_control(
owner: &PublicKey,
community_id: &CommunityId,
@@ -388,6 +428,7 @@ pub fn fold_control(
banned: roster.banned,
community: metadata.community,
channels: metadata.channels,
registries: metadata.registries,
floors,
gapped: roster.gapped || metadata.gapped,
}
@@ -397,6 +438,7 @@ pub fn fold_control(
struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
floors: Floors,
gapped: bool,
}
@@ -464,9 +506,55 @@ fn fold_metadata(
}
}
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold
}
fn fold_registries(
judge: &Judge<'_>,
editions: &[ParsedEdition],
floors: &mut Floors,
gapped: &mut bool,
) -> BTreeMap<PublicKey, Vec<PublicKey>> {
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
if edition.subkind == vsk::INVITE_LINKS
&& invite_links_locator(judge.community_id, &edition.author.to_bytes())
== edition.entity
{
candidates.entry(edition.entity).or_default().push(edition);
}
}
let mut registries = BTreeMap::new();
for (entity, group) in &candidates {
let Some(head) = authorized_head(judge, *entity, group, Permissions::CREATE_INVITE, gapped)
else {
continue;
};
floors.insert(*entity, EntityHead::from(head));
let Ok(links) = serde_json::from_str::<Vec<String>>(&head.content) else {
continue;
};
registries.insert(
head.author,
links
.iter()
.filter_map(|link| PublicKey::from_hex(link).ok())
.take(MAX_REGISTRY_LINKS)
.collect(),
);
}
registries
}
struct Judge<'a> {
owner: &'a PublicKey,
community_id: &'a CommunityId,
+163 -11
View File
@@ -1,9 +1,12 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
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::nip44::{self, Version};
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
@@ -11,15 +14,18 @@ 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::list::{canonical, union};
use crate::stream::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_INVITE_LIST: u16 = 13303;
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;
pub const MAX_INVITE_ENTRIES: usize = 64;
const FLAG_STOCK_SET: u8 = 0x01;
const INVITE_PATH: &str = "/invite/";
@@ -39,6 +45,9 @@ pub enum InviteError {
Json(String),
BadHex(&'static str),
TooManyChannels(usize),
TooManyInvites(usize),
Oversize(usize),
Kind(u16),
EpochTooLarge(u64),
OwnerMismatch,
BadFragment(&'static str),
@@ -60,6 +69,16 @@ impl fmt::Display for InviteError {
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
)
}
InviteError::TooManyInvites(count) => {
write!(
f,
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
)
}
InviteError::Oversize(len) => {
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
InviteError::OwnerMismatch => {
write!(f, "bundle owner does not reproduce its community_id")
@@ -123,7 +142,6 @@ pub struct CommunityInvite {
}
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()))?;
@@ -472,6 +490,149 @@ pub fn unwrap_direct_invite(
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteEntry {
/// The link's unlock secret, and its merge key.
pub token: String,
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
pub signer_sk: String,
pub community_id: CommunityId,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteTombstone {
pub token: String,
pub community_id: CommunityId,
#[serde(flatten)]
pub extra: Extra,
}
/// A creator's own link bookkeeping.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InviteList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<InviteEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<InviteTombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl InviteList {
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
pub fn is_live(&self, token: &str) -> bool {
self.entries.iter().any(|entry| entry.token == token)
&& !self
.tombstones
.iter()
.any(|tombstone| tombstone.token == token)
}
pub fn fits(&self) -> Result<(), InviteError> {
if self.entries.len() > MAX_INVITE_ENTRIES {
return Err(InviteError::TooManyInvites(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(InviteError::Oversize(json.len()));
}
Ok(())
}
}
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => {
let merged = merge_entry(slot.get(), &entry);
*slot.get_mut() = merged;
}
}
}
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
if canonical(&tombstone) < canonical(slot.get()) {
*slot.get_mut() = tombstone;
}
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
InviteList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
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_INVITE_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::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)
}
/// An entry is immutable once minted, so two copies should agree.
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
let (winner, loser) = if canonical(incoming) < canonical(held) {
(incoming, held)
} else {
(held, incoming)
};
let mut merged = winner.clone();
union(&mut merged.extra, loser.extra.clone());
merged
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(stream::seal_bytes(
&ConversationKey::new(*bundle_key),
@@ -642,11 +803,6 @@ mod tests {
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]
@@ -678,10 +834,6 @@ mod tests {
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]
+1
View File
@@ -5,6 +5,7 @@ pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod rekey;
pub mod roles;
pub mod store;
pub mod stream;
+2 -4
View File
@@ -43,7 +43,6 @@ impl fmt::Display for ListError {
impl std::error::Error for ListError {}
/// A membership's keys.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial {
pub community_id: CommunityId,
@@ -83,7 +82,6 @@ pub struct Tombstone {
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")]
@@ -242,7 +240,7 @@ fn pick<'a>(
held
}
fn union(into: &mut Extra, other: Extra) {
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
@@ -255,7 +253,7 @@ fn union(into: &mut Extra, other: Extra) {
}
}
fn canonical<T: Serialize>(value: &T) -> String {
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -120,6 +120,8 @@ pub struct CommunityState {
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64,
}
@@ -186,6 +188,7 @@ impl CommunityState {
relays,
heads,
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
+7 -5
View File
@@ -259,21 +259,21 @@ pub fn wrap_seal_with(
pub fn rewrap_seal(
seal: &Event,
new_group: &GroupKey,
read: &GroupKey,
signer: &GroupKey,
at: Timestamp,
) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable);
}
wrap_seal(seal, new_group, KIND_WRAP, at, &[])
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
}
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
}
/// Open and verify a wrap against a stream read view: the address to check and
/// the conversation key that opens the wraps, with no signing secret required.
pub fn open_wrap_at(
wrap: &Event,
address: &PublicKey,
@@ -498,7 +498,8 @@ mod tests {
assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), Timestamp::from_secs(2)).expect("rewraps");
rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
.expect("rewraps");
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
assert_eq!(reopened.author, author.public_key());
@@ -512,6 +513,7 @@ mod tests {
rewrap_seal(
&sealed(&edition, SealForm::Encrypted, &author),
&group(1),
&group(1),
Timestamp::from_secs(2)
),
Err(StreamError::NotRewrappable)