refactor concord

This commit is contained in:
2026-09-19 07:24:01 +07:00
parent 91c40b0799
commit 9081675494
12 changed files with 669 additions and 258 deletions
-3
View File
@@ -300,9 +300,6 @@ async fn subscribe(
} }
} }
// Concord wraps share kind 1059 with NIP-59 gift wraps, so an automatic
// target sends gossip after the plane authors as if they were DM peers.
// The community's own relays are the routing relays, so target them.
let target = if relays.is_empty() { let target = if relays.is_empty() {
ReqTarget::auto(vec![filter]) ReqTarget::auto(vec![filter])
} else { } else {
+39 -19
View File
@@ -3,8 +3,8 @@ use std::fmt;
use data_encoding::BASE64; use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce}; use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{ use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp, AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
UnsignedEvent, FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
}; };
use crate::derive::GroupKey; use crate::derive::GroupKey;
@@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
} }
/// A member's own document (the Community List, the Invite List): NIP-44 to self. /// 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> { pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
seal_bytes( where
&ConversationKey::derive(keys.secret_key(), &keys.public_key()) S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
.map_err(|error| StreamError::Encrypt(error.to_string()))?, {
plaintext, check_plaintext_cap(plaintext.len())?;
)
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
} }
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> { pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
open_bytes( where
&ConversationKey::derive(keys.secret_key(), &keys.public_key()) S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
.map_err(|error| StreamError::Decrypt(error.to_string()))?, {
content, let address = signer
) .get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
} }
pub fn build_seal( pub async fn build_seal<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
form: SealForm, form: SealForm,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
) -> Result<Event, StreamError> { ) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?; let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content) EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at) .custom_created_at(rumor.created_at)
.finalize(author) .finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string())) .map_err(|error| StreamError::Sign(error.to_string()))
} }
@@ -450,7 +470,7 @@ mod tests {
} }
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event { fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals") smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
} }
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event { fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
+20 -9
View File
@@ -183,18 +183,21 @@ pub fn build_snapshot_chunks(
.collect() .collect()
} }
pub fn seal_rumor( pub async fn seal_rumor<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
) -> Result<(Event, Keys), GuestbookError> { ) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16(); let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) { if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind)); return Err(GuestbookError::UnknownKind(kind));
} }
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?) Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
} }
@@ -556,7 +559,9 @@ mod tests {
} }
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor { fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0; let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
.expect("seals")
.0;
open(&wrap, &group()).expect("opens").1 open(&wrap, &group()).expect("opens").1
} }
@@ -826,7 +831,9 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0, &smol::block_on(seal_rumor(&bad_ms, &group(), &member))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::Stream(StreamError::BadMs)) Err(GuestbookError::Stream(StreamError::BadMs))
@@ -835,7 +842,9 @@ mod tests {
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT); let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0, &smol::block_on(seal_rumor(&bad_verb, &group(), &member))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::BadTag(TAG_CONTENT)) Err(GuestbookError::BadTag(TAG_CONTENT))
@@ -854,7 +863,7 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&ambiguous, &group(), &moderator) &smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
.expect("seals") .expect("seals")
.0, .0,
&group() &group()
@@ -876,7 +885,9 @@ mod tests {
); );
assert!(matches!( assert!(matches!(
open( open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0, &smol::block_on(seal_rumor(&rumor, &group(), &moderator))
.expect("seals")
.0,
&group() &group()
), ),
Err(GuestbookError::BadTag(TAG_SNAP)) Err(GuestbookError::BadTag(TAG_SNAP))
+26 -13
View File
@@ -183,25 +183,32 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
} }
} }
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> { pub async fn build_list_event<S>(keys: &S, list: &CommunityList) -> Result<Event, ListError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
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 = cord01::seal_to_self(keys, json.as_bytes())?; let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys) .finalize_async(keys)
.await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> { pub async fn parse_list_event<S>(keys: &S, event: &Event) -> Result<CommunityList, ListError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_COMMUNITY_LIST { if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16())); return Err(ListError::Kind(event.kind.as_u16()));
} }
let json = cord01::open_to_self(keys, &event.content)?; let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error) serde_json::from_str(&json).map_err(json_error)
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -419,18 +426,21 @@ mod tests {
extra: Extra::default(), extra: Extra::default(),
}; };
let event = build_list_event(&me, &mine).expect("builds"); let event = smol::block_on(build_list_event(&me, &mine)).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine); assert_eq!(
smol::block_on(parse_list_event(&me, &event)).expect("parses"),
mine
);
assert!( assert!(
!parse_list_event(&me, &event) !smol::block_on(parse_list_event(&me, &event))
.expect("parses") .expect("parses")
.is_live(&id(0x33)) .is_live(&id(0x33))
); );
// Only the member's own keys open it, and an unreadable list is "no news". // Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate(); let stranger = Keys::generate();
assert!(parse_list_event(&stranger, &event).is_err()); assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them. // Unknown fields survive the round trip, so a republish cannot wipe them.
let mut held = mine.clone(); let mut held = mine.clone();
@@ -440,8 +450,11 @@ mod tests {
.current .current
.extra .extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt = let rebuilt = smol::block_on(parse_list_event(
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses"); &me,
&smol::block_on(build_list_event(&me, &held)).expect("builds"),
))
.expect("parses");
assert_eq!(rebuilt, held); assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing. // The write gate refuses an over-cap or oversized List before publishing.
@@ -459,7 +472,7 @@ mod tests {
.collect(), .collect(),
); );
assert!(matches!( assert!(matches!(
build_list_event(&me, &crowded), smol::block_on(build_list_event(&me, &crowded)),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
)); ));
+190 -146
View File
@@ -4,7 +4,9 @@ pub mod list;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail}; use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>, pub wraps: Vec<Event>,
} }
pub fn genesis( pub async fn genesis<S>(
owner: &Keys, owner: &S,
metadata: &CommunityMetadata, metadata: &CommunityMetadata,
at_secs: u64, at_secs: u64,
) -> Result<CommunityGenesis> { ) -> Result<CommunityGenesis>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let metadata_content = encode_metadata(metadata)?; let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?; let owner_salt = random_32()?;
let owner_key = owner
.get_public_key_async()
.await
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
let identity = CommunityIdentity { let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt), community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
owner: owner.public_key(), owner: owner_key,
owner_salt, owner_salt,
}; };
@@ -167,7 +176,7 @@ pub fn genesis(
let mut wraps = Vec::with_capacity(editions.len()); let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions { for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?); wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
} }
Ok(CommunityGenesis { Ok(CommunityGenesis {
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
} }
impl ControlWriter { impl ControlWriter {
pub fn publish( pub async fn publish<S>(
&self, &self,
keys: &Keys, keys: &S,
edition: Edition<'_>, edition: Edition<'_>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rumor = build_edition(EditionFields { let rumor = build_edition(EditionFields {
author: self.author, author: self.author,
subkind: edition.subkind, subkind: edition.subkind,
@@ -231,20 +243,23 @@ impl ControlWriter {
}); });
let parsed = parse_edition(&rumor)?; let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?; let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
Ok((wrap, EntityHead::from(&parsed))) Ok((wrap, EntityHead::from(&parsed)))
} }
pub fn set_community_metadata( pub async fn set_community_metadata<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
metadata: &CommunityMetadata, metadata: &CommunityMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = encode_metadata(metadata)?; let content = encode_metadata(metadata)?;
self.publish( self.publish(
@@ -258,17 +273,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_channel_metadata( pub async fn set_channel_metadata<S>(
&self, &self,
keys: &Keys, keys: &S,
channel: &ChannelId, channel: &ChannelId,
metadata: &ChannelMetadata, metadata: &ChannelMetadata,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if metadata.name.len() > MAX_NAME_BYTES { if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes"); bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
} }
@@ -286,16 +305,20 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_role( pub async fn set_role<S>(
&self, &self,
keys: &Keys, keys: &S,
role: &Role, role: &Role,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if role.name.len() > MAX_NAME_BYTES { if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes"); bail!("role name exceeds {MAX_NAME_BYTES} bytes");
} }
@@ -313,17 +336,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_grant( pub async fn set_grant<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
grant: &Grant, grant: &Grant,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = grant.to_content()?; let content = grant.to_content()?;
self.publish( self.publish(
@@ -337,17 +364,21 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
pub fn set_banlist( pub async fn set_banlist<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
banned: &BTreeSet<PublicKey>, banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if banned.len() > MAX_BANLIST { if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries"); bail!("banlist exceeds {MAX_BANLIST} entries");
} }
@@ -366,19 +397,23 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn set_registry( pub async fn set_registry<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
creator: &PublicKey, creator: &PublicKey,
links: &[PublicKey], links: &[PublicKey],
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let entries: Vec<String> = links let entries: Vec<String> = links
.iter() .iter()
.take(MAX_REGISTRY_LINKS) .take(MAX_REGISTRY_LINKS)
@@ -397,20 +432,24 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for. /// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn set_pin_list( pub async fn set_pin_list<S>(
&self, &self,
keys: &Keys, keys: &S,
community_id: &CommunityId, community_id: &CommunityId,
channel: &ChannelId, channel: &ChannelId,
content: &str, content: &str,
head: Option<&EntityHead>, head: Option<&EntityHead>,
citation: Option<AuthorityCitation>, citation: Option<AuthorityCitation>,
at_secs: u64, at_secs: u64,
) -> Result<(Event, EntityHead)> { ) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
self.publish( self.publish(
keys, keys,
Edition { Edition {
@@ -422,6 +461,7 @@ impl ControlWriter {
}, },
at_secs, at_secs,
) )
.await
} }
} }
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
selection.head.map(|index| authorized[index]) selection.head.map(|index| authorized[index])
} }
fn seal_edition( async fn seal_edition<S>(
edition: &UnsignedEvent, edition: &UnsignedEvent,
owner: &Keys, owner: &S,
read: &GroupKey, read: &GroupKey,
signer: &GroupKey, signer: &GroupKey,
at_secs: u64, at_secs: u64,
) -> Result<Event> { ) -> Result<Event>
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?; where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
let (wrap, _) = wrap_seal_with( let (wrap, _) = wrap_seal_with(
&seal, &seal,
@@ -731,6 +774,8 @@ fn seal_edition(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use nostr_sdk::prelude::Keys;
use super::*; use super::*;
use crate::cord03::{self, build_message, seal_rumor}; use crate::cord03::{self, build_message, seal_rumor};
use crate::cord04::pins; use crate::cord04::pins;
@@ -768,7 +813,7 @@ mod tests {
#[test] #[test]
fn metadata_and_channel_edits_reach_a_second_client() { fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -797,33 +842,31 @@ mod tests {
.get(minted.channel_id.as_bytes()) .get(minted.channel_id.as_bytes())
.expect("head"); .expect("head");
let (community_wrap, _) = writer let (community_wrap, _) = smol::block_on(writer.set_community_metadata(
.set_community_metadata( &owner,
&owner, &community_id,
&community_id, &CommunityMetadata {
&CommunityMetadata { relays: vec!["wss://relay.example".to_owned()],
relays: vec!["wss://relay.example".to_owned()], ..metadata("coop two")
..metadata("coop two") },
}, Some(community_head),
Some(community_head), None,
None, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes"); let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata(
let (channel_wrap, _) = writer &owner,
.set_channel_metadata( &minted.channel_id,
&owner, &ChannelMetadata {
&minted.channel_id, name: "lobby".to_owned(),
&ChannelMetadata { private: false,
name: "lobby".to_owned(), ..ChannelMetadata::default()
private: false, },
..ChannelMetadata::default() Some(channel_head),
}, None,
Some(channel_head), AT + 2,
None, ))
AT + 2, .expect("publishes");
)
.expect("publishes");
let mut edited = genesis_editions.clone(); let mut edited = genesis_editions.clone();
edited.extend(open_all( edited.extend(open_all(
@@ -875,7 +918,7 @@ mod tests {
fn a_delegated_member_edits_metadata_only_under_its_own_grant() { fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate(); let owner = Keys::generate();
let member = Keys::generate(); let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -896,21 +939,20 @@ mod tests {
extra: Extra::default(), extra: Extra::default(),
}; };
let (role_wrap, _) = writer let (role_wrap, _) = smol::block_on(writer.publish(
.publish( &owner,
&owner, Edition {
Edition { subkind: vsk::ROLE,
subkind: vsk::ROLE, entity: *role_id.as_bytes(),
entity: *role_id.as_bytes(), content: &role.to_content().expect("serializes"),
content: &role.to_content().expect("serializes"), head: None,
head: None, citation: None,
citation: None, },
}, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes"); let (grant_wrap, _) = smol::block_on(
let (grant_wrap, _) = writer writer.publish(
.publish(
&owner, &owner,
Edition { Edition {
subkind: vsk::GRANT, subkind: vsk::GRANT,
@@ -927,8 +969,9 @@ mod tests {
citation: None, citation: None,
}, },
AT + 2, AT + 2,
) ),
.expect("publishes"); )
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk()); let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
@@ -959,36 +1002,34 @@ mod tests {
}; };
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer let (uncited, _) = smol::block_on(member_writer.publish(
.publish( &member,
&member, Edition {
Edition { subkind: vsk::COMMUNITY_METADATA,
subkind: vsk::COMMUNITY_METADATA, entity: *community_id.as_bytes(),
entity: *community_id.as_bytes(), content: &content,
content: &content, head: Some(head),
head: Some(head), citation: None,
citation: None, },
}, AT + 3,
AT + 3, ))
) .expect("publishes");
.expect("publishes"); let (cited, _) = smol::block_on(member_writer.publish(
let (cited, _) = member_writer &member,
.publish( Edition {
&member, subkind: vsk::COMMUNITY_METADATA,
Edition { entity: *community_id.as_bytes(),
subkind: vsk::COMMUNITY_METADATA, content: &content,
entity: *community_id.as_bytes(), head: Some(head),
content: &content, citation: Some(AuthorityCitation {
head: Some(head), entity: grant.entity,
citation: Some(AuthorityCitation { version: grant.version,
entity: grant.entity, hash: grant.self_hash,
version: grant.version, }),
hash: grant.self_hash, },
}), AT + 4,
}, ))
AT + 4, .expect("publishes");
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed. // Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone(); let mut forged = base.clone();
@@ -1023,7 +1064,7 @@ mod tests {
#[test] #[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() { fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -1042,7 +1083,7 @@ mod tests {
AT * 1_000, AT * 1_000,
None, None,
); );
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals");
let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH) let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens") .expect("opens")
.0; .0;
@@ -1064,17 +1105,16 @@ mod tests {
read: read.clone(), read: read.clone(),
signer: signer.clone(), signer: signer.clone(),
}; };
let (pin_wrap, _) = writer let (pin_wrap, _) = smol::block_on(writer.set_pin_list(
.set_pin_list( &owner,
&owner, &community_id,
&community_id, &channel,
&channel, &content,
&content, None,
None, None,
None, AT + 1,
AT + 1, ))
) .expect("publishes");
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk()); let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk())); editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
@@ -1109,7 +1149,7 @@ mod tests {
#[test] #[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() { fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let owner_pk = owner.public_key(); let owner_pk = owner.public_key();
let (read, signer) = holder(&minted); let (read, signer) = holder(&minted);
@@ -1124,10 +1164,16 @@ mod tests {
&owner_pk, &owner_pk,
&community_id, &community_id,
&open_all( &open_all(
&[writer &[smol::block_on(writer.set_community_metadata(
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1) &owner,
.expect("publishes") &community_id,
.0], metadata,
None,
None,
AT + 1,
))
.expect("publishes")
.0],
&writer.read, &writer.read,
&writer.signer.pk(), &writer.signer.pk(),
), ),
@@ -1163,8 +1209,7 @@ mod tests {
.map(|_| Keys::generate().public_key()) .map(|_| Keys::generate().public_key())
.collect(); .collect();
assert!( assert!(
writer smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2))
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
.is_err() .is_err()
); );
@@ -1179,20 +1224,19 @@ mod tests {
assert!(grant.to_content().is_err()); assert!(grant.to_content().is_err());
assert!( assert!(
writer smol::block_on(writer.set_channel_metadata(
.set_channel_metadata( &owner,
&owner, &minted.channel_id,
&minted.channel_id, &ChannelMetadata {
&ChannelMetadata { name: "x".repeat(MAX_NAME_BYTES + 1),
name: "x".repeat(MAX_NAME_BYTES + 1), private: false,
private: false, ..ChannelMetadata::default()
..ChannelMetadata::default() },
}, None,
None, None,
None, AT + 3,
AT + 3, ))
) .is_err()
.is_err()
); );
} }
} }
+13 -7
View File
@@ -292,19 +292,22 @@ pub fn build_typing(
} }
/// `ephemeral` picks the 21059 wrap, which relays must not store. /// `ephemeral` picks the 21059 wrap, which relays must not store.
pub fn seal_rumor( pub async fn seal_rumor<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
group: &GroupKey, group: &GroupKey,
author: &Keys, author: &S,
ephemeral: bool, ephemeral: bool,
) -> Result<(Event, Keys), ChatError> { ) -> Result<(Event, Keys), ChatError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16(); let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) { if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind)); return Err(ChatError::UnknownKind(kind));
} }
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
let wrap_kind = if ephemeral { let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL KIND_WRAP_EPHEMERAL
} else { } else {
@@ -674,7 +677,9 @@ mod tests {
} }
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0 smol::block_on(seal_rumor(rumor, group, author, false))
.expect("seals")
.0
} }
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
@@ -949,7 +954,8 @@ mod tests {
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks. // chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals"); let seal =
smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals");
let (wrap, _) = wrap_seal( let (wrap, _) = wrap_seal(
&seal, &seal,
&group, &group,
@@ -971,7 +977,7 @@ mod tests {
AT, AT,
); );
assert!(matches!( assert!(matches!(
seal_rumor(&ghost, &group, &alice, false), smol::block_on(seal_rumor(&ghost, &group, &alice, false)),
Err(ChatError::UnknownKind(3300)) Err(ChatError::UnknownKind(3300))
)); ));
+6 -4
View File
@@ -583,7 +583,7 @@ mod tests {
at_ms, at_ms,
None, None,
); );
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens") open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
} }
@@ -699,7 +699,7 @@ mod tests {
AT_MS + 5_000, AT_MS + 5_000,
None, None,
); );
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals"); let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel()); let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
@@ -718,7 +718,8 @@ mod tests {
AT_MS + 6_000, AT_MS + 6_000,
None, None,
); );
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals"); let (wrap, _) =
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel()); let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
@@ -805,7 +806,8 @@ mod tests {
None, None,
AT_MS + 1_000, AT_MS + 1_000,
); );
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals"); let (wrap, _) =
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!( assert_eq!(
+13 -6
View File
@@ -590,25 +590,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList
} }
} }
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> { pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
list.fits()?; 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 = cord01::seal_to_self(keys, json.as_bytes())?; let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content) EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys) .finalize_async(keys)
.await
.map_err(crypto_error) .map_err(crypto_error)
} }
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> { pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST { if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16())); return Err(InviteError::Kind(event.kind.as_u16()));
} }
let json = cord01::open_to_self(keys, &event.content)?; let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error) serde_json::from_str(&json).map_err(json_error)
} }
/// An entry is immutable once minted, so two copies should agree. /// An entry is immutable once minted, so two copies should agree.
+61 -40
View File
@@ -4,7 +4,10 @@ use std::fmt;
use anyhow::Result; use anyhow::Result;
use data_encoding::HEXLOWER; use data_encoding::HEXLOWER;
use nostr::nips::nip44::v2::ConversationKey; use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent}; use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp,
UnsignedEvent,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError}; use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError};
@@ -598,8 +601,8 @@ pub fn build_rekey_rumor(
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn build_rekey_chunks( pub async fn build_rekey_chunks<S>(
rotator: &Keys, rotator: &S,
group: &GroupKey, group: &GroupKey,
scope: RekeyScope, scope: RekeyScope,
new_epoch: Epoch, new_epoch: Epoch,
@@ -609,7 +612,11 @@ pub fn build_rekey_chunks(
citation: Option<&AuthorityCitation>, citation: Option<&AuthorityCitation>,
severed: bool, severed: bool,
at_secs: u64, at_secs: u64,
) -> Result<Vec<Event>, RekeyError> { ) -> Result<Vec<Event>, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect(); let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
if groups.is_empty() { if groups.is_empty() {
@@ -621,7 +628,7 @@ pub fn build_rekey_chunks(
for (index, group_blobs) in groups.into_iter().enumerate() { for (index, group_blobs) in groups.into_iter().enumerate() {
let rumor = build_rekey_rumor( let rumor = build_rekey_rumor(
rotator.public_key(), rotator_key,
scope, scope,
new_epoch, new_epoch,
prev_epoch, prev_epoch,
@@ -633,7 +640,7 @@ pub fn build_rekey_chunks(
at_secs, at_secs,
)?; )?;
let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?; let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?;
let (wrap, _) = cord01::wrap_seal( let (wrap, _) = cord01::wrap_seal(
&seal, &seal,
group, group,
@@ -731,14 +738,17 @@ pub fn dissolved_tombstone_rumor(
) )
} }
pub fn seal_dissolved( pub async fn seal_dissolved<S>(
rumor: &UnsignedEvent, rumor: &UnsignedEvent,
community_id: &CommunityId, community_id: &CommunityId,
owner: &Keys, owner: &S,
at_secs: u64, at_secs: u64,
) -> Result<Event, RekeyError> { ) -> Result<Event, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let group = dissolved_group_key(community_id).map_err(crypto_error)?; let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?; let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
let (wrap, _) = cord01::wrap_seal( let (wrap, _) = cord01::wrap_seal(
&seal, &seal,
&group, &group,
@@ -1059,7 +1069,7 @@ mod tests {
let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives");
let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY); let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY);
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1070,7 +1080,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 1); assert_eq!(chunks.len(), 1);
@@ -1269,26 +1279,26 @@ mod tests {
content: String, content: String,
at_secs: u64, at_secs: u64,
) -> Event { ) -> Event {
writer smol::block_on(writer.publish(
.publish( owner,
owner, Edition {
Edition { subkind,
subkind, entity,
entity, content: &content,
content: &content, head: None,
head: None, citation: None,
citation: None, },
}, at_secs,
at_secs, ))
) .expect("publishes")
.expect("publishes") .0
.0
} }
#[test] #[test]
fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() { fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() {
let owner = Keys::generate(); let owner = Keys::generate();
let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints"); let minted =
smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints");
let community_id = minted.identity.community_id; let community_id = minted.identity.community_id;
let read = let read =
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"); control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives");
@@ -1426,7 +1436,7 @@ mod tests {
}) })
.collect(); .collect();
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1437,7 +1447,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 1, "a full send chunk is one event"); assert_eq!(chunks.len(), 1, "a full send chunk is one event");
@@ -1452,7 +1462,7 @@ mod tests {
wrapped: "x".to_owned(), wrapped: "x".to_owned(),
}); });
let chunks = build_rekey_chunks( let chunks = smol::block_on(build_rekey_chunks(
&rotator, &rotator,
&group, &group,
scope, scope,
@@ -1463,7 +1473,7 @@ mod tests {
None, None,
false, false,
AT, AT,
) ))
.expect("builds"); .expect("builds");
assert_eq!(chunks.len(), 2, "one over the cap splits across two events"); assert_eq!(chunks.len(), 2, "one over the cap splits across two events");
@@ -1485,8 +1495,13 @@ mod tests {
content: "{}", content: "{}",
at_secs: AT, at_secs: AT,
}); });
let seal = let seal = smol::block_on(cord01::build_seal(
cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals"); &rumor,
SealForm::Plaintext,
&prior_read,
&owner,
))
.expect("seals");
let refounding = plan_refounding(Epoch(1)).expect("plans"); let refounding = plan_refounding(Epoch(1)).expect("plans");
let read = refounding.read(&community_id).expect("derives"); let read = refounding.read(&community_id).expect("derives");
@@ -1507,8 +1522,13 @@ mod tests {
assert_eq!(reopened.author, owner.public_key()); assert_eq!(reopened.author, owner.public_key());
// Only a plaintext seal can be carried forward. // Only a plaintext seal can be carried forward.
let encrypted = let encrypted = smol::block_on(cord01::build_seal(
cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals"); &rumor,
SealForm::Encrypted,
&prior_read,
&owner,
))
.expect("seals");
assert!(matches!( assert!(matches!(
compact(&[encrypted], &read, &signer, AT + 1), compact(&[encrypted], &read, &signer, AT + 1),
Err(RekeyError::Stream(StreamError::NotRewrappable)) Err(RekeyError::Stream(StreamError::NotRewrappable))
@@ -1527,7 +1547,8 @@ mod tests {
}; };
let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT); let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT);
let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals"); let wrap =
smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals");
assert!(verify_dissolved(&wrap, &identity)); assert!(verify_dissolved(&wrap, &identity));
assert_eq!( assert_eq!(
@@ -1538,18 +1559,18 @@ mod tests {
// Anyone holding the community id finds the address, but only the committed // Anyone holding the community id finds the address, but only the committed
// owner's signature counts. // owner's signature counts.
let impostor = Keys::generate(); let impostor = Keys::generate();
let forged = seal_dissolved( let forged = smol::block_on(seal_dissolved(
&dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT), &dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT),
&community_id, &community_id,
&impostor, &impostor,
AT, AT,
) ))
.expect("seals"); .expect("seals");
assert!(!verify_dissolved(&forged, &identity)); assert!(!verify_dissolved(&forged, &identity));
// The spec's all-zero `eid` is refused: it would let one owner's genuine // The spec's all-zero `eid` is refused: it would let one owner's genuine
// tombstone be re-wrapped at another of their communities and kill it. // tombstone be re-wrapped at another of their communities and kill it.
let zeroed = seal_dissolved( let zeroed = smol::block_on(seal_dissolved(
&cord01::build_rumor_secs( &cord01::build_rumor_secs(
KIND_CONTROL, KIND_CONTROL,
owner.public_key(), owner.public_key(),
@@ -1563,7 +1584,7 @@ mod tests {
&community_id, &community_id,
&owner, &owner,
AT, AT,
) ))
.expect("seals"); .expect("seals");
assert!(matches!( assert!(matches!(
open_dissolved(&zeroed, &community_id), open_dissolved(&zeroed, &community_id),
+5 -1
View File
@@ -461,7 +461,11 @@ mod tests {
at_ms, at_ms,
None, None,
); );
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0); relay.insert(
smol::block_on(seal_rumor(&rumor, &group, &author, false))
.expect("seals")
.0,
);
} }
let mut seen = BTreeSet::new(); let mut seen = BTreeSet::new();
+280
View File
@@ -0,0 +1,280 @@
# Concord backend audit and simplification plan
Audit of `crates/concord`, triggered by `CommunityRegistry` never reaching
`subscribe`: `sync::load` found zero community state documents. Tracing that
surfaced two separate things: the app only uses a fraction of the crate, and the
crate's writers take a concrete `nostr::Keys`, which the app's signer can never
produce.
Sizes: ~10,500 lines total — ~6,750 production, ~3,750 tests.
## Decisions taken
- **D1 — Keep the unwired protocol surface.** `cord05`/`cord06`/`pins`/paging
stay in the tree for future use. No mass deletion. (Findings are recorded in
§4 for reference only.)
- **D2 — Replace `&Keys` with a signer boundary** for account-key operations.
Verified feasible against the pinned SDK; design in §2.
---
## 1. `&Keys` cannot be replaced by a public key — but it can be replaced by a signer
The original question was whether functions like `genesis` only need
`signer.get_public_key_async()`. They do not: they sign.
- `cord02::genesis` (`cords/cord02/mod.rs:117`) → `seal_edition` (`:711`) →
`build_seal` (`cord01.rs:217`), which signs the seal (`.finalize(author)`,
`cord01.rs:226`), and `wrap_seal_with` (`:247`), which signs the wrap.
- Self-addressed documents use NIP-44 to self: `seal_to_self`
(`cord01.rs:201`) derives a conversation key from `keys.secret_key()`.
A public key can produce neither a Schnorr signature nor an ECDH key, so
"public-key-only" is impossible. The real defect is the **concrete type**: the
app holds `state::UniversalSigner` (async, possibly NIP-46), and a `nostr::Keys`
can never be conjured from it. `docs/concord-usage.md:535-536` already records
this as a deliberate migration pass.
### What the pinned SDK actually provides
Pinned rev `b230cec` (`nostr` 0.45.4 / `nostr-sdk` 0.45.2):
- There is **no `NostrSigner` trait in this revision.** The async signer surface
is three traits, all in the `nostr` crate:
- `AsyncGetPublicKey``nostr/src/key/public_key.rs:39`
- `AsyncSignEvent``nostr/src/event/mod.rs:366`
- `AsyncNip44``nostr/src/nips/nip44/traits.rs:30`
- `Keys` implements all three (`nostr/src/key/mod.rs:298,309,342`), so tests and
local key holders keep working.
- `UniversalSigner` already implements all three with
`Error = UniversalSignerError` (`crates/state/src/signer.rs:148-191`).
- SDK helpers accept them:
- `EventBuilder::finalize_async``S: AsyncGetPublicKey + AsyncSignEvent + ?Sized`
(`nostr/src/event/builder.rs:171-193`)
- `GiftWrapBuilder::finalize_async``S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44`
(`nostr/src/nips/nip59.rs:334-355`)
- `UnwrappedGift::from_gift_wrap_async``T: AsyncNip44` (`nip59.rs:84-90`)
So the answer is yes: pass a signer. `UniversalSigner` works as-is.
### Per-function bounds, not a bundle
Each function should request only the capabilities it uses. The SDK itself is
designed this way (`UnsignedEvent::finalize_async` takes only `AsyncSignEvent`,
`EventBuilder::finalize_async` takes `AsyncGetPublicKey + AsyncSignEvent`,
NIP-59 takes all three).
| Operation | Bounds |
| --- | --- |
| Sign a seal/edition/rekey wrap, author already known | `AsyncSignEvent` |
| Build an event where the author comes from the signer | `AsyncGetPublicKey + AsyncSignEvent` |
| To-self documents (Community List, Invite List) | `AsyncGetPublicKey + AsyncNip44`, plus `AsyncSignEvent` when the document is itself an event |
| Decrypt-only (`parse_list_event`, `unwrap_direct_invite`) | `AsyncNip44` |
| Rekey blob encrypt (`build_blob`) | `AsyncGetPublicKey + AsyncNip44` (no signing) |
| Rekey blob open (`open_blob`) | `AsyncNip44` |
| Direct invite build (`GiftWrapBuilder`) | all three |
Use generics (`S: AsyncSignEvent + ?Sized`), never `&dyn`: the traits carry
associated `Error` types, so `dyn AsyncSignEvent` would force the concrete error
at every call site (`dyn AsyncSignEvent<Error = UniversalSignerError>`),
defeating the abstraction. The SDK uses generics throughout for this reason.
Do **not** define a supertrait bundle
`trait Signer: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 {}`: all three
supertraits declare an associated `Error`, so `Self::Error` becomes ambiguous,
and the bundle forces NIP-44 onto purely-signing callers (and vice versa).
Inside concord, replace `builder.finalize(&keys)` with
`builder.finalize_async(signer).await`. `finalize_async` fetches the signer's
public key and uses it as the event author, exactly as `finalize` did, so the
bytes are unchanged for every caller that passes a matching signer.
We deliberately **do not** pre-check the author against `rumor.pubkey` inside
`build_seal`. The seal's author is the signer's own public key, matching the old
`finalize` semantics; a signer that does not match the rumor is still caught by
`open_wrap_at` as `AuthorMismatch` (`cord01.rs:328`). Pre-checking would also
make it impossible to construct the hostile seals the cord suite relies on as
test vectors (`cord01.rs` `hostile_wraps_are_dropped_in_order`).
If the repeated `<S as ...>::Error: Error + Send + Sync + 'static` bounds
become too noisy, the only stable-Rust way to shorten them is an owned
error-erased trait (as the app already does with
`crates/state/src/signer.rs:64-138`). That trades precision for brevity; keep
per-function bounds unless the noise proves unmanageable.
### What must NOT go through the signer
- **Group-key NIP-44.** `cord01::{seal_bytes, open_bytes, wrap_seal,
wrap_seal_with, rewrap_seal}` encrypt under a `ConversationKey` derived from
HKDF group secrets. `AsyncNip44` can only ECDH against a public key, so group
encryption stays on `ConversationKey` / `GroupKey::keys()`.
- **Wrap signatures.** Wraps are signed by the derived group signer key
(`GroupKey::keys()`), not the account.
- **Locally held raw secrets.** `cord05::{build_bundle_event, build_revocation}`
take a generated `link_signer` whose secret the app stores as
`signer_sk` (`docs/concord-usage.md:306-321`). `&Keys` is correct there; the
app has the secret itself.
- **Local database artifacts.** `store::{cache_rumor, save_state}` sign with the
internal random `LOCAL_KEYS` (`store.rs:18`). No user signer involved.
### Call-site inventory
Account-key sites to migrate:
| Site | Today | After | Bounds |
| --- | --- | --- | --- |
| `cord02::genesis` (`cord02/mod.rs:117`) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `ControlWriter::{publish, set_*}` (`cord02/mod.rs:214-425`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `seal_edition` (`cord02/mod.rs:711`, internal) | `owner: &Keys` | `owner: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::build_seal` (`cord01.rs:217`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord01::{seal_to_self, open_to_self}` (`:201,209`) | `keys: &Keys` | `&S`, async | `AsyncGetPublicKey + AsyncNip44` |
| `guestbook::seal_rumor` (`guestbook.rs:186`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `cord03::seal_rumor` (`cord03.rs:295`) | `author: &Keys` | `author: &S` | `AsyncGetPublicKey + AsyncSignEvent` |
| `list::build_list_event` (`list.rs:186`) | `keys: &Keys` | `keys: &S` | all three |
| `list::parse_list_event` (`list.rs:197`) | `keys: &Keys` | `keys: &S` | `AsyncGetPublicKey + AsyncNip44` |
| `cord05::{build_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | `&S` (stage 3) | build: all three; unwrap: `AsyncNip44` |
| `cord05::{build_invite_list, parse_invite_list}` (`:593,604`) | `keys: &Keys` | **done** | build: all three; parse: `AsyncGetPublicKey + AsyncNip44` |
| `cord06::build_blob` (`:302`) | `rotator: &Keys` | `rotator: &S` (stage 3) | `AsyncGetPublicKey + AsyncNip44` |
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` |
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all
`cord01` wrap functions, `GroupKey::keys()`, `store::LOCAL_KEYS`.
### Async ripple and tests
Every migrated function becomes `async`. `smol` is already a dev-dependency of
concord (`crates/concord/Cargo.toml:21-23`), so affected `#[test]`s become
`smol::block_on(...)` wrappers. The app's call sites are already async
background tasks.
### Known constraints
- `GiftWrapBuilder::finalize_async` and `UnwrappedGift::from_gift_wrap_async`
are generic over `S: Sized` (no `?Sized`), so those functions must stay
generic, never `&dyn`.
- Every converted `S::Error` must be `Error + Send + Sync + 'static` for the
SDK helpers' `Error::other` (`nostr/src/error.rs:100-105`) and for
`anyhow`; `Keys::AsyncGetPublicKey::Error = Infallible`,
`Keys::AsyncSignEvent::Error = nostr::Error`, `UniversalSignerError`
(`crates/state/src/signer.rs:10-32`) all qualify.
- `AsyncGetPublicKey` is worth requiring alongside `AsyncSignEvent` wherever the
author is embedded in the payload: `sign_event` signs the id of the given
unsigned event without rewriting its pubkey, so a mismatched signer is only
caught later by signature verification.
---
## 2. Migration plan
### Phase 1 — replace `&Keys` with per-function signer bounds (no behavior change) — DONE
1. No new module: change the signatures listed in the inventory table to
generics over the SDK traits (`S: AsyncGetPublicKey + AsyncSignEvent`,
`S: AsyncSignEvent`, `S: AsyncGetPublicKey + AsyncNip44`, or `S: AsyncNip44`).
2. Migrate the live path only: `cord01::build_seal`, `cord01::{seal_to_self,
open_to_self}`, `seal_edition`, `genesis`, `ControlWriter`, `guestbook::
seal_rumor`, `cord03::seal_rumor`, `list::{build,parse}_list_event`.
3. Update `docs/concord-usage.md` examples to take a signer.
4. Update concord tests to `smol::block_on`; `&Keys` keeps working because it
implements all three traits.
Validation: `cargo test -p concord` — 46 passed, 0 failed. The one behavior
change from the plan sketch is the dropped up-front author check in §1.
`cord01::{seal_to_self, open_to_self}` now take `&str` and return `String`
(NIP-44 is UTF-8 text), so the `list` and invite-list callers read the plaintext
with `serde_json::from_str`.
**Unplanned but forced:** `cord01::{build_seal, seal_to_self, open_to_self}` are
shared helpers, so the unwired callers had to be migrated in the same pass to
keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
`cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and
group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3.
### Phase 2 — app uses the signer
1. `CommunityRegistry::create(&signer, …)` works with `UniversalSigner` directly
— no secret exposure. This is the change that makes `subscribe` fire.
2. Remove the app-side reimplementation of `list::parse_list_event`
(`crates/community/src/sync.rs:154-156`) now that it accepts a signer.
### Phase 3 — migrate the remaining unwired writers
`cord05` direct invite / invite list, `cord06` blob/rekey/dissolved, when (or
before) the flows that use them are wired. The helpers already force the
`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and
`async` (see Phase 1); what remains is `cord05::{build_direct_invite,
unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the
`Sized` generics (no `&dyn`) for the NIP-59 paths.
### Phase 4 — duplication and hygiene (independent, low risk)
1. Add `store::load_states(client)` and delete the app-side state-document scan
(`crates/community/src/sync.rs:100-137`).
2. Export the `concord/` state prefix from concord; delete the app-side copies
(`store.rs:26`, `sync.rs:15-16`).
3. Collapse the duplicated tag parsers (`cord03.rs:614-654` vs
`guestbook.rs:496-530`) and the identical `ChatError`/`GuestbookError`
enums.
4. Remove never-varied parameters where the change is local: `banned_at` from
`complete_memberlist` (doc admits "empty today"), `cache_rumor -> Result<()>`
once nothing reads the bool, `snapshot_authority`/`ephemeral`/`query_rumors
(until)` if no scheduled flow needs them.
5. Tighten visibility of internal-only `pub` items in `cord04`
(`edition_hash`, `fold`, `FoldResult`, `bootstrap_head`, `HeadSelection`,
`parse_banlist`, `Role::parse`, `Grant::parse`).
6. Fix doc drift: `backfill` arity (`docs/concord-usage.md:212`), `save_state`
parameter (`:487`), `init` signature (`:431-432`), and refresh the "Not wired
up yet" section (`:528-545`) once Phase 2 lands.
---
## 3. Retained-by-decision surface (reference only)
Per D1 these stay, but they should be understood as unwired, not live:
| Module | Approx. prod LOC | App use |
| --- | --- | --- |
| `cord06` rotation/refounding/dissolution | ~850 | none |
| `cord05` invites/links/direct/list | ~650 | none (types only, via unused `list::join_material`) |
| `cord04::pins` | ~550 | none |
| `cord03` write path + `fold` + `plane_keys` | ~340 | only `open` / `expiration_of` |
| guestbook / list write paths | ~240 | `open`, `coalesce`, `complete_memberlist`, `is_live` |
| `store` paging / purge / query / load_state | ~180 | `cache_rumor`, `save_state` |
Truly unreferenced even by tests (safe candidates, but kept per D1):
`CommunityInvite::expired`, `GroupKey::pk_hex`, `From<[u8; 32]>` impls,
`CommunityRoles::{roles, is_empty}`.
---
## 4. Non-goals
- No mass deletion of unwired modules (D1).
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01`
envelope semantics.
- No group-key encryption through the signer.
- Tests move only alongside the code they cover.
## 5. Validation
- `cargo test -p concord` after each phase; `cargo test --workspace` before
landing.
- Phase 1 is behavior-preserving: the existing cord test suite is the oracle.
- Phase 2 adds the app-level test: seed a `CommunityState` via
`store::save_state`, drive `CommunityRegistry`, assert a subscription is made
and an inbound wrap folds into the community.
## 6. Immediate unblock
Two options, both app-side:
1. Smallest (no concord change): expose the local `Keys` the account path
already constructs (`crates/state/src/lib.rs:254`) and add
`CommunityRegistry::create` around it.
2. Clean (needs Phase 1): `CommunityRegistry::create(&UniversalSigner, …)` with
no secret exposure, working for NIP-46 accounts too.
Option 2 is the reason to do Phase 1.
+16 -10
View File
@@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata};
use concord::store::{self, CommunityState, save_state}; use concord::store::{self, CommunityState, save_state};
let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() }; let metadata = CommunityMetadata { name: "Room".into(), ..Default::default() };
let minted = cord02::genesis(&owner_keys, &metadata, now_secs)?; let minted = cord02::genesis(&owner_keys, &metadata, now_secs).await?;
// minted.identity — community_id, owner, owner_salt (verify() recomputes it) // minted.identity — community_id, owner, owner_salt (verify() recomputes it)
// minted.wraps — the two owner-signed genesis editions, already sealed // minted.wraps — the two owner-signed genesis editions, already sealed
@@ -114,7 +114,7 @@ use concord::cord02::guestbook;
let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?; let guestbook = guestbook_group_key(&invite.community_root, &invite.community_id, invite.root_epoch)?;
let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms); let rumor = cord02::guestbook::build_join(my_pk, Some((creator_npub, label)), now_ms);
let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys)?; let (wrap, _) = cord02::guestbook::seal_rumor(&rumor, &guestbook, &my_keys).await?;
client.send_event(&wrap).to(&relays).await?; client.send_event(&wrap).to(&relays).await?;
``` ```
@@ -159,7 +159,7 @@ use concord::derive::channel_group_key;
let plane = channel_group_key(&community_root, &channel, epoch)?; // public channel 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 rumor = build_message(my_pk, &channel, epoch, text, None, at_ms, timer);
let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false)?; let (wrap, wrap_key) = cord03::seal_rumor(&rumor, &plane, &my_keys, false).await?;
client.send_event(&wrap).to(&relays).await?; client.send_event(&wrap).to(&relays).await?;
``` ```
@@ -253,7 +253,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c
let head = control.floors.get(entity).cloned(); let head = control.floors.get(entity).cloned();
let (wrap, new_head) = writer.set_community_metadata( let (wrap, new_head) = writer.set_community_metadata(
&my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs)?; &my_keys, &community_id, &metadata, head.as_ref(), citation, now_secs).await?;
``` ```
`citation` is the `vac` the actor acts under — `None` only for the owner. Build it `citation` is the `vac` the actor acts under — `None` only for the owner. Build it
@@ -405,12 +405,12 @@ A member's own memberships, synced across their devices:
use concord::cord02::list; use concord::cord02::list;
let material = cord02::list::join_material(&invite, staff.then_some(&control_root)); let material = cord02::list::join_material(&invite, staff.then_some(&control_root));
let mut mine = cord02::list::parse_list_event(&my_keys, &event)?; let mut mine = cord02::list::parse_list_event(&my_keys, &event).await?;
mine = cord02::list::merge(mine, cord02::list::CommunityList { mine = cord02::list::merge(mine, cord02::list::CommunityList {
entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }], entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: Default::default() }],
..Default::default() ..Default::default()
}); });
let event = cord02::list::build_list_event(&my_keys, &mine)?; // kind 13302, NIP-44 to self let event = cord02::list::build_list_event(&my_keys, &mine).await?; // kind 13302, NIP-44 to self
``` ```
`is_live(&id)` answers joined-versus-left: a tombstone is terminal until a `is_live(&id)` answers joined-versus-left: a tombstone is terminal until a
@@ -493,8 +493,9 @@ self.consumer = Some(cx.spawn(async move |this, cx| {
- Do the first load in `cx.defer_in(window, ...)` so `init` returns before the - Do the first load in `cx.defer_in(window, ...)` so `init` returns before the
first relay request. first relay request.
- NIP-46 signing is async: call `signer.get_public_key_async()` / - NIP-46 signing is async: call `signer.get_public_key_async()` /
`sign_event_async` inside the background task. The builders still take `sign_event_async` inside the background task. Every account-key writer takes
`&Keys`, so run them where device keys are available. any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await`
it there rather than requiring device keys.
### Subscriptions ### Subscriptions
@@ -532,8 +533,13 @@ client.subscribe(filter).with_id(sub_id).await?;
the plane whose address it carries, and rebuilding a subscription when a plane's the plane whose address it carries, and rebuilding a subscription when a plane's
address changes (join, channel added, rekey folded). GPUI integration above is address changes (join, channel added, rekey folded). GPUI integration above is
the shape to build, not code that exists. the shape to build, not code that exists.
- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate - **Account-key writers take any signer, not `&Keys`.** `genesis`,
pass over the builders, not a per-call patch. `ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are
`async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` /
`AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work.
Group-key and locally-held-secret writers (`cord01` wrap functions,
`cord05::build_bundle_event`, `store`) still take the raw key material they
genuinely need.
- **`crates/chat/src/lib.rs::handle_notifications` treats every kind 1059 event as - **`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 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 that handler must route by subscription id before any concord subscription goes