feat: add community ui (#52)
Reviewed-on: #52
This commit was merged in pull request #52.
This commit is contained in:
@@ -3,8 +3,8 @@ use std::fmt;
|
||||
use data_encoding::BASE64;
|
||||
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
|
||||
use nostr_sdk::prelude::{
|
||||
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
||||
UnsignedEvent,
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
|
||||
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
|
||||
use crate::derive::GroupKey;
|
||||
@@ -17,8 +17,8 @@ pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
|
||||
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
|
||||
|
||||
const TAG_MS: &str = "ms";
|
||||
const TAG_CHANNEL: &str = "channel";
|
||||
const TAG_EPOCH: &str = "epoch";
|
||||
pub(crate) const TAG_CHANNEL: &str = "channel";
|
||||
pub(crate) const TAG_EPOCH: &str = "epoch";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SealForm {
|
||||
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
|
||||
}
|
||||
|
||||
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
|
||||
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
|
||||
seal_bytes(
|
||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
|
||||
plaintext,
|
||||
)
|
||||
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
check_plaintext_cap(plaintext.len())?;
|
||||
|
||||
let address = signer
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||
|
||||
signer
|
||||
.nip44_encrypt_async(&address, plaintext)
|
||||
.await
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
|
||||
open_bytes(
|
||||
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
|
||||
content,
|
||||
)
|
||||
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let address = signer
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
|
||||
|
||||
signer
|
||||
.nip44_decrypt_async(&address, content)
|
||||
.await
|
||||
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||
}
|
||||
|
||||
pub fn build_seal(
|
||||
pub async fn build_seal<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
form: SealForm,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
) -> Result<Event, StreamError> {
|
||||
author: &S,
|
||||
) -> Result<Event, StreamError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = seal_content(rumor, form, group)?;
|
||||
EventBuilder::new(Kind::Custom(form.kind()), content)
|
||||
.custom_created_at(rumor.created_at)
|
||||
.finalize(author)
|
||||
.finalize_async(author)
|
||||
.await
|
||||
.map_err(|error| StreamError::Sign(error.to_string()))
|
||||
}
|
||||
|
||||
@@ -400,7 +420,10 @@ fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
|
||||
pub(crate) fn unique_tag(
|
||||
rumor: &UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<String>, StreamError> {
|
||||
let mut found: Option<String> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
@@ -450,7 +473,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
|
||||
build_seal(rumor, form, &group(0), author).expect("seals")
|
||||
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
|
||||
}
|
||||
|
||||
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
|
||||
};
|
||||
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
|
||||
pub use crate::cords::rumor::RumorError as GuestbookError;
|
||||
use crate::cords::rumor::{optional_citation, pubkey, required, value};
|
||||
use crate::{GroupKey, decode_hex_32};
|
||||
|
||||
pub const KIND_JOIN_LEAVE: u16 = 3306;
|
||||
@@ -29,41 +27,6 @@ const TAG_CONTENT: &str = "content";
|
||||
const CONTENT_JOIN: &str = "join";
|
||||
const CONTENT_LEAVE: &str = "leave";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum GuestbookError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for GuestbookError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
|
||||
GuestbookError::NotEncryptedSealed => {
|
||||
write!(f, "guestbook rumor must ride an encrypted seal")
|
||||
}
|
||||
GuestbookError::UnknownKind(kind) => {
|
||||
write!(f, "not a guestbook rumor kind: {kind}")
|
||||
}
|
||||
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
|
||||
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
|
||||
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for GuestbookError {}
|
||||
|
||||
impl From<StreamError> for GuestbookError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
GuestbookError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GuestbookEntry {
|
||||
Join {
|
||||
@@ -183,18 +146,21 @@ pub fn build_snapshot_chunks(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn seal_rumor(
|
||||
pub async fn seal_rumor<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
) -> Result<(Event, Keys), GuestbookError> {
|
||||
author: &S,
|
||||
) -> Result<(Event, Keys), GuestbookError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_guestbook_kind(kind) {
|
||||
return Err(GuestbookError::UnknownKind(kind));
|
||||
}
|
||||
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
|
||||
|
||||
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
|
||||
}
|
||||
@@ -221,10 +187,15 @@ pub fn open(
|
||||
Ok((opened, rumor))
|
||||
}
|
||||
|
||||
/// Coalesce the guestbook flat: one final state per npub, the latest entry
|
||||
/// winning by millisecond time, ties broken by the lower rumor id.
|
||||
///
|
||||
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
|
||||
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
|
||||
pub fn coalesce(
|
||||
rumors: &[GuestbookRumor],
|
||||
now_ms: u64,
|
||||
snapshot_authority: Option<&PublicKey>,
|
||||
snapshot_authorities: &BTreeSet<PublicKey>,
|
||||
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
|
||||
) -> BTreeMap<PublicKey, MemberState> {
|
||||
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
|
||||
@@ -284,7 +255,7 @@ pub fn coalesce(
|
||||
at_ms,
|
||||
..
|
||||
} => {
|
||||
if snapshot_authority != Some(refounder) {
|
||||
if !snapshot_authorities.contains(refounder) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -466,73 +437,21 @@ fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), Guestboo
|
||||
Ok((snapshot_id, (index, total)))
|
||||
}
|
||||
|
||||
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(GuestbookError::BadTag(TAG_CITATION))
|
||||
}
|
||||
|
||||
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
|
||||
canonical_decimal(raw)
|
||||
.and_then(|value| u32::try_from(value).ok())
|
||||
.ok_or(GuestbookError::BadTag(TAG_SNAP))
|
||||
}
|
||||
|
||||
fn required<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<&'a [String], GuestbookError> {
|
||||
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
|
||||
}
|
||||
|
||||
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||
pubkey(value(required(rumor, name)?, name)?, name)
|
||||
}
|
||||
|
||||
fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, GuestbookError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(GuestbookError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(GuestbookError::BadTag(name))
|
||||
}
|
||||
|
||||
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::build_rumor_secs;
|
||||
use crate::cord01::{StreamError, build_rumor_secs};
|
||||
use crate::cord04::TAG_CITATION;
|
||||
use crate::derive::guestbook_group_key;
|
||||
use crate::{CommunityId, Epoch};
|
||||
|
||||
@@ -543,6 +462,11 @@ mod tests {
|
||||
CommunityId::from_bytes([0x11u8; 32])
|
||||
}
|
||||
|
||||
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
|
||||
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
|
||||
keys.iter().map(|keys| keys.public_key()).collect()
|
||||
}
|
||||
|
||||
fn group() -> GroupKey {
|
||||
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
|
||||
}
|
||||
@@ -556,7 +480,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
|
||||
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
|
||||
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
|
||||
.expect("seals")
|
||||
.0;
|
||||
|
||||
open(&wrap, &group()).expect("opens").1
|
||||
}
|
||||
@@ -617,7 +543,7 @@ mod tests {
|
||||
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
|
||||
};
|
||||
|
||||
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
|
||||
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&alice.public_key()),
|
||||
@@ -646,7 +572,7 @@ mod tests {
|
||||
|
||||
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
|
||||
assert_eq!(
|
||||
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
|
||||
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
|
||||
states,
|
||||
"arrival order cannot change the fold"
|
||||
);
|
||||
@@ -733,7 +659,7 @@ mod tests {
|
||||
),
|
||||
];
|
||||
|
||||
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
|
||||
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
|
||||
|
||||
assert_eq!(
|
||||
states.get(&kicked.public_key()),
|
||||
@@ -770,21 +696,21 @@ mod tests {
|
||||
)
|
||||
.remove(0);
|
||||
|
||||
for authority in [None, Some(refounder.public_key())] {
|
||||
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
|
||||
let states = coalesce(
|
||||
&[
|
||||
publish(&by_refounder, &refounder),
|
||||
publish(&by_impostor, &impostor),
|
||||
],
|
||||
AT + 1_000,
|
||||
authority.as_ref(),
|
||||
&authority,
|
||||
|_, _, _| true,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
states.contains_key(&seeded.public_key()),
|
||||
authority.is_some(),
|
||||
"only the epoch's refounder seeds, and there is no owner fallback"
|
||||
!authority.is_empty(),
|
||||
"only a known refounder seeds, and there is no owner fallback"
|
||||
);
|
||||
assert!(
|
||||
!states.contains_key(&smuggled.public_key()),
|
||||
@@ -808,11 +734,11 @@ mod tests {
|
||||
&member,
|
||||
);
|
||||
assert!(
|
||||
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
|
||||
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
|
||||
"an entry more than an hour ahead is dropped"
|
||||
);
|
||||
assert_eq!(
|
||||
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
|
||||
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
|
||||
1,
|
||||
"the horizon itself is skew, not forgery"
|
||||
);
|
||||
@@ -826,7 +752,9 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::Stream(StreamError::BadMs))
|
||||
@@ -835,7 +763,9 @@ mod tests {
|
||||
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_CONTENT))
|
||||
@@ -854,7 +784,7 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&ambiguous, &group(), &moderator)
|
||||
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
@@ -876,7 +806,9 @@ mod tests {
|
||||
);
|
||||
assert!(matches!(
|
||||
open(
|
||||
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
|
||||
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
|
||||
.expect("seals")
|
||||
.0,
|
||||
&group()
|
||||
),
|
||||
Err(GuestbookError::BadTag(TAG_SNAP))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,9 @@ pub mod list;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||
use nostr_sdk::prelude::{
|
||||
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
|
||||
pub wraps: Vec<Event>,
|
||||
}
|
||||
|
||||
pub fn genesis(
|
||||
owner: &Keys,
|
||||
pub async fn genesis<S>(
|
||||
owner: &S,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
) -> Result<CommunityGenesis>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let metadata_content = encode_metadata(metadata)?;
|
||||
let owner_salt = random_32()?;
|
||||
let owner_key = owner
|
||||
.get_public_key_async()
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
|
||||
|
||||
let identity = CommunityIdentity {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||
owner: owner.public_key(),
|
||||
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
|
||||
owner: owner_key,
|
||||
owner_salt,
|
||||
};
|
||||
|
||||
@@ -167,7 +176,7 @@ pub fn genesis(
|
||||
let mut wraps = Vec::with_capacity(editions.len());
|
||||
|
||||
for edition in &editions {
|
||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
|
||||
}
|
||||
|
||||
Ok(CommunityGenesis {
|
||||
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
|
||||
}
|
||||
|
||||
impl ControlWriter {
|
||||
pub fn publish(
|
||||
pub async fn publish<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
edition: Edition<'_>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let rumor = build_edition(EditionFields {
|
||||
author: self.author,
|
||||
subkind: edition.subkind,
|
||||
@@ -231,20 +243,23 @@ impl ControlWriter {
|
||||
});
|
||||
|
||||
let parsed = parse_edition(&rumor)?;
|
||||
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
|
||||
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
|
||||
|
||||
Ok((wrap, EntityHead::from(&parsed)))
|
||||
}
|
||||
|
||||
pub fn set_community_metadata(
|
||||
pub async fn set_community_metadata<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
metadata: &CommunityMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = encode_metadata(metadata)?;
|
||||
|
||||
self.publish(
|
||||
@@ -258,17 +273,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_channel_metadata(
|
||||
pub async fn set_channel_metadata<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
channel: &ChannelId,
|
||||
metadata: &ChannelMetadata,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if metadata.name.len() > MAX_NAME_BYTES {
|
||||
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
@@ -286,16 +305,20 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_role(
|
||||
pub async fn set_role<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
role: &Role,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if role.name.len() > MAX_NAME_BYTES {
|
||||
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
@@ -313,17 +336,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_grant(
|
||||
pub async fn set_grant<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
grant: &Grant,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let content = grant.to_content()?;
|
||||
|
||||
self.publish(
|
||||
@@ -337,17 +364,21 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn set_banlist(
|
||||
pub async fn set_banlist<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
banned: &BTreeSet<PublicKey>,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
if banned.len() > MAX_BANLIST {
|
||||
bail!("banlist exceeds {MAX_BANLIST} entries");
|
||||
}
|
||||
@@ -366,19 +397,23 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_registry(
|
||||
pub async fn set_registry<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
creator: &PublicKey,
|
||||
links: &[PublicKey],
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let entries: Vec<String> = links
|
||||
.iter()
|
||||
.take(MAX_REGISTRY_LINKS)
|
||||
@@ -397,20 +432,24 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_pin_list(
|
||||
pub async fn set_pin_list<S>(
|
||||
&self,
|
||||
keys: &Keys,
|
||||
keys: &S,
|
||||
community_id: &CommunityId,
|
||||
channel: &ChannelId,
|
||||
content: &str,
|
||||
head: Option<&EntityHead>,
|
||||
citation: Option<AuthorityCitation>,
|
||||
at_secs: u64,
|
||||
) -> Result<(Event, EntityHead)> {
|
||||
) -> Result<(Event, EntityHead)>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
self.publish(
|
||||
keys,
|
||||
Edition {
|
||||
@@ -422,6 +461,7 @@ impl ControlWriter {
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
|
||||
selection.head.map(|index| authorized[index])
|
||||
}
|
||||
|
||||
fn seal_edition(
|
||||
async fn seal_edition<S>(
|
||||
edition: &UnsignedEvent,
|
||||
owner: &Keys,
|
||||
owner: &S,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at_secs: u64,
|
||||
) -> Result<Event> {
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||
) -> Result<Event>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
|
||||
|
||||
let (wrap, _) = wrap_seal_with(
|
||||
&seal,
|
||||
@@ -731,15 +774,14 @@ fn seal_edition(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
use super::*;
|
||||
use crate::cord03::{self, build_message, seal_rumor};
|
||||
use crate::cord04::fold;
|
||||
use crate::cord04::pins;
|
||||
use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
|
||||
use crate::derive::{channel_group_key, grant_locator};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
use crate::state::CommunityState;
|
||||
use crate::{Extra, RoleId};
|
||||
|
||||
const AT: u64 = 1_700_000_000;
|
||||
@@ -768,70 +810,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genesis_reopens_for_a_second_holder() {
|
||||
let owner = Keys::generate();
|
||||
let community_metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
|
||||
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// Only what an invite hands over: the roots, the community id and the owner salt.
|
||||
let (read, signer) = holder(&minted);
|
||||
let editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
|
||||
assert_eq!(editions.len(), 2);
|
||||
|
||||
let community = &editions[0];
|
||||
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||
assert_eq!(community.author, owner.public_key());
|
||||
assert_eq!((community.version, community.prev), (1, None));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||
.expect("parses")
|
||||
.name,
|
||||
"coop"
|
||||
);
|
||||
|
||||
let channel = &editions[1];
|
||||
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||
|
||||
for edition in &editions {
|
||||
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||
assert_eq!(folded.head, Some(0));
|
||||
assert!(
|
||||
folded.anchored && !folded.gap,
|
||||
"genesis anchors at its floor"
|
||||
);
|
||||
}
|
||||
|
||||
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
save_state(&database, &state).await.expect("saves");
|
||||
let loaded = load_state(&database, &minted.identity.community_id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("present");
|
||||
|
||||
assert_eq!(loaded.community_root, minted.community_root);
|
||||
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||
assert_eq!(loaded.channels.len(), 1);
|
||||
assert_eq!(loaded.heads.len(), 2);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_channel_edits_reach_a_second_client() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -860,33 +842,31 @@ mod tests {
|
||||
.get(minted.channel_id.as_bytes())
|
||||
.expect("head");
|
||||
|
||||
let (community_wrap, _) = writer
|
||||
.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
None,
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
None,
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
&CommunityMetadata {
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..metadata("coop two")
|
||||
},
|
||||
Some(community_head),
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "lobby".to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
Some(channel_head),
|
||||
None,
|
||||
AT + 2,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
let mut edited = genesis_editions.clone();
|
||||
edited.extend(open_all(
|
||||
@@ -938,7 +918,7 @@ mod tests {
|
||||
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
|
||||
let owner = Keys::generate();
|
||||
let member = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -959,21 +939,20 @@ mod tests {
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let (role_wrap, _) = writer
|
||||
.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = writer
|
||||
.publish(
|
||||
let (role_wrap, _) = smol::block_on(writer.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::ROLE,
|
||||
entity: *role_id.as_bytes(),
|
||||
content: &role.to_content().expect("serializes"),
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (grant_wrap, _) = smol::block_on(
|
||||
writer.publish(
|
||||
&owner,
|
||||
Edition {
|
||||
subkind: vsk::GRANT,
|
||||
@@ -990,8 +969,9 @@ mod tests {
|
||||
citation: None,
|
||||
},
|
||||
AT + 2,
|
||||
)
|
||||
.expect("publishes");
|
||||
),
|
||||
)
|
||||
.expect("publishes");
|
||||
|
||||
let mut base = open_all(&minted.wraps, &read, &signer.pk());
|
||||
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
|
||||
@@ -1022,36 +1002,34 @@ mod tests {
|
||||
};
|
||||
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
|
||||
|
||||
let (uncited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (cited, _) = member_writer
|
||||
.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (uncited, _) = smol::block_on(member_writer.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: None,
|
||||
},
|
||||
AT + 3,
|
||||
))
|
||||
.expect("publishes");
|
||||
let (cited, _) = smol::block_on(member_writer.publish(
|
||||
&member,
|
||||
Edition {
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *community_id.as_bytes(),
|
||||
content: &content,
|
||||
head: Some(head),
|
||||
citation: Some(AuthorityCitation {
|
||||
entity: grant.entity,
|
||||
version: grant.version,
|
||||
hash: grant.self_hash,
|
||||
}),
|
||||
},
|
||||
AT + 4,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
// Uncited, the edit claims an authority the member never showed.
|
||||
let mut forged = base.clone();
|
||||
@@ -1086,7 +1064,7 @@ mod tests {
|
||||
#[test]
|
||||
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -1105,7 +1083,7 @@ mod tests {
|
||||
AT * 1_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
|
||||
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
|
||||
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
|
||||
.expect("opens")
|
||||
.0;
|
||||
@@ -1127,17 +1105,16 @@ mod tests {
|
||||
read: read.clone(),
|
||||
signer: signer.clone(),
|
||||
};
|
||||
let (pin_wrap, _) = writer
|
||||
.set_pin_list(
|
||||
&owner,
|
||||
&community_id,
|
||||
&channel,
|
||||
&content,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
)
|
||||
.expect("publishes");
|
||||
let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
|
||||
&owner,
|
||||
&community_id,
|
||||
&channel,
|
||||
&content,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes");
|
||||
|
||||
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
|
||||
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
|
||||
@@ -1172,7 +1149,7 @@ mod tests {
|
||||
#[test]
|
||||
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
|
||||
let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let owner_pk = owner.public_key();
|
||||
let (read, signer) = holder(&minted);
|
||||
@@ -1187,10 +1164,16 @@ mod tests {
|
||||
&owner_pk,
|
||||
&community_id,
|
||||
&open_all(
|
||||
&[writer
|
||||
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
|
||||
.expect("publishes")
|
||||
.0],
|
||||
&[smol::block_on(writer.set_community_metadata(
|
||||
&owner,
|
||||
&community_id,
|
||||
metadata,
|
||||
None,
|
||||
None,
|
||||
AT + 1,
|
||||
))
|
||||
.expect("publishes")
|
||||
.0],
|
||||
&writer.read,
|
||||
&writer.signer.pk(),
|
||||
),
|
||||
@@ -1226,8 +1209,7 @@ mod tests {
|
||||
.map(|_| Keys::generate().public_key())
|
||||
.collect();
|
||||
assert!(
|
||||
writer
|
||||
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
|
||||
smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
@@ -1242,20 +1224,19 @@ mod tests {
|
||||
assert!(grant.to_content().is_err());
|
||||
|
||||
assert!(
|
||||
writer
|
||||
.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "x".repeat(MAX_NAME_BYTES + 1),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
AT + 3,
|
||||
)
|
||||
.is_err()
|
||||
smol::block_on(writer.set_channel_metadata(
|
||||
&owner,
|
||||
&minted.channel_id,
|
||||
&ChannelMetadata {
|
||||
name: "x".repeat(MAX_NAME_BYTES + 1),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
},
|
||||
None,
|
||||
None,
|
||||
AT + 3,
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
|
||||
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms,
|
||||
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
|
||||
wrap_seal,
|
||||
};
|
||||
use crate::cord04::{
|
||||
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
|
||||
unique_tag, wrap_seal,
|
||||
};
|
||||
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
|
||||
pub use crate::cords::rumor::RumorError as ChatError;
|
||||
use crate::cords::rumor::{optional_citation, pubkey, tag, value};
|
||||
use crate::derive::channel_group_key;
|
||||
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
|
||||
|
||||
@@ -26,6 +25,9 @@ pub const KIND_TIMER_NOTICE: u16 = 1740;
|
||||
pub const KIND_WEBXDC: u16 = 3310;
|
||||
pub const KIND_TYPING: u16 = 23311;
|
||||
|
||||
pub const ROW_KINDS: [u16; 4] = [KIND_MESSAGE, KIND_FILE, KIND_COMMENT, KIND_TIMER_NOTICE];
|
||||
pub const SIDE_KINDS: [u16; 3] = [KIND_DELETE, KIND_REACTION, KIND_EDIT];
|
||||
|
||||
const TAG_QUOTE: &str = "q";
|
||||
const TAG_TARGET: &str = "e";
|
||||
const TAG_TARGET_KIND: &str = "k";
|
||||
@@ -36,42 +38,6 @@ const TAG_TARGET_AUTHOR: &str = "p";
|
||||
const TAG_EXPIRATION: &str = "expiration";
|
||||
const TAG_TIMER: &str = "timer";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ChatError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||
ExemptExpiration,
|
||||
}
|
||||
|
||||
impl fmt::Display for ChatError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ChatError::Stream(error) => write!(f, "stream: {error}"),
|
||||
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
|
||||
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
|
||||
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
|
||||
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
|
||||
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
|
||||
ChatError::ExemptExpiration => {
|
||||
write!(f, "a delete or timer notice must not carry an expiration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ChatError {}
|
||||
|
||||
impl From<StreamError> for ChatError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
ChatError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ReplyRef {
|
||||
@@ -163,7 +129,6 @@ pub fn build_message(
|
||||
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
||||
}
|
||||
|
||||
/// `parent` is the immediate parent; a `None` root means the parent is the thread's root.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_comment(
|
||||
author: PublicKey,
|
||||
@@ -180,12 +145,14 @@ pub fn build_comment(
|
||||
|
||||
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
|
||||
tags.push(reply_tag(TAG_ROOT, &root.reply));
|
||||
|
||||
if let Some(root_author) = root.reply.author {
|
||||
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
|
||||
}
|
||||
|
||||
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
|
||||
tags.push(reply_tag(TAG_TARGET, &parent.reply));
|
||||
|
||||
if let Some(parent_author) = parent.reply.author {
|
||||
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
|
||||
}
|
||||
@@ -292,19 +259,22 @@ pub fn build_typing(
|
||||
}
|
||||
|
||||
/// `ephemeral` picks the 21059 wrap, which relays must not store.
|
||||
pub fn seal_rumor(
|
||||
pub async fn seal_rumor<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
group: &GroupKey,
|
||||
author: &Keys,
|
||||
author: &S,
|
||||
ephemeral: bool,
|
||||
) -> Result<(Event, Keys), ChatError> {
|
||||
) -> Result<(Event, Keys), ChatError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if !is_chat_kind(kind) {
|
||||
return Err(ChatError::UnknownKind(kind));
|
||||
}
|
||||
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
|
||||
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
|
||||
let wrap_kind = if ephemeral {
|
||||
KIND_WRAP_EPHEMERAL
|
||||
} else {
|
||||
@@ -348,6 +318,20 @@ pub fn open(
|
||||
Ok((opened, chat))
|
||||
}
|
||||
|
||||
/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch.
|
||||
pub fn parse_rumor(rumor: &UnsignedEvent) -> Result<ChatRumor, ChatError> {
|
||||
let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)?
|
||||
.ok_or(ChatError::MissingTag(TAG_CHANNEL))?
|
||||
.parse()
|
||||
.map_err(|_| ChatError::BadTag(TAG_CHANNEL))?;
|
||||
|
||||
let epoch = unique_tag(rumor, TAG_EPOCH)?
|
||||
.ok_or(ChatError::MissingTag(TAG_EPOCH))
|
||||
.and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?;
|
||||
|
||||
typed(rumor, &channel, Epoch(epoch))
|
||||
}
|
||||
|
||||
/// `secret` is the `community_root` for a public channel, its own key for a private one.
|
||||
pub fn plane_keys(
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
@@ -576,16 +560,6 @@ fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16
|
||||
.map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(ChatError::BadTag(TAG_CITATION))
|
||||
}
|
||||
|
||||
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
|
||||
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
|
||||
return Ok(None);
|
||||
@@ -611,51 +585,16 @@ fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
|
||||
)
|
||||
}
|
||||
|
||||
fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, ChatError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(ChatError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
|
||||
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cord01::StreamError;
|
||||
|
||||
const SECRET: [u8; 32] = [0x2du8; 32];
|
||||
const AT: u64 = 1_700_000_000_417;
|
||||
@@ -674,7 +613,9 @@ mod tests {
|
||||
}
|
||||
|
||||
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
|
||||
seal_rumor(rumor, group, author, false).expect("seals").0
|
||||
smol::block_on(seal_rumor(rumor, group, author, false))
|
||||
.expect("seals")
|
||||
.0
|
||||
}
|
||||
|
||||
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
|
||||
@@ -949,7 +890,8 @@ mod tests {
|
||||
|
||||
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
|
||||
// chat rumor however well-formed it looks.
|
||||
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
|
||||
let seal =
|
||||
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
|
||||
let (wrap, _) = wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
@@ -971,7 +913,7 @@ mod tests {
|
||||
AT,
|
||||
);
|
||||
assert!(matches!(
|
||||
seal_rumor(&ghost, &group, &alice, false),
|
||||
smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
|
||||
Err(ChatError::UnknownKind(3300))
|
||||
));
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ fn signing_bytes(
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
@@ -246,14 +246,14 @@ impl From<&ParsedEdition> for EditionMeta {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
|
||||
for (index, edition) in editions.iter().enumerate() {
|
||||
@@ -311,7 +311,7 @@ pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>)
|
||||
}
|
||||
|
||||
/// The highest version overall, ignoring contiguity.
|
||||
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
||||
editions
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
@@ -583,7 +583,7 @@ mod tests {
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
|
||||
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
|
||||
|
||||
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
|
||||
}
|
||||
@@ -699,7 +699,7 @@ mod tests {
|
||||
AT_MS + 5_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
|
||||
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
|
||||
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
|
||||
@@ -718,7 +718,8 @@ mod tests {
|
||||
AT_MS + 6_000,
|
||||
None,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
|
||||
let (wrap, _) =
|
||||
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
|
||||
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
|
||||
@@ -805,7 +806,8 @@ mod tests {
|
||||
None,
|
||||
AT_MS + 1_000,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
|
||||
let (wrap, _) =
|
||||
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
|
||||
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
|
||||
|
||||
assert_eq!(
|
||||
|
||||
@@ -100,7 +100,7 @@ pub struct Role {
|
||||
}
|
||||
|
||||
impl Role {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
serde_json::from_str(content).ok()
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ pub struct Grant {
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
pub fn parse(content: &str) -> Option<Self> {
|
||||
fn parse(content: &str) -> Option<Self> {
|
||||
serde_json::from_str(content).ok()
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Grant {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
|
||||
fn parse_banlist(content: &str) -> Option<Vec<PublicKey>> {
|
||||
let entries: Vec<String> = serde_json::from_str(content).ok()?;
|
||||
let mut banned = Vec::with_capacity(entries.len());
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
|
||||
use crate::cord02::list::{canonical, union};
|
||||
use crate::cord02::{ImageRef, MAX_RELAYS};
|
||||
use crate::cord04::{TAG_SUBKIND, vsk};
|
||||
use crate::derive::{TOKEN_LEN, verify_community_id};
|
||||
use crate::utils::{canonical, union};
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
pub const KIND_BUNDLE: u16 = 33301;
|
||||
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_direct_invite(
|
||||
inviter: &Keys,
|
||||
pub async fn build_direct_invite<S>(
|
||||
inviter: &S,
|
||||
recipient: &PublicKey,
|
||||
invite: &CommunityInvite,
|
||||
) -> Result<Event, InviteError> {
|
||||
) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
|
||||
{
|
||||
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 author = inviter.get_public_key_async().await.map_err(crypto_error)?;
|
||||
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
|
||||
|
||||
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
|
||||
|
||||
@@ -469,15 +472,22 @@ pub fn build_direct_invite(
|
||||
|
||||
GiftWrapBuilder::new(*recipient, rumor)
|
||||
.extra_tags(tags)
|
||||
.finalize(inviter)
|
||||
.finalize_async(inviter)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub fn unwrap_direct_invite(
|
||||
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
|
||||
pub async fn unwrap_direct_invite<S>(
|
||||
wrap: &Event,
|
||||
recipient: &Keys,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError> {
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
|
||||
recipient: &S,
|
||||
) -> Result<(PublicKey, CommunityInvite), InviteError>
|
||||
where
|
||||
S: AsyncNip44,
|
||||
{
|
||||
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
|
||||
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
|
||||
return Err(InviteError::BadEvent("rumor is not a direct invite"));
|
||||
@@ -590,25 +600,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
|
||||
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
|
||||
{
|
||||
list.fits()?;
|
||||
|
||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||
let content = cord01::seal_to_self(keys, json.as_bytes())?;
|
||||
let content = cord01::seal_to_self(keys, &json).await?;
|
||||
|
||||
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
||||
.finalize(keys)
|
||||
.finalize_async(keys)
|
||||
.await
|
||||
.map_err(crypto_error)
|
||||
}
|
||||
|
||||
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
|
||||
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
if event.kind.as_u16() != KIND_INVITE_LIST {
|
||||
return Err(InviteError::Kind(event.kind.as_u16()));
|
||||
}
|
||||
|
||||
let json = cord01::open_to_self(keys, &event.content)?;
|
||||
let json = cord01::open_to_self(keys, &event.content).await?;
|
||||
|
||||
serde_json::from_slice(&json).map_err(json_error)
|
||||
serde_json::from_str(&json).map_err(json_error)
|
||||
}
|
||||
|
||||
/// An entry is immutable once minted, so two copies should agree.
|
||||
@@ -920,7 +937,12 @@ mod tests {
|
||||
let recipient = Keys::generate();
|
||||
let invite = bundle();
|
||||
|
||||
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
|
||||
let wrap = smol::block_on(build_direct_invite(
|
||||
&inviter,
|
||||
&recipient.public_key(),
|
||||
&invite,
|
||||
))
|
||||
.expect("builds");
|
||||
assert_eq!(wrap.kind, Kind::GiftWrap);
|
||||
assert_ne!(
|
||||
wrap.pubkey,
|
||||
@@ -932,13 +954,14 @@ mod tests {
|
||||
"the k tag is what makes an invite indexable"
|
||||
);
|
||||
|
||||
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
|
||||
let (sender, opened) =
|
||||
smol::block_on(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());
|
||||
assert!(smol::block_on(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::cord03::KIND_MESSAGE), "hello")
|
||||
@@ -947,7 +970,7 @@ mod tests {
|
||||
.finalize(&recipient)
|
||||
.expect("wraps");
|
||||
assert!(matches!(
|
||||
unwrap_direct_invite(&wrap, &recipient),
|
||||
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
|
||||
Err(InviteError::BadEvent(_))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr::nips::nip44::v2::ConversationKey;
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent};
|
||||
use data_encoding::{BASE64, HEXLOWER};
|
||||
use nostr_sdk::prelude::{
|
||||
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
|
||||
@@ -298,34 +299,48 @@ pub fn blob_locator(
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_blob(
|
||||
rotator: &Keys,
|
||||
pub async fn build_blob<S>(
|
||||
rotator: &S,
|
||||
recipient: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
new_key: &[u8; 32],
|
||||
control_pk: Option<&[u8; 32]>,
|
||||
control_root: Option<&[u8; 32]>,
|
||||
) -> Result<RekeyBlob, RekeyError> {
|
||||
) -> Result<RekeyBlob, RekeyError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
|
||||
{
|
||||
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?;
|
||||
let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?;
|
||||
|
||||
let wrapped = rotator
|
||||
.nip44_encrypt_async(recipient, &BASE64.encode(&plaintext))
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
|
||||
Ok(RekeyBlob {
|
||||
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch),
|
||||
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?,
|
||||
locator: blob_locator(&rotator_pk, recipient, scope, epoch),
|
||||
wrapped,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_blob(
|
||||
recipient: &Keys,
|
||||
pub async fn open_blob<S>(
|
||||
recipient: &S,
|
||||
rotator: &PublicKey,
|
||||
scope: RekeyScope,
|
||||
epoch: Epoch,
|
||||
blob: &RekeyBlob,
|
||||
community_id: &CommunityId,
|
||||
) -> Result<KeyDelivery, RekeyError> {
|
||||
let conversation =
|
||||
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?;
|
||||
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?;
|
||||
) -> Result<KeyDelivery, RekeyError>
|
||||
where
|
||||
S: AsyncNip44 + ?Sized,
|
||||
{
|
||||
let text = recipient
|
||||
.nip44_decrypt_async(rotator, &blob.wrapped)
|
||||
.await
|
||||
.map_err(crypto_error)?;
|
||||
let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?;
|
||||
|
||||
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
|
||||
}
|
||||
@@ -341,15 +356,6 @@ pub fn find_my_blobs<'a>(
|
||||
blobs.iter().filter(move |blob| blob.locator == wanted)
|
||||
}
|
||||
|
||||
fn seal_to(
|
||||
secret: &SecretKey,
|
||||
recipient: &PublicKey,
|
||||
plaintext: &[u8],
|
||||
) -> Result<String, RekeyError> {
|
||||
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
|
||||
Ok(cord01::seal_bytes(&conversation, plaintext)?)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RekeyChunk {
|
||||
pub rotator: PublicKey,
|
||||
@@ -528,6 +534,43 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The material one rotation delivers, by scope.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RotationPlan {
|
||||
/// A refounding: a fresh root, with the root that reads and signs under it.
|
||||
Base(Refounding),
|
||||
/// A channel: a fresh channel key, and nothing beside it.
|
||||
Channel { epoch: Epoch, new_key: [u8; 32] },
|
||||
}
|
||||
|
||||
impl RotationPlan {
|
||||
pub fn epoch(&self) -> Epoch {
|
||||
match self {
|
||||
Self::Base(refounding) => refounding.epoch,
|
||||
Self::Channel { epoch, .. } => *epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The secret the rotation delivers, which becomes the scope's new read key.
|
||||
pub fn new_key(&self) -> [u8; 32] {
|
||||
match self {
|
||||
Self::Base(refounding) => refounding.new_root,
|
||||
Self::Channel { new_key, .. } => *new_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint what a rotation of `scope` delivers at `epoch`.
|
||||
pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result<RotationPlan> {
|
||||
match scope {
|
||||
RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)),
|
||||
RekeyScope::Channel(_) => Ok(RotationPlan::Channel {
|
||||
epoch,
|
||||
new_key: random_32()?,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Carries the settled heads across a refounding.
|
||||
pub fn compact(
|
||||
seals: &[Event],
|
||||
@@ -598,8 +641,8 @@ pub fn build_rekey_rumor(
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_rekey_chunks(
|
||||
rotator: &Keys,
|
||||
pub async fn build_rekey_chunks<S>(
|
||||
rotator: &S,
|
||||
group: &GroupKey,
|
||||
scope: RekeyScope,
|
||||
new_epoch: Epoch,
|
||||
@@ -609,7 +652,11 @@ pub fn build_rekey_chunks(
|
||||
citation: Option<&AuthorityCitation>,
|
||||
severed: bool,
|
||||
at_secs: u64,
|
||||
) -> Result<Vec<Event>, RekeyError> {
|
||||
) -> Result<Vec<Event>, RekeyError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
|
||||
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
|
||||
|
||||
if groups.is_empty() {
|
||||
@@ -621,7 +668,7 @@ pub fn build_rekey_chunks(
|
||||
|
||||
for (index, group_blobs) in groups.into_iter().enumerate() {
|
||||
let rumor = build_rekey_rumor(
|
||||
rotator.public_key(),
|
||||
rotator_key,
|
||||
scope,
|
||||
new_epoch,
|
||||
prev_epoch,
|
||||
@@ -633,7 +680,7 @@ pub fn build_rekey_chunks(
|
||||
at_secs,
|
||||
)?;
|
||||
|
||||
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?;
|
||||
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
|
||||
let (wrap, _) = cord01::wrap_seal(
|
||||
&seal,
|
||||
group,
|
||||
@@ -731,14 +778,17 @@ pub fn dissolved_tombstone_rumor(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn seal_dissolved(
|
||||
pub async fn seal_dissolved<S>(
|
||||
rumor: &UnsignedEvent,
|
||||
community_id: &CommunityId,
|
||||
owner: &Keys,
|
||||
owner: &S,
|
||||
at_secs: u64,
|
||||
) -> Result<Event, RekeyError> {
|
||||
) -> Result<Event, RekeyError>
|
||||
where
|
||||
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
|
||||
{
|
||||
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
|
||||
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
|
||||
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
|
||||
let (wrap, _) = cord01::wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
@@ -858,6 +908,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
|
||||
mod tests {
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use nostr_sdk::prelude::Keys;
|
||||
|
||||
use super::*;
|
||||
use crate::cord01::KIND_WRAP;
|
||||
use crate::cord02::{
|
||||
@@ -912,16 +964,16 @@ mod tests {
|
||||
let scope = RekeyScope::Channel(channel());
|
||||
|
||||
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
keys,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
blob,
|
||||
&community_id,
|
||||
)
|
||||
))
|
||||
};
|
||||
let blob = build_blob(
|
||||
let blob = smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -929,7 +981,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(
|
||||
@@ -962,7 +1014,7 @@ mod tests {
|
||||
.to_bytes();
|
||||
|
||||
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
RekeyScope::Base,
|
||||
@@ -970,7 +1022,7 @@ mod tests {
|
||||
&key,
|
||||
pk,
|
||||
root,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
|
||||
@@ -1043,7 +1095,7 @@ mod tests {
|
||||
let community_id = community();
|
||||
|
||||
let blob_for = |recipient: &Keys, key: [u8; 32]| {
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&recipient.public_key(),
|
||||
scope,
|
||||
@@ -1051,7 +1103,7 @@ mod tests {
|
||||
&key,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
};
|
||||
let mine = blob_for(&me, [0xAA; 32]);
|
||||
@@ -1059,7 +1111,7 @@ mod tests {
|
||||
|
||||
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
|
||||
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1070,7 +1122,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
assert_eq!(chunks.len(), 1);
|
||||
|
||||
@@ -1129,14 +1181,14 @@ mod tests {
|
||||
.next()
|
||||
.expect("located");
|
||||
assert_eq!(
|
||||
open_blob(
|
||||
smol::block_on(open_blob(
|
||||
&me,
|
||||
&rotator.public_key(),
|
||||
scope,
|
||||
epoch,
|
||||
located,
|
||||
&community_id
|
||||
)
|
||||
))
|
||||
.expect("opens")
|
||||
.new_key,
|
||||
[0xAA; 32]
|
||||
@@ -1269,26 +1321,26 @@ mod tests {
|
||||
content: String,
|
||||
at_secs: u64,
|
||||
) -> Event {
|
||||
writer
|
||||
.publish(
|
||||
owner,
|
||||
Edition {
|
||||
subkind,
|
||||
entity,
|
||||
content: &content,
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
)
|
||||
.expect("publishes")
|
||||
.0
|
||||
smol::block_on(writer.publish(
|
||||
owner,
|
||||
Edition {
|
||||
subkind,
|
||||
entity,
|
||||
content: &content,
|
||||
head: None,
|
||||
citation: None,
|
||||
},
|
||||
at_secs,
|
||||
))
|
||||
.expect("publishes")
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
|
||||
let owner = Keys::generate();
|
||||
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints");
|
||||
let minted =
|
||||
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
|
||||
let community_id = minted.identity.community_id;
|
||||
let read =
|
||||
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
|
||||
@@ -1413,7 +1465,7 @@ mod tests {
|
||||
.map(|_| {
|
||||
let member = Keys::generate();
|
||||
|
||||
build_blob(
|
||||
smol::block_on(build_blob(
|
||||
&rotator,
|
||||
&member.public_key(),
|
||||
scope,
|
||||
@@ -1421,12 +1473,12 @@ mod tests {
|
||||
&[0xCD; 32],
|
||||
None,
|
||||
None,
|
||||
)
|
||||
))
|
||||
.expect("builds")
|
||||
})
|
||||
.collect();
|
||||
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1437,7 +1489,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(chunks.len(), 1, "a full send chunk is one event");
|
||||
@@ -1452,7 +1504,7 @@ mod tests {
|
||||
wrapped: "x".to_owned(),
|
||||
});
|
||||
|
||||
let chunks = build_rekey_chunks(
|
||||
let chunks = smol::block_on(build_rekey_chunks(
|
||||
&rotator,
|
||||
&group,
|
||||
scope,
|
||||
@@ -1463,7 +1515,7 @@ mod tests {
|
||||
None,
|
||||
false,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("builds");
|
||||
|
||||
assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
|
||||
@@ -1485,8 +1537,13 @@ mod tests {
|
||||
content: "{}",
|
||||
at_secs: AT,
|
||||
});
|
||||
let seal =
|
||||
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals");
|
||||
let seal = smol::block_on(cord01::build_seal(
|
||||
&rumor,
|
||||
SealForm::Plaintext,
|
||||
&prior_read,
|
||||
&owner,
|
||||
))
|
||||
.expect("seals");
|
||||
|
||||
let refounding = plan_refounding(Epoch(1)).expect("plans");
|
||||
let read = refounding.read(&community_id).expect("derives");
|
||||
@@ -1507,8 +1564,13 @@ mod tests {
|
||||
assert_eq!(reopened.author, owner.public_key());
|
||||
|
||||
// Only a plaintext seal can be carried forward.
|
||||
let encrypted =
|
||||
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals");
|
||||
let encrypted = smol::block_on(cord01::build_seal(
|
||||
&rumor,
|
||||
SealForm::Encrypted,
|
||||
&prior_read,
|
||||
&owner,
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(matches!(
|
||||
compact(&[encrypted], &read, &signer, AT + 1),
|
||||
Err(RekeyError::Stream(StreamError::NotRewrappable))
|
||||
@@ -1527,7 +1589,8 @@ mod tests {
|
||||
};
|
||||
|
||||
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
|
||||
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals");
|
||||
let wrap =
|
||||
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
|
||||
|
||||
assert!(verify_dissolved(&wrap, &identity));
|
||||
assert_eq!(
|
||||
@@ -1538,18 +1601,18 @@ mod tests {
|
||||
// Anyone holding the community id finds the address, but only the committed
|
||||
// owner's signature counts.
|
||||
let impostor = Keys::generate();
|
||||
let forged = seal_dissolved(
|
||||
let forged = smol::block_on(seal_dissolved(
|
||||
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
|
||||
&community_id,
|
||||
&impostor,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(!verify_dissolved(&forged, &identity));
|
||||
|
||||
// The spec's all-zero `eid` is refused: it would let one owner's genuine
|
||||
// tombstone be re-wrapped at another of their communities and kill it.
|
||||
let zeroed = seal_dissolved(
|
||||
let zeroed = smol::block_on(seal_dissolved(
|
||||
&cord01::build_rumor_secs(
|
||||
KIND_CONTROL,
|
||||
owner.public_key(),
|
||||
@@ -1563,7 +1626,7 @@ mod tests {
|
||||
&community_id,
|
||||
&owner,
|
||||
AT,
|
||||
)
|
||||
))
|
||||
.expect("seals");
|
||||
assert!(matches!(
|
||||
open_dissolved(&zeroed, &community_id),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and
|
||||
//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file.
|
||||
mod rumor;
|
||||
|
||||
pub mod cord01;
|
||||
pub mod cord02;
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
use std::fmt;
|
||||
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use crate::cord01::StreamError;
|
||||
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
|
||||
use crate::decode_hex_32;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RumorError {
|
||||
Stream(StreamError),
|
||||
NotEncryptedSealed,
|
||||
UnknownKind(u16),
|
||||
MissingTag(&'static str),
|
||||
DuplicateTag(&'static str),
|
||||
BadTag(&'static str),
|
||||
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||
ExemptExpiration,
|
||||
}
|
||||
|
||||
impl fmt::Display for RumorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
RumorError::Stream(error) => write!(f, "stream: {error}"),
|
||||
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
|
||||
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
|
||||
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
|
||||
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
|
||||
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
|
||||
RumorError::ExemptExpiration => {
|
||||
write!(f, "a delete or timer notice must not carry an expiration")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RumorError {}
|
||||
|
||||
impl From<StreamError> for RumorError {
|
||||
fn from(error: StreamError) -> Self {
|
||||
RumorError::Stream(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tag<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, RumorError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for candidate in rumor.tags.iter() {
|
||||
let fields = candidate.as_slice();
|
||||
|
||||
if fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(RumorError::DuplicateTag(name));
|
||||
}
|
||||
|
||||
found = Some(fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn required<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<&'a [String], RumorError> {
|
||||
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
|
||||
}
|
||||
|
||||
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
|
||||
fields
|
||||
.get(1)
|
||||
.map(String::as_str)
|
||||
.ok_or(RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
|
||||
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
|
||||
|
||||
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
|
||||
}
|
||||
|
||||
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
|
||||
let Some(fields) = tag(rumor, TAG_CITATION)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
citation_from(fields)
|
||||
.map(Some)
|
||||
.ok_or(RumorError::BadTag(TAG_CITATION))
|
||||
}
|
||||
@@ -2,11 +2,11 @@ mod cords;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
pub mod store;
|
||||
pub mod state;
|
||||
|
||||
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
|
||||
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
||||
pub use utils::derive::{self, GroupKey};
|
||||
|
||||
pub(crate) use types::Extra;
|
||||
pub(crate) use utils::{decode_hex_32, decode_hex_lower, fill_random, random_32};
|
||||
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
|
||||
pub use utils::decode_hex_32;
|
||||
pub use utils::derive::{self, GroupKey};
|
||||
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use anyhow::Result;
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
|
||||
use crate::cord02::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::cord05::ChannelGrant;
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
|
||||
|
||||
/// The `concord/` namespace for locally-keyed documents.
|
||||
pub const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
/// A key epoch the client still holds, retained so history stays readable.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeldKey {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The publish time of the rotation that superseded this key.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
/// A community root epoch the client still holds, retained for the same reason.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HeldRoot {
|
||||
pub epoch: Epoch,
|
||||
pub key: [u8; 32],
|
||||
/// The epoch's Control Plane signer, when the rotation delivered one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_pk: Option<PublicKey>,
|
||||
/// The publish time of the rotation that superseded this root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retired_at: Option<Timestamp>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
/// The channel's read secret when the member was granted it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub key: Option<[u8; 32]>,
|
||||
/// Keys this one superseded, retained so a rotation never blanks history.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub priors: Vec<HeldKey>,
|
||||
}
|
||||
|
||||
impl ChannelKeyRef {
|
||||
/// The write coordinate: only the current epoch is ever published under.
|
||||
pub fn current(&self) -> Option<(Epoch, [u8; 32])> {
|
||||
self.key.map(|key| (self.epoch, key))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a channel's history sync has reached, in wrap times.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelCursor {
|
||||
/// The newest wrap ingested, so a live subscription knows where to resume.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub newest: Option<Timestamp>,
|
||||
/// The oldest wrap paged back to, so the next round resumes below it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oldest: Option<Timestamp>,
|
||||
/// History verifiably swept to the bottom.
|
||||
#[serde(default)]
|
||||
pub exhausted: bool,
|
||||
}
|
||||
|
||||
impl ChannelCursor {
|
||||
pub fn merge(self, round: Self) -> Self {
|
||||
Self {
|
||||
newest: later(self.newest, round.newest),
|
||||
oldest: earlier(self.oldest, round.oldest),
|
||||
exhausted: self.exhausted || round.exhausted,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.max(round)),
|
||||
(held, None) => held,
|
||||
(None, round) => round,
|
||||
}
|
||||
}
|
||||
|
||||
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
|
||||
match (held, round) {
|
||||
(Some(held), Some(round)) => Some(held.min(round)),
|
||||
(held, None) => held,
|
||||
(None, round) => round,
|
||||
}
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelKeyRef>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub heads: Vec<EntityHead>,
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub banned: BTreeSet<PublicKey>,
|
||||
/// Where each channel's history sync has reached.
|
||||
#[serde(
|
||||
default,
|
||||
rename = "channel_cursors",
|
||||
skip_serializing_if = "BTreeMap::is_empty"
|
||||
)]
|
||||
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
|
||||
/// Root epochs the community has rotated past that this client still holds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub held_roots: Vec<HeldRoot>,
|
||||
/// The epoch a channel rotation removed us at.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
|
||||
/// The npubs whose rotation minted an epoch of this community we verified.
|
||||
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
|
||||
pub refounders: BTreeSet<PublicKey>,
|
||||
/// The base epoch we were excluded at.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub removed_at: Option<Epoch>,
|
||||
/// A complete rotation ahead of our epoch predates our join and carries no blob.
|
||||
#[serde(default)]
|
||||
pub stranded: bool,
|
||||
#[serde(default)]
|
||||
pub dissolved: bool,
|
||||
/// When this community joined the member's list, in milliseconds.
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
|
||||
impl CommunityState {
|
||||
pub fn from_genesis(
|
||||
genesis: &CommunityGenesis,
|
||||
editions: &[ParsedEdition],
|
||||
added_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
let mut name = None;
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
});
|
||||
|
||||
match edition.subkind.as_str() {
|
||||
vsk::COMMUNITY_METADATA => {
|
||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||
relays.extend(
|
||||
metadata
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
name = label(&metadata.name);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
channels.push(ChannelKeyRef {
|
||||
id: ChannelId::from_bytes(edition.entity),
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
key: None,
|
||||
priors: Vec::new(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let control_pks = BTreeMap::from([(
|
||||
ROOT_EPOCH.0,
|
||||
control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)?
|
||||
.pk(),
|
||||
)]);
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
name,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_root: Some(genesis.control_root),
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
heads,
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
|
||||
let control_pks = match material.control_pk {
|
||||
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
|
||||
None => BTreeMap::new(),
|
||||
};
|
||||
|
||||
let mut channels = Vec::with_capacity(material.channels.len());
|
||||
|
||||
for grant in &material.channels {
|
||||
let key = match &grant.key {
|
||||
Some(key) => Some(decode_hex_32(key)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
channels.push(ChannelKeyRef {
|
||||
id: grant.id,
|
||||
name: grant.name.clone(),
|
||||
private: key.is_some(),
|
||||
epoch: grant.epoch,
|
||||
key,
|
||||
priors: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
id: material.community_id,
|
||||
name: label(&material.name),
|
||||
owner: material.owner,
|
||||
owner_salt: decode_hex_32(&material.owner_salt)?,
|
||||
community_root: decode_hex_32(&material.community_root)?,
|
||||
root_epoch: material.root_epoch,
|
||||
control_root: match &material.control_root {
|
||||
Some(root) => Some(decode_hex_32(root)?),
|
||||
None => None,
|
||||
},
|
||||
control_pks,
|
||||
channels,
|
||||
relays: material
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
/// Every root epoch we hold, the current one first.
|
||||
pub fn roots(&self) -> Vec<HeldRoot> {
|
||||
let mut roots = Vec::with_capacity(self.held_roots.len() + 1);
|
||||
roots.push(HeldRoot {
|
||||
epoch: self.root_epoch,
|
||||
key: self.community_root,
|
||||
control_pk: self.control_pks.get(&self.root_epoch.0).copied(),
|
||||
retired_at: None,
|
||||
});
|
||||
roots.extend(self.held_roots.iter().copied());
|
||||
roots
|
||||
}
|
||||
|
||||
/// Every secret held for a channel, newest epoch first.
|
||||
pub fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
|
||||
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
if held.private {
|
||||
let mut keys: Vec<HeldKey> = held
|
||||
.key
|
||||
.map(|key| HeldKey {
|
||||
epoch: held.epoch,
|
||||
key,
|
||||
retired_at: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
keys.extend(held.priors.iter().copied());
|
||||
return keys;
|
||||
}
|
||||
|
||||
self.roots()
|
||||
.into_iter()
|
||||
.map(|root| HeldKey {
|
||||
epoch: root.epoch,
|
||||
key: root.key,
|
||||
retired_at: root.retired_at,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether a channel rotation removed us at or after `epoch`.
|
||||
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
|
||||
self.channel_cuts
|
||||
.get(channel)
|
||||
.is_some_and(|cut| epoch <= *cut)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
self.heads
|
||||
.iter()
|
||||
.map(|head| (head.entity, head.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||
self.heads = fold.floors.values().cloned().collect();
|
||||
self.banned = fold.banned.clone();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
|
||||
if let Some(name) = label(&community.name) {
|
||||
self.name = Some(name);
|
||||
}
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
if metadata.deleted.unwrap_or(false) {
|
||||
self.channels.retain(|channel| channel.id != *id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||
Some(channel) => {
|
||||
channel.name = metadata.name.clone();
|
||||
|
||||
if !metadata.private {
|
||||
channel.private = false;
|
||||
}
|
||||
}
|
||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||
id: *id,
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
key: None,
|
||||
priors: Vec::new(),
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
|
||||
let material = JoinMaterial {
|
||||
community_id: state.id,
|
||||
owner: state.owner,
|
||||
owner_salt: HEXLOWER.encode(&state.owner_salt),
|
||||
community_root: HEXLOWER.encode(&state.community_root),
|
||||
root_epoch: state.root_epoch,
|
||||
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
|
||||
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
|
||||
channels: state
|
||||
.channels
|
||||
.iter()
|
||||
.map(|channel| ChannelGrant {
|
||||
id: channel.id,
|
||||
key: channel.key.map(|key| HEXLOWER.encode(&key)),
|
||||
epoch: channel.epoch,
|
||||
name: channel.name.clone(),
|
||||
extra: Extra::default(),
|
||||
})
|
||||
.collect(),
|
||||
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
|
||||
name: name.to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
CommunityListEntry {
|
||||
community_id: state.id,
|
||||
seed: material.clone(),
|
||||
current: material,
|
||||
added_at: state.added_at_ms,
|
||||
extra: Extra::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(name: &str) -> Option<String> {
|
||||
let trimmed = name.trim();
|
||||
(!trimmed.is_empty()).then(|| trimmed.to_owned())
|
||||
}
|
||||
|
||||
/// The local document key a community's state is stored under.
|
||||
pub fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cursor_merge_only_moves_forward_and_never_seals() {
|
||||
let held = ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_000)),
|
||||
oldest: Some(Timestamp::from_secs(5_000)),
|
||||
exhausted: false,
|
||||
};
|
||||
|
||||
// An incomplete round reports nothing and moves neither bound.
|
||||
assert_eq!(held.merge(ChannelCursor::default()), held);
|
||||
|
||||
let merged = held.merge(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
});
|
||||
assert_eq!(
|
||||
merged,
|
||||
ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(2_000)),
|
||||
oldest: Some(Timestamp::from_secs(3_000)),
|
||||
exhausted: true,
|
||||
}
|
||||
);
|
||||
|
||||
// A later round that learned less cannot walk either bound back.
|
||||
assert_eq!(
|
||||
merged.merge(ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_500)),
|
||||
oldest: Some(Timestamp::from_secs(4_000)),
|
||||
exhausted: false,
|
||||
}),
|
||||
merged
|
||||
);
|
||||
}
|
||||
|
||||
/// A cursor stored when the boundaries were milliseconds must not be read as
|
||||
/// seconds. The key it was stored under is gone, so the document's counters
|
||||
/// are ignored and the channel re-syncs rather than being sealed off by an
|
||||
/// `exhausted` that outlived the bounds it was earned against.
|
||||
#[test]
|
||||
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
|
||||
let channel = ChannelId::from_bytes([0x9c; 32]);
|
||||
let cursor = ChannelCursor {
|
||||
newest: Some(Timestamp::from_secs(1_700_000_000)),
|
||||
exhausted: true,
|
||||
..ChannelCursor::default()
|
||||
};
|
||||
let mut state = CommunityState {
|
||||
id: CommunityId::from_bytes([0x42; 32]),
|
||||
name: None,
|
||||
owner: Keys::generate().public_key(),
|
||||
owner_salt: [0x01; 32],
|
||||
community_root: [0x02; 32],
|
||||
root_epoch: Epoch(0),
|
||||
control_root: None,
|
||||
control_pks: BTreeMap::new(),
|
||||
channels: Vec::new(),
|
||||
relays: Vec::new(),
|
||||
heads: Vec::new(),
|
||||
banned: BTreeSet::new(),
|
||||
cursors: BTreeMap::new(),
|
||||
held_roots: Vec::new(),
|
||||
channel_cuts: BTreeMap::new(),
|
||||
refounders: BTreeSet::new(),
|
||||
removed_at: None,
|
||||
stranded: false,
|
||||
dissolved: false,
|
||||
added_at_ms: 7,
|
||||
};
|
||||
|
||||
// The document a version that stored milliseconds wrote: its own key,
|
||||
// and boundaries padded by a thousand.
|
||||
let mut stored = serde_json::Map::new();
|
||||
stored.insert(
|
||||
channel.to_hex(),
|
||||
serde_json::json!({
|
||||
"newest_ms": 1_700_000_000_000u64,
|
||||
"oldest_ms": 1_699_999_000_000u64,
|
||||
"exhausted": true
|
||||
}),
|
||||
);
|
||||
|
||||
let mut legacy = serde_json::to_value(&state).expect("serializes");
|
||||
legacy
|
||||
.as_object_mut()
|
||||
.expect("a document")
|
||||
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
|
||||
|
||||
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
|
||||
|
||||
assert!(
|
||||
read.cursors.is_empty(),
|
||||
"a millisecond cursor is not a seconds cursor"
|
||||
);
|
||||
|
||||
// A typed cursor still round-trips under the key it is written with.
|
||||
state.cursors.insert(channel, cursor);
|
||||
|
||||
let document = serde_json::to_value(&state).expect("serializes");
|
||||
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
|
||||
|
||||
assert_eq!(read.cursors.get(&channel), Some(&cursor));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
|
||||
let owner = Keys::generate().public_key();
|
||||
let control_pk = Keys::generate().public_key();
|
||||
let staff = ChannelId::from_bytes([0x9c; 32]);
|
||||
let general = ChannelId::from_bytes([0x9d; 32]);
|
||||
|
||||
let material = JoinMaterial {
|
||||
community_id: CommunityId::from_bytes([0x42; 32]),
|
||||
owner,
|
||||
owner_salt: "01".repeat(32),
|
||||
community_root: "02".repeat(32),
|
||||
root_epoch: Epoch(3),
|
||||
control_pk: Some(control_pk),
|
||||
control_root: Some("03".repeat(32)),
|
||||
channels: vec![
|
||||
ChannelGrant {
|
||||
id: staff,
|
||||
key: Some("04".repeat(32)),
|
||||
epoch: Epoch(2),
|
||||
name: "staff".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
ChannelGrant {
|
||||
id: general,
|
||||
key: None,
|
||||
epoch: Epoch(0),
|
||||
name: "general".to_owned(),
|
||||
extra: Extra::default(),
|
||||
},
|
||||
],
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
name: "Room".to_owned(),
|
||||
extra: Extra::default(),
|
||||
};
|
||||
|
||||
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
|
||||
|
||||
assert_eq!(state.id, material.community_id);
|
||||
assert_eq!(state.owner, owner);
|
||||
assert_eq!(state.owner_salt, [0x01; 32]);
|
||||
assert_eq!(state.community_root, [0x02; 32]);
|
||||
assert_eq!(state.root_epoch, Epoch(3));
|
||||
assert_eq!(state.control_root, Some([0x03; 32]));
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
assert!(
|
||||
state.heads.is_empty(),
|
||||
"the first control fold fills the heads"
|
||||
);
|
||||
assert!(state.banned.is_empty());
|
||||
assert!(!state.dissolved);
|
||||
assert_eq!(state.relays.len(), 1);
|
||||
assert_eq!(state.added_at_ms, 7);
|
||||
|
||||
// A granted key lands on the channel and makes it private; a grant with
|
||||
// no key is a public channel.
|
||||
let granted = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == staff)
|
||||
.expect("staff");
|
||||
assert!(granted.private);
|
||||
assert_eq!(granted.key, Some([0x04; 32]));
|
||||
assert_eq!(granted.epoch, Epoch(2));
|
||||
assert_eq!(granted.name, "staff");
|
||||
|
||||
let public = state
|
||||
.channels
|
||||
.iter()
|
||||
.find(|c| c.id == general)
|
||||
.expect("general");
|
||||
assert!(!public.private);
|
||||
assert_eq!(public.key, None);
|
||||
|
||||
// A member who is not staff carries no control_root, but reading needs no
|
||||
// secret: the address rides in the material either way.
|
||||
let mut member = material.clone();
|
||||
member.control_root = None;
|
||||
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
|
||||
assert_eq!(state.control_root, None);
|
||||
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
|
||||
}
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::cord01::{KIND_WRAP_EPHEMERAL, OpenedStream};
|
||||
use crate::cord02::{
|
||||
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
|
||||
};
|
||||
use crate::cord03::{self, ChatRumor, plane_keys};
|
||||
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
const MAX_PAGES: usize = 8;
|
||||
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
|
||||
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
const WRAP_TAG: &str = "e";
|
||||
const KIND_TAG: &str = "k";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||
pub async fn cache_rumor(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
opened: &OpenedStream,
|
||||
) -> Result<bool> {
|
||||
if cord03::expiration_of(&opened.rumor)?
|
||||
.is_some_and(|expiration| expiration <= Timestamp::now())
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let tags = vec![
|
||||
Tag::identifier(opened.rumor_id),
|
||||
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
|
||||
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
|
||||
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
|
||||
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
|
||||
Tag::public_key(opened.author),
|
||||
];
|
||||
let at = Timestamp::from_secs(opened.at_ms / 1000);
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn purge_expired(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
now: Timestamp,
|
||||
) -> Result<usize> {
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
let mut expired = Vec::new();
|
||||
|
||||
for event in database.query(filter).await? {
|
||||
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if expiration <= now {
|
||||
expired.push(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
let purged = expired.len();
|
||||
|
||||
if purged > 0 {
|
||||
database.delete(Filter::new().ids(expired)).await?;
|
||||
}
|
||||
|
||||
Ok(purged)
|
||||
}
|
||||
|
||||
pub async fn query_rumors(
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<UnsignedEvent>> {
|
||||
let mut filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.custom_tag(MARK_TAG, MARK_VALUE)
|
||||
.custom_tag(CHANNEL_TAG, channel.to_hex());
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
|
||||
for event in database.query(filter).await? {
|
||||
let Some(rumor_id) = event.tags.identifier() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match newest.get(&rumor_id) {
|
||||
Some(existing) if existing.created_at >= event.created_at => {}
|
||||
_ => {
|
||||
newest.insert(rumor_id, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut events: Vec<Event> = newest.into_values().collect();
|
||||
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
|
||||
let mut rumors = Vec::with_capacity(events.len());
|
||||
for event in events {
|
||||
let rumor = UnsignedEvent::from_json(event.content)
|
||||
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
|
||||
rumors.push(rumor);
|
||||
}
|
||||
|
||||
Ok(rumors)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelKeyRef>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
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,
|
||||
}
|
||||
|
||||
impl CommunityState {
|
||||
pub fn from_genesis(
|
||||
genesis: &CommunityGenesis,
|
||||
editions: &[ParsedEdition],
|
||||
added_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
});
|
||||
|
||||
match edition.subkind.as_str() {
|
||||
vsk::COMMUNITY_METADATA => {
|
||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||
relays.extend(
|
||||
metadata
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
channels.push(ChannelKeyRef {
|
||||
id: ChannelId::from_bytes(edition.entity),
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let control_pks = BTreeMap::from([(
|
||||
ROOT_EPOCH.0,
|
||||
control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)?
|
||||
.pk(),
|
||||
)]);
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_root: Some(genesis.control_root),
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
heads,
|
||||
banned: BTreeSet::new(),
|
||||
dissolved: false,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
|
||||
pub fn floors(&self) -> Floors {
|
||||
self.heads
|
||||
.iter()
|
||||
.map(|head| (head.entity, head.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply_fold(&mut self, fold: &ControlFold) {
|
||||
self.heads = fold.floors.values().cloned().collect();
|
||||
self.banned = fold.banned.clone();
|
||||
|
||||
if let Some(community) = &fold.community {
|
||||
self.relays = community
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok())
|
||||
.collect();
|
||||
}
|
||||
|
||||
for (id, metadata) in &fold.channels {
|
||||
if metadata.deleted.unwrap_or(false) {
|
||||
self.channels.retain(|channel| channel.id != *id);
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.channels.iter_mut().find(|channel| channel.id == *id) {
|
||||
Some(channel) => {
|
||||
channel.name = metadata.name.clone();
|
||||
|
||||
if !metadata.private {
|
||||
channel.private = false;
|
||||
}
|
||||
}
|
||||
None if !metadata.private => self.channels.push(ChannelKeyRef {
|
||||
id: *id,
|
||||
name: metadata.name.clone(),
|
||||
private: false,
|
||||
epoch: self.root_epoch,
|
||||
}),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match database.query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn backfill(
|
||||
client: &Client,
|
||||
database: &dyn NostrDatabase,
|
||||
channel: &ChannelId,
|
||||
held: &[(Epoch, [u8; 32])],
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ChatRumor>> {
|
||||
let planes = plane_keys(held, channel)?;
|
||||
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
|
||||
|
||||
let mut cursor = until;
|
||||
let mut seen: BTreeSet<EventId> = BTreeSet::new();
|
||||
let mut found: Vec<ChatRumor> = Vec::new();
|
||||
|
||||
for _ in 0..MAX_PAGES {
|
||||
let page = fetch_page(client, &authors, cursor, limit).await?;
|
||||
|
||||
if page.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
|
||||
|
||||
for (opened, rumor) in fresh {
|
||||
if cache_rumor(database, channel, &opened).await? {
|
||||
found.push(rumor);
|
||||
}
|
||||
}
|
||||
|
||||
match next {
|
||||
Some(next) => cursor = Some(next),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
found.truncate(limit);
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn advance(
|
||||
page: &BTreeSet<Event>,
|
||||
planes: &[(Epoch, GroupKey)],
|
||||
channel: &ChannelId,
|
||||
cursor: Option<Timestamp>,
|
||||
limit: usize,
|
||||
seen: &mut BTreeSet<EventId>,
|
||||
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
|
||||
let mut fresh = Vec::new();
|
||||
|
||||
for wrap in page {
|
||||
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Ok((opened, rumor)) = cord03::open(wrap, group, channel, *epoch) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if seen.insert(rumor.id) {
|
||||
fresh.push((opened, rumor));
|
||||
}
|
||||
}
|
||||
|
||||
if fresh.is_empty() || page.len() < limit {
|
||||
return (fresh, None);
|
||||
}
|
||||
|
||||
let oldest = page.iter().map(|event| event.created_at).min();
|
||||
|
||||
match oldest {
|
||||
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
|
||||
_ => (fresh, None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_page(
|
||||
client: &Client,
|
||||
authors: &[PublicKey],
|
||||
until: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> Result<BTreeSet<Event>> {
|
||||
let mut filter = Filter::new()
|
||||
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
|
||||
.authors(authors.iter().copied())
|
||||
.limit(limit);
|
||||
|
||||
if let Some(until) = until {
|
||||
filter = filter.until(until);
|
||||
}
|
||||
|
||||
Ok(client.fetch_events(filter).await?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::Epoch;
|
||||
use crate::cord01::{
|
||||
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
|
||||
};
|
||||
use crate::cord03::{build_message, seal_rumor};
|
||||
use crate::derive::channel_group_key;
|
||||
|
||||
const SECRET: [u8; 32] = [0x07u8; 32];
|
||||
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
|
||||
|
||||
/// What a relay does with an inclusive `until` and a `limit`.
|
||||
fn serve_page(
|
||||
relay: &BTreeSet<Event>,
|
||||
cursor: Option<Timestamp>,
|
||||
limit: usize,
|
||||
) -> BTreeSet<Event> {
|
||||
let mut events: Vec<Event> = relay
|
||||
.iter()
|
||||
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
events.sort_by_key(|event| Reverse(event.created_at));
|
||||
events.truncate(limit);
|
||||
events.into_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_pages_back_across_a_rekey() {
|
||||
let channel = ChannelId::from_bytes([0x9cu8; 32]);
|
||||
let author = Keys::generate();
|
||||
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
|
||||
let planes = plane_keys(&held, &channel).expect("derives");
|
||||
|
||||
// Three messages a second apart: a page boundary falls between each.
|
||||
let base = 1_700_000_000_000;
|
||||
let mut relay: BTreeSet<Event> = BTreeSet::new();
|
||||
|
||||
for (content, secret, epoch, at_ms) in [
|
||||
("before the rekey", &SECRET, Epoch(0), base),
|
||||
("still before", &SECRET, Epoch(0), base + 1_000),
|
||||
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
|
||||
] {
|
||||
let group = channel_group_key(secret, &channel, epoch).expect("derives");
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
&channel,
|
||||
epoch,
|
||||
content,
|
||||
None,
|
||||
at_ms,
|
||||
None,
|
||||
);
|
||||
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
|
||||
}
|
||||
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut found = Vec::new();
|
||||
let mut cursor = None;
|
||||
|
||||
for _ in 0..3 {
|
||||
let page = serve_page(&relay, cursor, 2);
|
||||
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
|
||||
|
||||
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
|
||||
|
||||
match next {
|
||||
Some(next) => cursor = Some(next),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
|
||||
|
||||
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
|
||||
assert_eq!(
|
||||
contents,
|
||||
["after the rekey", "still before", "before the rekey"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumors_read_back_after_a_restart() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0xabu8; 32]);
|
||||
let author = Keys::generate();
|
||||
|
||||
smol::block_on(async {
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
|
||||
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
|
||||
let rumor = build_rumor_ms(
|
||||
9,
|
||||
author.public_key(),
|
||||
content,
|
||||
channel_binding_tags(&channel, Epoch(0)),
|
||||
at_ms,
|
||||
);
|
||||
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
|
||||
let (wrap, _) = wrap_seal(
|
||||
&seal,
|
||||
&group,
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_ms / 1000),
|
||||
&[],
|
||||
)
|
||||
.expect("wraps");
|
||||
|
||||
let opened = open_wrap(&wrap, &group).expect("opens");
|
||||
cache_rumor(&database, &channel, &opened)
|
||||
.await
|
||||
.expect("caches");
|
||||
}
|
||||
|
||||
// The group key is gone; only the local cache stands in for it.
|
||||
let rumors = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(rumors.len(), 2, "both messages come back");
|
||||
assert_eq!(rumors[0].content, "second", "newest first");
|
||||
assert_eq!(rumors[1].content, "first");
|
||||
|
||||
// A page boundary in message time, not in cache time.
|
||||
let until = Timestamp::from_secs(1_500);
|
||||
let page = query_rumors(&database, &channel, Some(until), 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(page.len(), 1);
|
||||
assert_eq!(page[0].content, "first");
|
||||
|
||||
let capped = query_rumors(&database, &channel, None, 1)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(capped.len(), 1);
|
||||
assert_eq!(capped[0].content, "second");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
let channel = ChannelId::from_bytes([0x77u8; 32]);
|
||||
let author = Keys::generate();
|
||||
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
|
||||
let now = Timestamp::now().as_secs();
|
||||
|
||||
smol::block_on(async {
|
||||
// A live timer is stored; one that already elapsed is refused at ingest.
|
||||
assert!(
|
||||
cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"live",
|
||||
Some(3_600),
|
||||
now
|
||||
)
|
||||
.await
|
||||
);
|
||||
assert!(
|
||||
!cache(
|
||||
&database,
|
||||
&group,
|
||||
&channel,
|
||||
&author,
|
||||
"gone",
|
||||
Some(1),
|
||||
now - 120
|
||||
)
|
||||
.await
|
||||
);
|
||||
|
||||
let stored = query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries");
|
||||
assert_eq!(stored.len(), 1);
|
||||
assert_eq!(stored[0].content, "live");
|
||||
|
||||
// Hiding is not disappearing: the sweep removes the row itself,
|
||||
// judged on the rumor's own signed tag.
|
||||
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
|
||||
.await
|
||||
.expect("sweeps");
|
||||
assert_eq!(purged, 1);
|
||||
assert!(
|
||||
query_rumors(&database, &channel, None, 10)
|
||||
.await
|
||||
.expect("queries")
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
// An untimed rumor is never swept, whatever the clock says.
|
||||
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
|
||||
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
|
||||
.await
|
||||
.expect("sweeps");
|
||||
assert_eq!(purged, 0);
|
||||
});
|
||||
}
|
||||
|
||||
async fn cache(
|
||||
database: &MemoryDatabase,
|
||||
group: &GroupKey,
|
||||
channel: &ChannelId,
|
||||
author: &Keys,
|
||||
content: &str,
|
||||
timer: Option<u64>,
|
||||
at_secs: u64,
|
||||
) -> bool {
|
||||
let rumor = build_message(
|
||||
author.public_key(),
|
||||
channel,
|
||||
Epoch(0),
|
||||
content,
|
||||
None,
|
||||
at_secs * 1_000,
|
||||
timer,
|
||||
);
|
||||
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
|
||||
let opened = open_wrap(&wrap, group).expect("opens");
|
||||
|
||||
cache_rumor(database, channel, &opened)
|
||||
.await
|
||||
.expect("caches")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
|
||||
use base64::{Engine as _, alphabet};
|
||||
|
||||
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
|
||||
/// encoding at any depth.
|
||||
///
|
||||
/// The reader tolerates non-zero trailing bits; the writer never emits them.
|
||||
/// The spec's own worked example (`examples.md` §6.2) contains five such
|
||||
/// values, and a reader cannot tell a mis-encoded named field from a correctly
|
||||
/// encoded one, so the boundary is the writer's alone.
|
||||
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
|
||||
&alphabet::URL_SAFE,
|
||||
GeneralPurposeConfig::new()
|
||||
.with_encode_padding(false)
|
||||
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
|
||||
.with_decode_allow_trailing_bits(true),
|
||||
);
|
||||
|
||||
pub(crate) fn encode(bytes: &[u8]) -> String {
|
||||
BASE64URL.encode(bytes)
|
||||
}
|
||||
|
||||
/// Decodes one 32-byte value, the width every §8 field has.
|
||||
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
|
||||
let bytes = BASE64URL
|
||||
.decode(value.trim())
|
||||
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
|
||||
|
||||
bytes
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
pub mod base64url;
|
||||
pub mod derive;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::Extra;
|
||||
|
||||
/// Decode a 64-character lowercase-hex string into 32 bytes.
|
||||
///
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
decode_hex_lower::<32>(value)
|
||||
}
|
||||
|
||||
@@ -38,3 +44,34 @@ pub(crate) fn random_32() -> Result<[u8; 32]> {
|
||||
fill_random(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Hex to unpadded base64url for one 32-byte §8 value.
|
||||
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
|
||||
Ok(base64url::encode(&decode_hex_32(value)?))
|
||||
}
|
||||
|
||||
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
|
||||
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
|
||||
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
|
||||
}
|
||||
|
||||
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
|
||||
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
|
||||
serde_json::to_string(value).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Unions an unknown-field map. Where both sides carry a key, the
|
||||
/// lexicographically lowest canonical bytes win, so two devices converge
|
||||
/// instead of flapping.
|
||||
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),
|
||||
None => true,
|
||||
};
|
||||
|
||||
if replace {
|
||||
into.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user