This commit is contained in:
2026-09-17 11:13:13 +07:00
parent fd39be0eda
commit fe2d956d40
13 changed files with 507 additions and 1391 deletions
-1222
View File
File diff suppressed because it is too large Load Diff
+10 -22
View File
@@ -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,
+7 -18
View File
@@ -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],
+5 -49
View File
@@ -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,
+4 -23
View File
@@ -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);
} }
} }
+3 -11
View File
@@ -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.
+1 -14
View File
@@ -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
View File
@@ -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)]
+7 -15
View File
@@ -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]
+3 -1
View File
@@ -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));
} }
+1 -2
View File
@@ -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,
+18 -2
View File
@@ -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,
+438
View File
@@ -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.