add rekeys, refounding and dissolution
This commit is contained in:
@@ -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
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user