clean up
This commit is contained in:
+10
-22
@@ -44,8 +44,7 @@ pub enum ChatError {
|
|||||||
MissingTag(&'static str),
|
MissingTag(&'static str),
|
||||||
DuplicateTag(&'static str),
|
DuplicateTag(&'static str),
|
||||||
BadTag(&'static str),
|
BadTag(&'static str),
|
||||||
/// A delete is a tombstone and a timer notice documents the policy, so
|
/// Neither a delete nor a timer notice may be erased by the policy it carries.
|
||||||
/// neither may be erased by the policy it carries.
|
|
||||||
ExemptExpiration,
|
ExemptExpiration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +72,14 @@ impl From<StreamError> for ChatError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chat event another chat event refers to: a quote, a comment's parent, a
|
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
|
||||||
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct ReplyRef {
|
pub struct ReplyRef {
|
||||||
pub id: EventId,
|
pub id: EventId,
|
||||||
pub author: Option<PublicKey>,
|
pub author: Option<PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A reference that also names the referenced event's kind, which a comment
|
/// A reference that also names the referenced event's kind, which `K`/`k` must commit on the wire.
|
||||||
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Target {
|
pub struct Target {
|
||||||
pub reply: ReplyRef,
|
pub reply: ReplyRef,
|
||||||
@@ -166,8 +163,7 @@ pub fn build_message(
|
|||||||
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
|
/// `parent` is the immediate parent; a `None` root means the parent is the thread's root.
|
||||||
/// immutable root; `None` means the parent is itself the root.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_comment(
|
pub fn build_comment(
|
||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
@@ -238,8 +234,7 @@ pub fn build_edit(
|
|||||||
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
|
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CORD-08 §4: an informational row in the timeline, gated by the roster rather
|
/// CORD-08 §4: informational, gated by the roster rather than by the fold.
|
||||||
/// than by the fold, so it is built like any other chat rumor.
|
|
||||||
pub fn build_timer_notice(
|
pub fn build_timer_notice(
|
||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
@@ -253,8 +248,7 @@ pub fn build_timer_notice(
|
|||||||
build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms)
|
build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tag is derived from the rumor's own signed `created_at`, so a later
|
/// Derived from the signed `created_at`, so a later metadata edit never reaches back.
|
||||||
/// metadata edit can never reach back into history.
|
|
||||||
fn expiration_tag(at_ms: u64, timer: Option<u64>) -> Option<Tag> {
|
fn expiration_tag(at_ms: u64, timer: Option<u64>) -> Option<Tag> {
|
||||||
timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()]))
|
timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()]))
|
||||||
}
|
}
|
||||||
@@ -297,8 +291,7 @@ pub fn build_typing(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
|
/// `ephemeral` picks the 21059 wrap, which relays must not store.
|
||||||
/// the 21059 wrap, which relays must not store.
|
|
||||||
pub fn seal_rumor(
|
pub fn seal_rumor(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
@@ -318,8 +311,7 @@ pub fn seal_rumor(
|
|||||||
KIND_WRAP
|
KIND_WRAP
|
||||||
};
|
};
|
||||||
|
|
||||||
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
|
// The wrap's copy is for relays; the inner one drives the local purge.
|
||||||
// stored event on schedule; the inner copy is what drives a local purge.
|
|
||||||
let expiration: Vec<Tag> = rumor
|
let expiration: Vec<Tag> = rumor
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
@@ -336,9 +328,7 @@ pub fn seal_rumor(
|
|||||||
)?)
|
)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
|
/// The claimed channel and epoch must both be the ones that opened the wrap.
|
||||||
/// rumor claims must both be the ones that opened it, so a keyholder of two
|
|
||||||
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
|
|
||||||
pub fn open(
|
pub fn open(
|
||||||
wrap: &Event,
|
wrap: &Event,
|
||||||
group: &GroupKey,
|
group: &GroupKey,
|
||||||
@@ -358,9 +348,7 @@ pub fn open(
|
|||||||
Ok((opened, chat))
|
Ok((opened, chat))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every epoch's group key for one channel. `secret` is whatever feeds the
|
/// `secret` is the `community_root` for a public channel, its own key for a private one.
|
||||||
/// channel at that epoch: the `community_root` for a public one, its own key
|
|
||||||
/// for a private one.
|
|
||||||
pub fn plane_keys(
|
pub fn plane_keys(
|
||||||
held: &[(Epoch, [u8; 32])],
|
held: &[(Epoch, [u8; 32])],
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
|
|||||||
@@ -63,8 +63,7 @@ pub struct CommunityMetadata {
|
|||||||
pub extra: Extra,
|
pub extra: Extra,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CORD-08 §1: absent, `0` and malformed all mean off, and a reader must not
|
/// CORD-08 §1: absent, `0` and malformed all mean off.
|
||||||
/// guess a default from garbage.
|
|
||||||
fn timer_seconds<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
fn timer_seconds<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||||
where
|
where
|
||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
@@ -193,7 +192,6 @@ pub fn open_edition(
|
|||||||
Ok(parse_edition(&opened.rumor)?)
|
Ok(parse_edition(&opened.rumor)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Appends editions to entity chains.
|
|
||||||
pub struct ControlWriter {
|
pub struct ControlWriter {
|
||||||
pub author: PublicKey,
|
pub author: PublicKey,
|
||||||
pub read: GroupKey,
|
pub read: GroupKey,
|
||||||
@@ -204,9 +202,7 @@ pub struct Edition<'a> {
|
|||||||
pub subkind: &'a str,
|
pub subkind: &'a str,
|
||||||
pub entity: [u8; 32],
|
pub entity: [u8; 32],
|
||||||
pub content: &'a str,
|
pub content: &'a str,
|
||||||
/// The head this edition supersedes.
|
/// The head this edition supersedes; `None` starts the chain.
|
||||||
///
|
|
||||||
/// `None` starts the chain.
|
|
||||||
pub head: Option<&'a EntityHead>,
|
pub head: Option<&'a EntityHead>,
|
||||||
pub citation: Option<AuthorityCitation>,
|
pub citation: Option<AuthorityCitation>,
|
||||||
}
|
}
|
||||||
@@ -400,8 +396,7 @@ impl ControlWriter {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `content` is the whole Pin List, in whichever of CORD-04 §7's two
|
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
|
||||||
/// self-describing forms the Channel's folded type calls for.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn set_pin_list(
|
pub fn set_pin_list(
|
||||||
&self,
|
&self,
|
||||||
@@ -455,8 +450,7 @@ pub struct ControlFold {
|
|||||||
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
|
||||||
/// Each creator's live link-signer set.
|
/// Each creator's live link-signer set.
|
||||||
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
|
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
|
||||||
/// Head content per `pins_locator`: a Pin List is addressed by a one-way
|
/// Head content per `pins_locator`; the coordinate is one-way, so a fold cannot name its Channel.
|
||||||
/// coordinate, so a fold cannot name the Channel it belongs to.
|
|
||||||
pub pins: BTreeMap<[u8; 32], String>,
|
pub pins: BTreeMap<[u8; 32], String>,
|
||||||
pub floors: Floors,
|
pub floors: Floors,
|
||||||
pub gapped: bool,
|
pub gapped: bool,
|
||||||
@@ -533,8 +527,7 @@ fn fold_metadata(
|
|||||||
|
|
||||||
for edition in editions {
|
for edition in editions {
|
||||||
match edition.subkind.as_str() {
|
match edition.subkind.as_str() {
|
||||||
// A channel addressed at the community's own coordinate would share, and
|
// A channel at the community's own coordinate would corrupt the metadata chain's floor.
|
||||||
// corrupt, the metadata chain's floor.
|
|
||||||
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
|
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
|
||||||
community.push(edition)
|
community.push(edition)
|
||||||
}
|
}
|
||||||
@@ -557,8 +550,7 @@ fn fold_metadata(
|
|||||||
fold.community = serde_json::from_str::<CommunityMetadata>(&head.content)
|
fold.community = serde_json::from_str::<CommunityMetadata>(&head.content)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|mut metadata| {
|
.map(|mut metadata| {
|
||||||
// Up to 5 relays is a recommendation, so a longer set is
|
// Up to 5 relays is a recommendation, so a longer set is truncated, not refused.
|
||||||
// truncated rather than refused, on read as well as on write.
|
|
||||||
metadata.relays.truncate(MAX_RELAYS);
|
metadata.relays.truncate(MAX_RELAYS);
|
||||||
metadata
|
metadata
|
||||||
});
|
});
|
||||||
@@ -590,10 +582,7 @@ fn fold_metadata(
|
|||||||
fold
|
fold
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A Pin List's coordinate derives one-way, so unlike the banlist, a grant or a
|
/// A one-way coordinate leaves the `eid` unchecked; violating content reads as empty.
|
||||||
/// registry there is nothing to check the `eid` against: an edition at an
|
|
||||||
/// unknown coordinate is simply never read. Its content is stored verbatim,
|
|
||||||
/// because a violating list still folds but reads as empty (CORD-04 §7).
|
|
||||||
fn fold_pins(
|
fn fold_pins(
|
||||||
judge: &Judge<'_>,
|
judge: &Judge<'_>,
|
||||||
editions: &[ParsedEdition],
|
editions: &[ParsedEdition],
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
use std::collections::HashMap;
|
|
||||||
use std::sync::{LazyLock, Mutex, PoisonError};
|
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use hkdf::Hkdf;
|
use hkdf::Hkdf;
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
@@ -77,27 +74,11 @@ pub struct GroupKey {
|
|||||||
|
|
||||||
impl GroupKey {
|
impl GroupKey {
|
||||||
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
|
||||||
let key = memo_key(label, secret, id32, epoch);
|
let secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
|
||||||
|
|
||||||
if let Some(hit) = lock_memo().get(&key) {
|
|
||||||
return Ok(hit.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
let info = build_info(label, id32, epoch);
|
|
||||||
let secret_key = hkdf_to_secret_key(secret, &info)?;
|
|
||||||
let keys = Keys::new(secret_key);
|
let keys = Keys::new(secret_key);
|
||||||
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
|
||||||
let group_key = Self { keys, conversation };
|
|
||||||
|
|
||||||
let mut memo = lock_memo();
|
Ok(Self { keys, conversation })
|
||||||
|
|
||||||
if memo.len() >= 1024 {
|
|
||||||
memo.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
memo.insert(key, group_key.clone());
|
|
||||||
|
|
||||||
Ok(group_key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pk(&self) -> PublicKey {
|
pub fn pk(&self) -> PublicKey {
|
||||||
@@ -125,27 +106,6 @@ impl std::fmt::Debug for GroupKey {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
|
|
||||||
|
|
||||||
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
|
|
||||||
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn clear_memo() {
|
|
||||||
lock_memo().clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
|
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(label.as_bytes());
|
|
||||||
hasher.update([0x00]);
|
|
||||||
hasher.update(secret);
|
|
||||||
hasher.update(id32);
|
|
||||||
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
|
|
||||||
hasher.update([epoch.is_some() as u8]);
|
|
||||||
hasher.finalize().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `secret` is the `community_root` for a public channel.
|
/// `secret` is the `community_root` for a public channel.
|
||||||
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
|
||||||
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
|
||||||
@@ -165,8 +125,7 @@ pub fn control_group_key(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The plane's address and wrap signer, held only by staff.
|
/// The plane's address and wrap signer, held only by staff; wraps still read under [`control_group_key`].
|
||||||
/// Wraps still encrypt under [`control_group_key`].
|
|
||||||
pub fn control_signer_group_key(
|
pub fn control_signer_group_key(
|
||||||
control_root: &[u8; 32],
|
control_root: &[u8; 32],
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
@@ -180,9 +139,7 @@ pub fn control_signer_group_key(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Member-writable, unlike the Control Plane:
|
/// Member-writable, unlike the Control Plane: a join or a leave is each member's own word.
|
||||||
///
|
|
||||||
/// - A join or a leave is each member's own word.
|
|
||||||
pub fn guestbook_group_key(
|
pub fn guestbook_group_key(
|
||||||
community_root: &[u8; 32],
|
community_root: &[u8; 32],
|
||||||
community_id: &CommunityId,
|
community_id: &CommunityId,
|
||||||
@@ -196,8 +153,7 @@ pub fn guestbook_group_key(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keyed by the prior `community_root` rather than the channel key,
|
/// Keyed by the prior `community_root`, so any retained member recovers any epoch's rekey.
|
||||||
/// so any retained member recovers any epoch's rekey without a ratchet.
|
|
||||||
pub fn channel_rekey_group_key(
|
pub fn channel_rekey_group_key(
|
||||||
prior_root: &[u8; 32],
|
prior_root: &[u8; 32],
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::cmp::Reverse;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
@@ -55,8 +56,7 @@ impl fmt::Display for EditionError {
|
|||||||
|
|
||||||
impl std::error::Error for EditionError {}
|
impl std::error::Error for EditionError {}
|
||||||
|
|
||||||
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
|
/// A `vac`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
|
||||||
/// coordinate, version and hash. It is a sync floor, not the verdict.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct AuthorityCitation {
|
pub struct AuthorityCitation {
|
||||||
pub entity: [u8; 32],
|
pub entity: [u8; 32],
|
||||||
@@ -312,16 +312,7 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
|
|||||||
editions
|
editions
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.reduce(|(best_index, best), (index, candidate)| {
|
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
|
||||||
let supersedes = candidate.version > best.version
|
|
||||||
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
|
|
||||||
|
|
||||||
if supersedes {
|
|
||||||
(index, candidate)
|
|
||||||
} else {
|
|
||||||
(best_index, best)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(|(index, _)| index)
|
.map(|(index, _)| index)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,18 +545,8 @@ mod tests {
|
|||||||
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
||||||
);
|
);
|
||||||
|
|
||||||
// The golden vector only exercises the absent-prev encoding; pin the
|
// The golden vector only exercises the absent-prev encoding, so pin the flag.
|
||||||
// present-prev branch structurally so a swapped flag stays visible.
|
|
||||||
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
|
||||||
assert_eq!(
|
|
||||||
bytes.len(),
|
|
||||||
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
|
|
||||||
);
|
|
||||||
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
|
|
||||||
assert_eq!(
|
|
||||||
bytes[8 + EDITION_LABEL.len() + 32..][..8],
|
|
||||||
1u64.to_be_bytes()
|
|
||||||
);
|
|
||||||
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use data_encoding::BASE64URL_NOPAD;
|
|||||||
use nostr::nips::nip01::Coordinate;
|
use nostr::nips::nip01::Coordinate;
|
||||||
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
|
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
|
||||||
use nostr::nips::nip44::v2::ConversationKey;
|
use nostr::nips::nip44::v2::ConversationKey;
|
||||||
use nostr::nips::nip44::{self, Version};
|
|
||||||
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
|
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -595,13 +594,7 @@ pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, Invite
|
|||||||
list.fits()?;
|
list.fits()?;
|
||||||
|
|
||||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
let content = nip44::encrypt(
|
let content = stream::seal_to_self(keys, json.as_bytes())?;
|
||||||
keys.secret_key(),
|
|
||||||
&keys.public_key(),
|
|
||||||
json.as_bytes(),
|
|
||||||
Version::V2,
|
|
||||||
)
|
|
||||||
.map_err(crypto_error)?;
|
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
|
||||||
.finalize(keys)
|
.finalize(keys)
|
||||||
@@ -613,10 +606,9 @@ pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, Invit
|
|||||||
return Err(InviteError::Kind(event.kind.as_u16()));
|
return Err(InviteError::Kind(event.kind.as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
|
let json = stream::open_to_self(keys, &event.content)?;
|
||||||
.map_err(crypto_error)?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json).map_err(json_error)
|
serde_json::from_slice(&json).map_err(json_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An entry is immutable once minted, so two copies should agree.
|
/// An entry is immutable once minted, so two copies should agree.
|
||||||
|
|||||||
@@ -86,8 +86,7 @@ macro_rules! hex_id {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hex_id! {
|
hex_id! {
|
||||||
/// A self-certifying commitment to the owner's key, carried inside invites and
|
/// A self-certifying commitment to the owner's key, never on the wire.
|
||||||
/// never on the wire.
|
|
||||||
CommunityId
|
CommunityId
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,18 +105,6 @@ hex_id! {
|
|||||||
)]
|
)]
|
||||||
pub struct Epoch(pub u64);
|
pub struct Epoch(pub u64);
|
||||||
|
|
||||||
impl From<u64> for Epoch {
|
|
||||||
fn from(value: u64) -> Self {
|
|
||||||
Self(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<Epoch> for u64 {
|
|
||||||
fn from(value: Epoch) -> Self {
|
|
||||||
value.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl fmt::Display for Epoch {
|
impl fmt::Display for Epoch {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
write!(f, "{}", self.0)
|
write!(f, "{}", self.0)
|
||||||
|
|||||||
+10
-12
@@ -2,12 +2,11 @@ use std::collections::BTreeMap;
|
|||||||
use std::collections::btree_map::Entry;
|
use std::collections::btree_map::Entry;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|
||||||
use nostr::nips::nip44::{self, Version};
|
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::invite::{ChannelGrant, CommunityInvite};
|
use crate::invite::{ChannelGrant, CommunityInvite};
|
||||||
use crate::stream::NIP44_MAX_PLAINTEXT;
|
use crate::stream::{self, NIP44_MAX_PLAINTEXT};
|
||||||
use crate::{CommunityId, Epoch, Extra};
|
use crate::{CommunityId, Epoch, Extra};
|
||||||
|
|
||||||
pub const KIND_COMMUNITY_LIST: u16 = 13302;
|
pub const KIND_COMMUNITY_LIST: u16 = 13302;
|
||||||
@@ -43,6 +42,12 @@ impl fmt::Display for ListError {
|
|||||||
|
|
||||||
impl std::error::Error for ListError {}
|
impl std::error::Error for ListError {}
|
||||||
|
|
||||||
|
impl From<stream::StreamError> for ListError {
|
||||||
|
fn from(error: stream::StreamError) -> Self {
|
||||||
|
ListError::Crypto(error.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct JoinMaterial {
|
pub struct JoinMaterial {
|
||||||
pub community_id: CommunityId,
|
pub community_id: CommunityId,
|
||||||
@@ -182,13 +187,7 @@ pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, List
|
|||||||
list.fits()?;
|
list.fits()?;
|
||||||
|
|
||||||
let json = serde_json::to_string(list).map_err(json_error)?;
|
let json = serde_json::to_string(list).map_err(json_error)?;
|
||||||
let content = nip44::encrypt(
|
let content = stream::seal_to_self(keys, json.as_bytes())?;
|
||||||
keys.secret_key(),
|
|
||||||
&keys.public_key(),
|
|
||||||
json.as_bytes(),
|
|
||||||
Version::V2,
|
|
||||||
)
|
|
||||||
.map_err(crypto_error)?;
|
|
||||||
|
|
||||||
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
|
||||||
.finalize(keys)
|
.finalize(keys)
|
||||||
@@ -200,10 +199,9 @@ pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, Lis
|
|||||||
return Err(ListError::Kind(event.kind.as_u16()));
|
return Err(ListError::Kind(event.kind.as_u16()));
|
||||||
}
|
}
|
||||||
|
|
||||||
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
|
let json = stream::open_to_self(keys, &event.content)?;
|
||||||
.map_err(crypto_error)?;
|
|
||||||
|
|
||||||
serde_json::from_str(&json).map_err(json_error)
|
serde_json::from_slice(&json).map_err(json_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ pub struct MessageKeys {
|
|||||||
hmac_key: [u8; 32],
|
hmac_key: [u8; 32],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Debug for MessageKeys {
|
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
||||||
f.write_str("MessageKeys(<disclosed>)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageKeys {
|
impl MessageKeys {
|
||||||
pub fn to_hex(&self) -> String {
|
pub fn to_hex(&self) -> String {
|
||||||
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
|
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
|
||||||
@@ -340,16 +334,14 @@ pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORD-01's binding, restated for a path that decrypts no wrap: without
|
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
|
||||||
// this, a private Channel's keyholder could pin its messages into a public
|
|
||||||
// list, disclosing them community-wide with proof.
|
|
||||||
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
|
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
|
||||||
|
|
||||||
// Every reader recomputes the identity; a claimed `id` is never trusted.
|
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
|
||||||
rumor.verify_id().ok()?;
|
rumor.verify_id().ok()?;
|
||||||
let rumor_id = rumor.compute_id();
|
let rumor_id = rumor.compute_id();
|
||||||
|
|
||||||
@@ -383,8 +375,7 @@ fn verify_edit_bundle(
|
|||||||
) -> Option<EditedContent> {
|
) -> Option<EditedContent> {
|
||||||
let seal = &bundle.seal;
|
let seal = &bundle.seal;
|
||||||
|
|
||||||
// Nobody else may revise another member's words, and this is checkable
|
// Checkable before any crypto: nobody else may revise another member's words.
|
||||||
// before any crypto.
|
|
||||||
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
|
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -632,11 +623,12 @@ mod tests {
|
|||||||
.expect("encrypts");
|
.expect("encrypts");
|
||||||
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
|
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
|
||||||
|
|
||||||
|
let hex = disclosure.to_hex();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
MessageKeys::from_hex(&disclosure.to_hex()),
|
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
|
||||||
Some(disclosure)
|
Some(hex.clone())
|
||||||
);
|
);
|
||||||
assert!(MessageKeys::from_hex(&disclosure.to_hex().to_uppercase()).is_none());
|
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ pub fn parse_blob_plaintext(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if width != MEMBER_BASE_BLOB_LEN && width != STAFF_BASE_BLOB_LEN && width < STAFF_BASE_BLOB_LEN
|
// Between the frozen forms is malformed; wider is a future form, kept below.
|
||||||
|
if (CHANNEL_BLOB_LEN + 1..MEMBER_BASE_BLOB_LEN).contains(&width)
|
||||||
|
|| (MEMBER_BASE_BLOB_LEN + 1..STAFF_BASE_BLOB_LEN).contains(&width)
|
||||||
{
|
{
|
||||||
return Err(RekeyError::BadBaseBlobWidth(width));
|
return Err(RekeyError::BadBaseBlobWidth(width));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,7 @@ const WRAP_TAG: &str = "e";
|
|||||||
const KIND_TAG: &str = "k";
|
const KIND_TAG: &str = "k";
|
||||||
const STATE_PREFIX: &str = "concord/";
|
const STATE_PREFIX: &str = "concord/";
|
||||||
|
|
||||||
/// CORD-08 §3: an already-expired rumor is refused at ingest, never stored.
|
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
|
||||||
/// Returns whether the rumor was kept.
|
|
||||||
pub async fn cache_rumor(
|
pub async fn cache_rumor(
|
||||||
database: &dyn NostrDatabase,
|
database: &dyn NostrDatabase,
|
||||||
channel: &ChannelId,
|
channel: &ChannelId,
|
||||||
|
|||||||
@@ -107,8 +107,7 @@ pub fn split_ms(at_ms: u64) -> (u64, u16) {
|
|||||||
(at_ms / 1000, (at_ms % 1000) as u16)
|
(at_ms / 1000, (at_ms % 1000) as u16)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a rumor carrying a full epoch-ms time: `created_at`
|
/// Build a rumor carrying a full epoch-ms time: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
|
||||||
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
|
|
||||||
pub fn build_rumor_ms(
|
pub fn build_rumor_ms(
|
||||||
kind: u16,
|
kind: u16,
|
||||||
author: PublicKey,
|
author: PublicKey,
|
||||||
@@ -198,6 +197,23 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
|
|||||||
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
.map_err(|error| StreamError::Decrypt(error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 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 fn build_seal(
|
pub fn build_seal(
|
||||||
rumor: &UnsignedEvent,
|
rumor: &UnsignedEvent,
|
||||||
form: SealForm,
|
form: SealForm,
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
# Using the Concord backend
|
||||||
|
|
||||||
|
`crates/concord` is a protocol crate: derivations, envelopes, folds and the local
|
||||||
|
state document. It has no GPUI dependency and owns no strings a user reads — the
|
||||||
|
UI layer decides every rendering. This document is the map from a UI action to
|
||||||
|
the calls it makes.
|
||||||
|
|
||||||
|
A community is addressed by a `community_id` (never on the wire) plus three
|
||||||
|
secrets: `community_root` (read access — holding it *is* membership),
|
||||||
|
`control_root` (write access to the Control Plane, held by staff), and per-Channel
|
||||||
|
keys for private channels. Authority is a roster of owner-rooted signed grants,
|
||||||
|
folded independently by every client.
|
||||||
|
|
||||||
|
## Modules
|
||||||
|
|
||||||
|
| Module | Owns |
|
||||||
|
| --- | --- |
|
||||||
|
| `derive` | Every frozen HKDF derivation and coordinate |
|
||||||
|
| `stream` | The CORD-01 envelope: seal, wrap, open, and the NIP-44 helpers |
|
||||||
|
| `edition` | Chained, versioned editions: parse, hash, fold, floors |
|
||||||
|
| `roles` | Permissions, roles, grants, the banlist, the authority fixpoint |
|
||||||
|
| `control` | The Control Plane: genesis, the fold, the writer, metadata |
|
||||||
|
| `chat` | The Chat Plane: message/reaction/edit/delete builders and the fold |
|
||||||
|
| `guestbook` | Joins, leaves, kicks, snapshots, the member list |
|
||||||
|
| `invite` | Invite bundles, links, the Direct Invite, the Invite List |
|
||||||
|
| `list` | The Community List (a member's own memberships, across devices) |
|
||||||
|
| `rekey` | Key rotations, refounding, compaction, dissolution |
|
||||||
|
| `pins` | Pin Lists, and the key disclosure a keyless reader verifies |
|
||||||
|
| `store` | Local rumor cache, the community state document, relay paging |
|
||||||
|
|
||||||
|
Read `CommunityId` as "this community", `ChannelId` as "this channel", `Epoch` as
|
||||||
|
"which key generation". Nothing else in the API needs internal state.
|
||||||
|
|
||||||
|
## Creating a community
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::control::{self, CommunityMetadata};
|
||||||
|
use concord::store::{self, CommunityState, save_state};
|
||||||
|
|
||||||
|
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
|
||||||
|
let minted = control::genesis(&owner_keys, &metadata, now_secs)?;
|
||||||
|
|
||||||
|
// minted.identity — community_id, owner, owner_salt (verify() recomputes it)
|
||||||
|
// minted.wraps — the two owner-signed genesis editions, already sealed
|
||||||
|
// minted.channel_id — the #general channel
|
||||||
|
for wrap in &minted.wraps {
|
||||||
|
client.send_event(wrap).to(&relays).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The owner then needs the folded state, which is also what every member does on
|
||||||
|
join:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::derive::{control_group_key, control_signer_group_key};
|
||||||
|
use concord::edition::ParsedEdition;
|
||||||
|
|
||||||
|
let read = control_group_key(&minted.community_root, &minted.identity.community_id, Epoch(0))?;
|
||||||
|
let signer = control_signer_group_key(&minted.control_root, &minted.identity.community_id, Epoch(0))?;
|
||||||
|
let editions: Vec<ParsedEdition> = minted
|
||||||
|
.wraps
|
||||||
|
.iter()
|
||||||
|
.map(|wrap| control::open_edition(wrap, &read, &signer.pk(), true))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
let mut state = CommunityState::from_genesis(&minted, &editions, added_at_ms)?;
|
||||||
|
save_state(database, &state).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Put the community's relay list into `state.relays` and add those relays to the
|
||||||
|
client explicitly — coop's client is a gossip client with no background refresh.
|
||||||
|
|
||||||
|
## Joining
|
||||||
|
|
||||||
|
An invite link resolves to a bundle:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::invite::{self, BundleState, invite_bundle_key};
|
||||||
|
|
||||||
|
let link = invite::parse_link(url)?; // link_signer, token, bootstrap_relays, naddr
|
||||||
|
|
||||||
|
// The crate does no I/O: fetch the naddr from the fragment's relays, then:
|
||||||
|
let invite = match invite::parse_bundle_event(&event, &link.link_signer, &invite_bundle_key(&link.token))? {
|
||||||
|
BundleState::Live(invite) => invite, // validate() already ran
|
||||||
|
BundleState::Revoked => return Ok(None), // a tombstone at the coordinate
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let (inviter, invite) = invite::unwrap_direct_invite(&wrap, &my_keys)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Either way the invite carries `community_id`, `owner`, `owner_salt`,
|
||||||
|
`community_root`, `root_epoch`, `control_pk`, the granted `channels`
|
||||||
|
(`ChannelGrant { id, key, epoch, name }`) and the relay set. `invite.expired(now_ms)`
|
||||||
|
is a preview rule: a past expiry still renders, but the join is refused.
|
||||||
|
|
||||||
|
Then publish a join so the member list sees the member before any backfill:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::derive::guestbook_group_key;
|
||||||
|
use concord::guestbook;
|
||||||
|
|
||||||
|
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
|
||||||
|
let rumor = guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
|
||||||
|
let (wrap, _) = guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?;
|
||||||
|
client.send_event(&wrap).to(&relays).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading the Control Plane
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::control::{self, ControlFold};
|
||||||
|
|
||||||
|
let editions: Vec<ParsedEdition> = wraps
|
||||||
|
.iter()
|
||||||
|
.filter_map(|wrap| control::open_edition(wrap, &read, &control_pk, true).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let control: ControlFold =
|
||||||
|
control::fold_control(&owner, &community_id, &editions, &state.floors(), &state.banned);
|
||||||
|
state.apply_fold(&control);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ControlFold` is everything the community UI needs:
|
||||||
|
|
||||||
|
| Field | Use |
|
||||||
|
| --- | --- |
|
||||||
|
| `community` | name, description, icon, banner, `message_expiration` |
|
||||||
|
| `channels` | the channel list; `deleted: true` means drop it |
|
||||||
|
| `roles` | `role()`, `roles_of()`, `effective_permissions()`, `is_authorized()`, `is_staff()` |
|
||||||
|
| `banned` | the banlist |
|
||||||
|
| `registries` / `is_public()` | each invite creator's live link signers |
|
||||||
|
| `pins` / `pin_content(id, channel)` | Pin List content per channel |
|
||||||
|
| `floors` | the committed heads the next fold is judged against |
|
||||||
|
| `gapped` | a chain hole: refetch the Control Plane before trusting what is missing |
|
||||||
|
|
||||||
|
`None` on `community` or a channel means "this client saw no authorized edition",
|
||||||
|
never "the value is gone" — keep what the state already holds rather than walking
|
||||||
|
the community backwards. Feed `state.floors()` and `state.banned` into the next
|
||||||
|
fold; they are its memory.
|
||||||
|
|
||||||
|
## Sending a message
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::chat::{self, build_message};
|
||||||
|
use concord::derive::channel_group_key;
|
||||||
|
|
||||||
|
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel
|
||||||
|
let rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
|
||||||
|
let (wrap, wrap_key) = chat::seal_rumor(&rumor, &plane, &my_keys, false)?;
|
||||||
|
client.send_event(&wrap).to(&relays).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `epoch` is the channel's current epoch (`state.channels` carries it). A private
|
||||||
|
channel derives from its own key instead of `community_root`.
|
||||||
|
- `timer` is `control.community.message_expiration`; pass `None` when it is off.
|
||||||
|
The builder attaches the NIP-40 tag and `seal_rumor` mirrors it onto the wrap,
|
||||||
|
so relays drop the ciphertext too.
|
||||||
|
- `ephemeral: true` picks kind `21059` for typing indicators. Keep the returned
|
||||||
|
wrap key if the message may be deleted later — a kind-5 delete needs it.
|
||||||
|
- That `send_event` is the whole publish path; there is no optimistic echo. Feed
|
||||||
|
the wrap through the same ingest path the subscription uses so send-then-read
|
||||||
|
never waits on a relay round trip.
|
||||||
|
|
||||||
|
`build_edit`, `build_reaction` and `build_delete` are the same shape, each a rumor
|
||||||
|
about an existing `EventId` rather than a mutation.
|
||||||
|
|
||||||
|
## Reading a channel
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::chat::{self, fold, plane_keys};
|
||||||
|
|
||||||
|
let planes = plane_keys(&held, &channel)?; // &[(Epoch, secret)]
|
||||||
|
let mut rumors = Vec::new();
|
||||||
|
|
||||||
|
for wrap in &wraps {
|
||||||
|
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((opened, rumor)) = chat::open(wrap, group, &channel, *epoch) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
store::cache_rumor(database, &channel, &opened).await?;
|
||||||
|
rumors.push(rumor);
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = fold(&rumors, Timestamp::now(), |actor, citation, author| {
|
||||||
|
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
||||||
|
&& control.roles.can_act_on_member(actor, &owner, author, Permissions::MANAGE_MESSAGES)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`ChatMessage` carries `content`, `at_ms`, `edited_at`, `deleted`, `reactions`,
|
||||||
|
`reply_to` and `thread_root` already resolved. The fold drops expired rumors; a
|
||||||
|
`deleted` row is still returned so the timeline keeps its shape.
|
||||||
|
|
||||||
|
Relay history pages through the local cache:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let page = store::backfill(client, database, &channel, &held, until, 50).await?;
|
||||||
|
let cached = store::query_rumors(database, &channel, None, 50).await?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`backfill` walks newest-first across every held epoch, caches what it opens, and
|
||||||
|
stops on a short page. `query_rumors` is the read path when the group keys are
|
||||||
|
gone. Run `store::purge_expired(database, &channel, now)` on the same cadence as
|
||||||
|
any other local sweep — the timer is cooperative, so the local store is the
|
||||||
|
artifact that has to forget.
|
||||||
|
|
||||||
|
`ChatAction::TimerNotice { seconds }` is a policy notice, not a message: render it
|
||||||
|
as an inline row only when its author passes
|
||||||
|
`control.roles.is_authorized(&author, &owner, Permissions::MANAGE_METADATA)`.
|
||||||
|
|
||||||
|
## Membership
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let states = guestbook::coalesce(&rumors, now_ms, Some(&refounder_pk), |actor, target, citation| {
|
||||||
|
citation_ok(&owner, &community_id, actor, citation, &control.roles.floors)
|
||||||
|
&& control.roles.can_act_on_member(actor, &owner, target, Permissions::KICK)
|
||||||
|
});
|
||||||
|
let members = guestbook::complete_memberlist(&states, &observed, &granted, &control.banned, &BTreeMap::new());
|
||||||
|
```
|
||||||
|
|
||||||
|
- `observed` is npub → ms for every author this client has seen publish anything
|
||||||
|
usable, which is what makes a member visible before their Join arrives. Only
|
||||||
|
count it forward.
|
||||||
|
- `granted` is every npub the roster ranks; they are members with no Guestbook
|
||||||
|
entry at all.
|
||||||
|
- `banned_at` is empty today, so a ban is terminal in the fold. Fill it when the
|
||||||
|
banlist head's timestamp is plumbed through.
|
||||||
|
- Removal is three separate actions, composed by the caller: strip the grant
|
||||||
|
(immediate and cheap), then the kick directive, then — for a ban — the rotation
|
||||||
|
that actually enforces it.
|
||||||
|
|
||||||
|
## Moderation writes
|
||||||
|
|
||||||
|
Every Control Plane write goes through one writer and one edition shape:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.clone() };
|
||||||
|
let head = control.floors.get(entity).cloned();
|
||||||
|
|
||||||
|
let (wrap, new_head) = writer.set_community_metadata(
|
||||||
|
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it
|
||||||
|
from the folded Grant that ranks them (`AuthorityCitation { entity, version, hash }`)
|
||||||
|
and pass the head from the current fold, so the chain cannot silently fork.
|
||||||
|
|
||||||
|
Wrappers: `set_community_metadata`, `set_channel_metadata`, `set_role`,
|
||||||
|
`set_grant`, `set_banlist`, `set_registry`, `set_pin_list`, plus raw `publish`.
|
||||||
|
A ban is a `set_banlist` followed by a base rekey; a kick is a `set_grant` with an
|
||||||
|
empty `role_ids` followed by `guestbook::build_kick`.
|
||||||
|
|
||||||
|
## Pins
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::pins;
|
||||||
|
|
||||||
|
let entry = pins::build_entry(&opened_message, &plane, &channel)?;
|
||||||
|
let head_content = control.pin_content(&community_id, &channel).unwrap_or("");
|
||||||
|
let read = pins::read_list(head_content, |epoch| channel_group_key(&root, &channel, epoch).ok());
|
||||||
|
let content = pins::publishable(&read, channel_is_private, &plane, epoch)?;
|
||||||
|
let (wrap, _) = writer.set_pin_list(
|
||||||
|
&my_keys, &community_id, &channel, &content, head, citation, now_secs)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Reading is verification: `read_list` decodes either content form (public, or
|
||||||
|
sealed under the channel key at the named epoch), and
|
||||||
|
`pins::verify_entry(entry, &channel)` returns a `VerifiedPin` with the proven
|
||||||
|
author, words and time — no history and no old keys needed. `read.sealed` means
|
||||||
|
the list is sealed under an epoch this client never held: show it as unavailable,
|
||||||
|
and never write from it (`publishable` refuses). `pins::killed_by(&pin, &delete)`
|
||||||
|
answers whether a folded kind-5 erases an entry.
|
||||||
|
|
||||||
|
## Invites
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::derive::{invite_bundle_key, TOKEN_LEN};
|
||||||
|
use concord::invite::{self, InviteEntry, InviteTombstone};
|
||||||
|
|
||||||
|
let token: [u8; TOKEN_LEN] = /* 16 bytes from any CSPRNG */;
|
||||||
|
let bundle_key = invite_bundle_key(&token);
|
||||||
|
let link_signer = Keys::generate();
|
||||||
|
let bundle = invite::build_bundle_event(&link_signer, &invite, &bundle_key)?;
|
||||||
|
let url = invite::build_invite_url(BASE, &link_signer.public_key(), &token, &relays)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
A link is a coordinate plus a fragment: the naddr fetches the bundle, the token
|
||||||
|
unlocks it, and the fragment names the relays to fetch from.
|
||||||
|
`invite::stock_relays()` is what a fragment with no relays of its own means.
|
||||||
|
|
||||||
|
The `link_signer` secret is what lets the creator refresh or retire the link, so
|
||||||
|
keep it against the token in the member's own Invite List — a local document
|
||||||
|
encrypted to self, exactly like the Community List:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut list = invite::parse_invite_list(&my_keys, &event)?;
|
||||||
|
list.entries.push(InviteEntry {
|
||||||
|
token: HEXLOWER.encode(&token),
|
||||||
|
signer_sk: link_signer.secret_key().to_secret_hex(),
|
||||||
|
community_id: invite.community_id,
|
||||||
|
url,
|
||||||
|
label: None,
|
||||||
|
created_at: now_ms,
|
||||||
|
expires_at: None,
|
||||||
|
extra: Default::default(),
|
||||||
|
});
|
||||||
|
let event = invite::build_invite_list(&my_keys, &list)?; // kind 13303
|
||||||
|
|
||||||
|
// Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
|
||||||
|
list.tombstones.push(InviteTombstone {
|
||||||
|
token: HEXLOWER.encode(&token),
|
||||||
|
community_id: invite.community_id,
|
||||||
|
extra: Default::default(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`merge_invite_lists` merges two devices' copies, `is_live(&token_hex)` answers
|
||||||
|
whether a link still stands, and `fits()` is the write gate.
|
||||||
|
|
||||||
|
## Rekeys, refounding and dissolution
|
||||||
|
|
||||||
|
A rotation is authority plus delivery: `rekey_authorized(&control.roles, &owner, &me, permission, &removed)`
|
||||||
|
gates it, `plan_refounding(epoch)` mints the new pair, and `build_rekey_chunks`
|
||||||
|
seals one blob per remaining member:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::derive::epoch_key_commitment;
|
||||||
|
use concord::rekey::{self, RekeyScope};
|
||||||
|
|
||||||
|
let scope = RekeyScope::Channel(channel_id); // or RekeyScope::Base
|
||||||
|
let plan = rekey::plan_refounding(Epoch(epoch + 1))?;
|
||||||
|
|
||||||
|
// A base rotation delivers the new control-plane keys beside the root; a channel
|
||||||
|
// rotation delivers only that channel's fresh key.
|
||||||
|
let new_key = plan.new_root;
|
||||||
|
let (control_pk, control_root) = match scope {
|
||||||
|
RekeyScope::Base => {
|
||||||
|
let pk = plan.signer(&community_id)?.pk().to_bytes();
|
||||||
|
(Some(pk), is_staff.then_some(&plan.new_control_root))
|
||||||
|
}
|
||||||
|
RekeyScope::Channel(_) => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let blobs = members
|
||||||
|
.iter()
|
||||||
|
.map(|member| {
|
||||||
|
rekey::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root)
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
let rekey_group = rekey::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
|
||||||
|
let wraps = rekey::build_rekey_chunks(
|
||||||
|
&my_keys,
|
||||||
|
&rekey_group,
|
||||||
|
scope,
|
||||||
|
plan.epoch,
|
||||||
|
Epoch(epoch),
|
||||||
|
&epoch_key_commitment(Epoch(epoch), &community_root),
|
||||||
|
&blobs,
|
||||||
|
citation,
|
||||||
|
false,
|
||||||
|
now_secs,
|
||||||
|
)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
On the receiving side, `rekey::parse_rekey_chunk(&opened)` per wrap, then
|
||||||
|
`collect_rotations(&chunks)`, then `am_i_removed(&rotation, &me)` — which is
|
||||||
|
`None` until every chunk is held, because an incomplete set is never a removal. A
|
||||||
|
member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the key
|
||||||
|
only if the plaintext binds to the scope and epoch they expect and its `prevcommit`
|
||||||
|
matches the key they already hold. Two concurrent rotations settle on `fork_winner`.
|
||||||
|
|
||||||
|
Dissolution is owner-only and terminal:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let rumor = rekey::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs);
|
||||||
|
let wrap = rekey::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?;
|
||||||
|
|
||||||
|
// A receiver seals the community read-only on sight.
|
||||||
|
if rekey::verify_dissolved(&wrap, &identity) {
|
||||||
|
state.dissolved = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Community List
|
||||||
|
|
||||||
|
A member's own memberships, synced across their devices:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use concord::list;
|
||||||
|
|
||||||
|
let material = list::join_material(&invite, staff.then_some(&control_root));
|
||||||
|
let mut mine = list::parse_list_event(&my_keys, &event)?;
|
||||||
|
mine = list::merge(mine, list::CommunityList {
|
||||||
|
entries: vec![list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let event = list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self
|
||||||
|
```
|
||||||
|
|
||||||
|
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
|
||||||
|
strictly newer join outruns it. `fits()` is the write gate — 50 memberships and
|
||||||
|
the NIP-44 size cap, both protocol constants.
|
||||||
|
|
||||||
|
## GPUI conventions
|
||||||
|
|
||||||
|
- Wrap, decrypt, verify, fold and every database or relay call go in
|
||||||
|
`cx.background_spawn`. A secp256k1 verification per edition is far too
|
||||||
|
expensive for the foreground thread.
|
||||||
|
- Hold entities foreground: `cx.spawn` with `this.update(cx, |this, cx| …)` and
|
||||||
|
the inner `cx`, keeping the returned `Task` in a field so it is cancelled with
|
||||||
|
the view.
|
||||||
|
- In tests, use `cx.background_executor().timer(..)` for delays, never
|
||||||
|
`smol::Timer`, or `run_until_parked()` will find nothing left to run.
|
||||||
|
|
||||||
|
## Not wired up yet
|
||||||
|
|
||||||
|
- **No registry and no sync engine.** `crates/concord` has no subscriptions, no
|
||||||
|
`init`, and no `Entity<Community>`; the UI owns subscribing, routing a wrap to
|
||||||
|
the plane whose address it carries, and rebuilding a subscription when a plane's
|
||||||
|
address changes (join, channel added, rekey folded).
|
||||||
|
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate
|
||||||
|
pass over the builders, not a per-call patch.
|
||||||
|
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as
|
||||||
|
a NIP-59 gift wrap for the current user.** Concord wraps are kind 1059 too, so
|
||||||
|
that handler must route by subscription id before any concord subscription goes
|
||||||
|
live, or every stream wrap lands in the DM trash and raises a toast.
|
||||||
|
- **No plane key can be persisted yet.** `CommunityState` has nowhere to keep a
|
||||||
|
key a rotation delivered and `ChannelKeyRef` carries no key of its own, so a
|
||||||
|
client can verify a rotation and still lose it on restart — history under a
|
||||||
|
prior root or a prior channel epoch is unreadable until that schema change
|
||||||
|
lands.
|
||||||
Reference in New Issue
Block a user