From 90816754943cadedc95300a515f205f835092ae7 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 07:24:01 +0700 Subject: [PATCH] refactor concord --- crates/community/src/lib.rs | 3 - crates/concord/src/cords/cord01.rs | 58 ++-- crates/concord/src/cords/cord02/guestbook.rs | 29 +- crates/concord/src/cords/cord02/list.rs | 39 ++- crates/concord/src/cords/cord02/mod.rs | 336 +++++++++++-------- crates/concord/src/cords/cord03.rs | 20 +- crates/concord/src/cords/cord04/pins.rs | 10 +- crates/concord/src/cords/cord05.rs | 19 +- crates/concord/src/cords/cord06.rs | 101 +++--- crates/concord/src/store.rs | 6 +- docs/concord-simplification-plan.md | 280 ++++++++++++++++ docs/concord-usage.md | 26 +- 12 files changed, 669 insertions(+), 258 deletions(-) create mode 100644 docs/concord-simplification-plan.md diff --git a/crates/community/src/lib.rs b/crates/community/src/lib.rs index 09fa81c2..d2705705 100644 --- a/crates/community/src/lib.rs +++ b/crates/community/src/lib.rs @@ -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() { ReqTarget::auto(vec![filter]) } else { diff --git a/crates/concord/src/cords/cord01.rs b/crates/concord/src/cords/cord01.rs index 8da49232..4da781ec 100644 --- a/crates/concord/src/cords/cord01.rs +++ b/crates/concord/src/cords/cord01.rs @@ -3,8 +3,8 @@ use std::fmt; use data_encoding::BASE64; use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce}; use nostr_sdk::prelude::{ - Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp, - UnsignedEvent, + AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent, + FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent, }; use crate::derive::GroupKey; @@ -198,32 +198,52 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result Result { - seal_bytes( - &ConversationKey::derive(keys.secret_key(), &keys.public_key()) - .map_err(|error| StreamError::Encrypt(error.to_string()))?, - plaintext, - ) +pub async fn seal_to_self(signer: &S, plaintext: &str) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + check_plaintext_cap(plaintext.len())?; + + let address = signer + .get_public_key_async() + .await + .map_err(|error| StreamError::Encrypt(error.to_string()))?; + + signer + .nip44_encrypt_async(&address, plaintext) + .await + .map_err(|error| StreamError::Encrypt(error.to_string())) } -pub fn open_to_self(keys: &Keys, content: &str) -> Result, StreamError> { - open_bytes( - &ConversationKey::derive(keys.secret_key(), &keys.public_key()) - .map_err(|error| StreamError::Decrypt(error.to_string()))?, - content, - ) +pub async fn open_to_self(signer: &S, content: &str) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ + let address = signer + .get_public_key_async() + .await + .map_err(|error| StreamError::Decrypt(error.to_string()))?; + + signer + .nip44_decrypt_async(&address, content) + .await + .map_err(|error| StreamError::Decrypt(error.to_string())) } -pub fn build_seal( +pub async fn build_seal( rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, - author: &Keys, -) -> Result { + author: &S, +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let content = seal_content(rumor, form, group)?; EventBuilder::new(Kind::Custom(form.kind()), content) .custom_created_at(rumor.created_at) - .finalize(author) + .finalize_async(author) + .await .map_err(|error| StreamError::Sign(error.to_string())) } @@ -450,7 +470,7 @@ mod tests { } fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event { - build_seal(rumor, form, &group(0), author).expect("seals") + smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals") } fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event { diff --git a/crates/concord/src/cords/cord02/guestbook.rs b/crates/concord/src/cords/cord02/guestbook.rs index 8e044734..a4002a68 100644 --- a/crates/concord/src/cords/cord02/guestbook.rs +++ b/crates/concord/src/cords/cord02/guestbook.rs @@ -183,18 +183,21 @@ pub fn build_snapshot_chunks( .collect() } -pub fn seal_rumor( +pub async fn seal_rumor( rumor: &UnsignedEvent, group: &GroupKey, - author: &Keys, -) -> Result<(Event, Keys), GuestbookError> { + author: &S, +) -> Result<(Event, Keys), GuestbookError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let kind = rumor.kind.as_u16(); if !is_guestbook_kind(kind) { return Err(GuestbookError::UnknownKind(kind)); } - let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; + let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?; Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?) } @@ -556,7 +559,9 @@ mod tests { } fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor { - let wrap = seal_rumor(rumor, &group(), author).expect("seals").0; + let wrap = smol::block_on(seal_rumor(rumor, &group(), author)) + .expect("seals") + .0; open(&wrap, &group()).expect("opens").1 } @@ -826,7 +831,9 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&bad_ms, &group(), &member).expect("seals").0, + &smol::block_on(seal_rumor(&bad_ms, &group(), &member)) + .expect("seals") + .0, &group() ), Err(GuestbookError::Stream(StreamError::BadMs)) @@ -835,7 +842,9 @@ mod tests { let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT); assert!(matches!( open( - &seal_rumor(&bad_verb, &group(), &member).expect("seals").0, + &smol::block_on(seal_rumor(&bad_verb, &group(), &member)) + .expect("seals") + .0, &group() ), Err(GuestbookError::BadTag(TAG_CONTENT)) @@ -854,7 +863,7 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&ambiguous, &group(), &moderator) + &smol::block_on(seal_rumor(&ambiguous, &group(), &moderator)) .expect("seals") .0, &group() @@ -876,7 +885,9 @@ mod tests { ); assert!(matches!( open( - &seal_rumor(&rumor, &group(), &moderator).expect("seals").0, + &smol::block_on(seal_rumor(&rumor, &group(), &moderator)) + .expect("seals") + .0, &group() ), Err(GuestbookError::BadTag(TAG_SNAP)) diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs index cd807983..2878f8e9 100644 --- a/crates/concord/src/cords/cord02/list.rs +++ b/crates/concord/src/cords/cord02/list.rs @@ -183,25 +183,32 @@ pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { } } -pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result { +pub async fn build_list_event(keys: &S, list: &CommunityList) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, json.as_bytes())?; + let content = cord01::seal_to_self(keys, &json).await?; EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) - .finalize(keys) + .finalize_async(keys) + .await .map_err(crypto_error) } -pub fn parse_list_event(keys: &Keys, event: &Event) -> Result { +pub async fn parse_list_event(keys: &S, event: &Event) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ if event.kind.as_u16() != KIND_COMMUNITY_LIST { 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)] @@ -419,18 +426,21 @@ mod tests { 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!(parse_list_event(&me, &event).expect("parses"), mine); + assert_eq!( + smol::block_on(parse_list_event(&me, &event)).expect("parses"), + mine + ); assert!( - !parse_list_event(&me, &event) + !smol::block_on(parse_list_event(&me, &event)) .expect("parses") .is_live(&id(0x33)) ); // Only the member's own keys open it, and an unreadable list is "no news". 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. let mut held = mine.clone(); @@ -440,8 +450,11 @@ mod tests { .current .extra .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); - let rebuilt = - parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses"); + let rebuilt = smol::block_on(parse_list_event( + &me, + &smol::block_on(build_list_event(&me, &held)).expect("builds"), + )) + .expect("parses"); assert_eq!(rebuilt, held); // The write gate refuses an over-cap or oversized List before publishing. @@ -459,7 +472,7 @@ mod tests { .collect(), ); assert!(matches!( - build_list_event(&me, &crowded), + smol::block_on(build_list_event(&me, &crowded)), Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 )); diff --git a/crates/concord/src/cords/cord02/mod.rs b/crates/concord/src/cords/cord02/mod.rs index 9c8fb876..d6ec362d 100644 --- a/crates/concord/src/cords/cord02/mod.rs +++ b/crates/concord/src/cords/cord02/mod.rs @@ -4,7 +4,9 @@ pub mod list; use std::collections::{BTreeMap, BTreeSet}; use anyhow::{Result, bail}; -use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent}; +use nostr_sdk::prelude::{ + AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent, +}; use serde::{Deserialize, Serialize}; use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; @@ -114,17 +116,24 @@ pub struct CommunityGenesis { pub wraps: Vec, } -pub fn genesis( - owner: &Keys, +pub async fn genesis( + owner: &S, metadata: &CommunityMetadata, at_secs: u64, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let metadata_content = encode_metadata(metadata)?; let owner_salt = random_32()?; + let owner_key = owner + .get_public_key_async() + .await + .map_err(|error| anyhow::anyhow!("signer: {error}"))?; let identity = CommunityIdentity { - community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt), - owner: owner.public_key(), + community_id: community_id_of(&owner_key.to_bytes(), &owner_salt), + owner: owner_key, owner_salt, }; @@ -167,7 +176,7 @@ pub fn genesis( let mut wraps = Vec::with_capacity(editions.len()); for edition in &editions { - wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?); + wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?); } Ok(CommunityGenesis { @@ -211,12 +220,15 @@ pub struct Edition<'a> { } impl ControlWriter { - pub fn publish( + pub async fn publish( &self, - keys: &Keys, + keys: &S, edition: Edition<'_>, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let rumor = build_edition(EditionFields { author: self.author, subkind: edition.subkind, @@ -231,20 +243,23 @@ impl ControlWriter { }); let parsed = parse_edition(&rumor)?; - let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?; + let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?; Ok((wrap, EntityHead::from(&parsed))) } - pub fn set_community_metadata( + pub async fn set_community_metadata( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, metadata: &CommunityMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let content = encode_metadata(metadata)?; self.publish( @@ -258,17 +273,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_channel_metadata( + pub async fn set_channel_metadata( &self, - keys: &Keys, + keys: &S, channel: &ChannelId, metadata: &ChannelMetadata, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if metadata.name.len() > MAX_NAME_BYTES { bail!("channel name exceeds {MAX_NAME_BYTES} bytes"); } @@ -286,16 +305,20 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_role( + pub async fn set_role( &self, - keys: &Keys, + keys: &S, role: &Role, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if role.name.len() > MAX_NAME_BYTES { bail!("role name exceeds {MAX_NAME_BYTES} bytes"); } @@ -313,17 +336,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_grant( + pub async fn set_grant( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, grant: &Grant, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let content = grant.to_content()?; self.publish( @@ -337,17 +364,21 @@ impl ControlWriter { }, at_secs, ) + .await } - pub fn set_banlist( + pub async fn set_banlist( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, banned: &BTreeSet, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { if banned.len() > MAX_BANLIST { bail!("banlist exceeds {MAX_BANLIST} entries"); } @@ -366,19 +397,23 @@ impl ControlWriter { }, at_secs, ) + .await } #[allow(clippy::too_many_arguments)] - pub fn set_registry( + pub async fn set_registry( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, creator: &PublicKey, links: &[PublicKey], head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { let entries: Vec = links .iter() .take(MAX_REGISTRY_LINKS) @@ -397,20 +432,24 @@ impl ControlWriter { }, at_secs, ) + .await } /// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for. #[allow(clippy::too_many_arguments)] - pub fn set_pin_list( + pub async fn set_pin_list( &self, - keys: &Keys, + keys: &S, community_id: &CommunityId, channel: &ChannelId, content: &str, head: Option<&EntityHead>, citation: Option, at_secs: u64, - ) -> Result<(Event, EntityHead)> { + ) -> Result<(Event, EntityHead)> + where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + { self.publish( keys, Edition { @@ -422,6 +461,7 @@ impl ControlWriter { }, at_secs, ) + .await } } @@ -708,14 +748,17 @@ fn authorized_head<'a>( selection.head.map(|index| authorized[index]) } -fn seal_edition( +async fn seal_edition( edition: &UnsignedEvent, - owner: &Keys, + owner: &S, read: &GroupKey, signer: &GroupKey, at_secs: u64, -) -> Result { - let seal = build_seal(edition, SealForm::Plaintext, read, owner)?; +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?; let (wrap, _) = wrap_seal_with( &seal, @@ -731,6 +774,8 @@ fn seal_edition( #[cfg(test)] mod tests { + use nostr_sdk::prelude::Keys; + use super::*; use crate::cord03::{self, build_message, seal_rumor}; use crate::cord04::pins; @@ -768,7 +813,7 @@ mod tests { #[test] fn metadata_and_channel_edits_reach_a_second_client() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -797,33 +842,31 @@ mod tests { .get(minted.channel_id.as_bytes()) .expect("head"); - let (community_wrap, _) = writer - .set_community_metadata( - &owner, - &community_id, - &CommunityMetadata { - relays: vec!["wss://relay.example".to_owned()], - ..metadata("coop two") - }, - Some(community_head), - None, - AT + 1, - ) - .expect("publishes"); - let (channel_wrap, _) = writer - .set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "lobby".to_owned(), - private: false, - ..ChannelMetadata::default() - }, - Some(channel_head), - None, - AT + 2, - ) - .expect("publishes"); + let (community_wrap, _) = smol::block_on(writer.set_community_metadata( + &owner, + &community_id, + &CommunityMetadata { + relays: vec!["wss://relay.example".to_owned()], + ..metadata("coop two") + }, + Some(community_head), + None, + AT + 1, + )) + .expect("publishes"); + let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata( + &owner, + &minted.channel_id, + &ChannelMetadata { + name: "lobby".to_owned(), + private: false, + ..ChannelMetadata::default() + }, + Some(channel_head), + None, + AT + 2, + )) + .expect("publishes"); let mut edited = genesis_editions.clone(); edited.extend(open_all( @@ -875,7 +918,7 @@ mod tests { fn a_delegated_member_edits_metadata_only_under_its_own_grant() { let owner = Keys::generate(); let member = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -896,21 +939,20 @@ mod tests { extra: Extra::default(), }; - let (role_wrap, _) = writer - .publish( - &owner, - Edition { - subkind: vsk::ROLE, - entity: *role_id.as_bytes(), - content: &role.to_content().expect("serializes"), - head: None, - citation: None, - }, - AT + 1, - ) - .expect("publishes"); - let (grant_wrap, _) = writer - .publish( + let (role_wrap, _) = smol::block_on(writer.publish( + &owner, + Edition { + subkind: vsk::ROLE, + entity: *role_id.as_bytes(), + content: &role.to_content().expect("serializes"), + head: None, + citation: None, + }, + AT + 1, + )) + .expect("publishes"); + let (grant_wrap, _) = smol::block_on( + writer.publish( &owner, Edition { subkind: vsk::GRANT, @@ -927,8 +969,9 @@ mod tests { citation: None, }, AT + 2, - ) - .expect("publishes"); + ), + ) + .expect("publishes"); let mut base = open_all(&minted.wraps, &read, &signer.pk()); base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); @@ -959,36 +1002,34 @@ mod tests { }; let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); - let (uncited, _) = member_writer - .publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: None, - }, - AT + 3, - ) - .expect("publishes"); - let (cited, _) = member_writer - .publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: Some(AuthorityCitation { - entity: grant.entity, - version: grant.version, - hash: grant.self_hash, - }), - }, - AT + 4, - ) - .expect("publishes"); + let (uncited, _) = smol::block_on(member_writer.publish( + &member, + Edition { + subkind: vsk::COMMUNITY_METADATA, + entity: *community_id.as_bytes(), + content: &content, + head: Some(head), + citation: None, + }, + AT + 3, + )) + .expect("publishes"); + let (cited, _) = smol::block_on(member_writer.publish( + &member, + Edition { + subkind: vsk::COMMUNITY_METADATA, + entity: *community_id.as_bytes(), + content: &content, + head: Some(head), + citation: Some(AuthorityCitation { + entity: grant.entity, + version: grant.version, + hash: grant.self_hash, + }), + }, + AT + 4, + )) + .expect("publishes"); // Uncited, the edit claims an authority the member never showed. let mut forged = base.clone(); @@ -1023,7 +1064,7 @@ mod tests { #[test] fn a_pin_list_folds_under_its_coordinate_for_a_second_client() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -1042,7 +1083,7 @@ mod tests { AT * 1_000, None, ); - let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals"); let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH) .expect("opens") .0; @@ -1064,17 +1105,16 @@ mod tests { read: read.clone(), signer: signer.clone(), }; - let (pin_wrap, _) = writer - .set_pin_list( - &owner, - &community_id, - &channel, - &content, - None, - None, - AT + 1, - ) - .expect("publishes"); + let (pin_wrap, _) = smol::block_on(writer.set_pin_list( + &owner, + &community_id, + &channel, + &content, + None, + None, + AT + 1, + )) + .expect("publishes"); let mut editions = open_all(&minted.wraps, &read, &signer.pk()); editions.extend(open_all(&[pin_wrap], &read, &signer.pk())); @@ -1109,7 +1149,7 @@ mod tests { #[test] fn the_timer_is_never_guessed_and_the_write_caps_hold() { let owner = Keys::generate(); - let minted = genesis(&owner, &metadata("coop"), AT).expect("mints"); + let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); let community_id = minted.identity.community_id; let owner_pk = owner.public_key(); let (read, signer) = holder(&minted); @@ -1124,10 +1164,16 @@ mod tests { &owner_pk, &community_id, &open_all( - &[writer - .set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1) - .expect("publishes") - .0], + &[smol::block_on(writer.set_community_metadata( + &owner, + &community_id, + metadata, + None, + None, + AT + 1, + )) + .expect("publishes") + .0], &writer.read, &writer.signer.pk(), ), @@ -1163,8 +1209,7 @@ mod tests { .map(|_| Keys::generate().public_key()) .collect(); assert!( - writer - .set_banlist(&owner, &community_id, &banned, None, None, AT + 2) + smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)) .is_err() ); @@ -1179,20 +1224,19 @@ mod tests { assert!(grant.to_content().is_err()); assert!( - writer - .set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "x".repeat(MAX_NAME_BYTES + 1), - private: false, - ..ChannelMetadata::default() - }, - None, - None, - AT + 3, - ) - .is_err() + smol::block_on(writer.set_channel_metadata( + &owner, + &minted.channel_id, + &ChannelMetadata { + name: "x".repeat(MAX_NAME_BYTES + 1), + private: false, + ..ChannelMetadata::default() + }, + None, + None, + AT + 3, + )) + .is_err() ); } } diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs index c75ec13b..bd3d1aef 100644 --- a/crates/concord/src/cords/cord03.rs +++ b/crates/concord/src/cords/cord03.rs @@ -292,19 +292,22 @@ pub fn build_typing( } /// `ephemeral` picks the 21059 wrap, which relays must not store. -pub fn seal_rumor( +pub async fn seal_rumor( rumor: &UnsignedEvent, group: &GroupKey, - author: &Keys, + author: &S, ephemeral: bool, -) -> Result<(Event, Keys), ChatError> { +) -> Result<(Event, Keys), ChatError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let kind = rumor.kind.as_u16(); if !is_chat_kind(kind) { return Err(ChatError::UnknownKind(kind)); } - let seal = build_seal(rumor, SealForm::Encrypted, group, author)?; + let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?; let wrap_kind = if ephemeral { KIND_WRAP_EPHEMERAL } else { @@ -674,7 +677,9 @@ mod tests { } fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { - seal_rumor(rumor, group, author, false).expect("seals").0 + smol::block_on(seal_rumor(rumor, group, author, false)) + .expect("seals") + .0 } fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { @@ -949,7 +954,8 @@ mod tests { // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a // chat rumor however well-formed it looks. - let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals"); + let seal = + smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals"); let (wrap, _) = wrap_seal( &seal, &group, @@ -971,7 +977,7 @@ mod tests { AT, ); assert!(matches!( - seal_rumor(&ghost, &group, &alice, false), + smol::block_on(seal_rumor(&ghost, &group, &alice, false)), Err(ChatError::UnknownKind(3300)) )); diff --git a/crates/concord/src/cords/cord04/pins.rs b/crates/concord/src/cords/cord04/pins.rs index e248d677..cdd18d6a 100644 --- a/crates/concord/src/cords/cord04/pins.rs +++ b/crates/concord/src/cords/cord04/pins.rs @@ -583,7 +583,7 @@ mod tests { at_ms, None, ); - let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals"); open(&wrap, &group(), &channel(), Epoch(0)).expect("opens") } @@ -699,7 +699,7 @@ mod tests { AT_MS + 5_000, None, ); - let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals"); + let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals"); let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel()); @@ -718,7 +718,8 @@ mod tests { AT_MS + 6_000, None, ); - let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals"); + let (wrap, _) = + smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals"); let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel()); @@ -805,7 +806,8 @@ mod tests { None, AT_MS + 1_000, ); - let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals"); + let (wrap, _) = + smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals"); let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens"); assert_eq!( diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 4550fd94..4736ad1d 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -590,25 +590,32 @@ pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList } } -pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result { +pub async fn build_invite_list(keys: &S, list: &InviteList) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized, +{ list.fits()?; let json = serde_json::to_string(list).map_err(json_error)?; - let content = cord01::seal_to_self(keys, json.as_bytes())?; + let content = cord01::seal_to_self(keys, &json).await?; EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content) - .finalize(keys) + .finalize_async(keys) + .await .map_err(crypto_error) } -pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result { +pub async fn parse_invite_list(keys: &S, event: &Event) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ if event.kind.as_u16() != KIND_INVITE_LIST { return Err(InviteError::Kind(event.kind.as_u16())); } - let json = cord01::open_to_self(keys, &event.content)?; + let json = cord01::open_to_self(keys, &event.content).await?; - serde_json::from_slice(&json).map_err(json_error) + serde_json::from_str(&json).map_err(json_error) } /// An entry is immutable once minted, so two copies should agree. diff --git a/crates/concord/src/cords/cord06.rs b/crates/concord/src/cords/cord06.rs index ce124970..8f2fe436 100644 --- a/crates/concord/src/cords/cord06.rs +++ b/crates/concord/src/cords/cord06.rs @@ -4,7 +4,10 @@ use std::fmt; use anyhow::Result; use data_encoding::HEXLOWER; use nostr::nips::nip44::v2::ConversationKey; -use nostr_sdk::prelude::{Event, Keys, PublicKey, SecretKey, Tag, Timestamp, UnsignedEvent}; +use nostr_sdk::prelude::{ + AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp, + UnsignedEvent, +}; use serde::{Deserialize, Serialize}; use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError}; @@ -598,8 +601,8 @@ pub fn build_rekey_rumor( } #[allow(clippy::too_many_arguments)] -pub fn build_rekey_chunks( - rotator: &Keys, +pub async fn build_rekey_chunks( + rotator: &S, group: &GroupKey, scope: RekeyScope, new_epoch: Epoch, @@ -609,7 +612,11 @@ pub fn build_rekey_chunks( citation: Option<&AuthorityCitation>, severed: bool, at_secs: u64, -) -> Result, RekeyError> { +) -> Result, RekeyError> +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ + let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?; let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect(); if groups.is_empty() { @@ -621,7 +628,7 @@ pub fn build_rekey_chunks( for (index, group_blobs) in groups.into_iter().enumerate() { let rumor = build_rekey_rumor( - rotator.public_key(), + rotator_key, scope, new_epoch, prev_epoch, @@ -633,7 +640,7 @@ pub fn build_rekey_chunks( at_secs, )?; - let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator)?; + let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?; let (wrap, _) = cord01::wrap_seal( &seal, group, @@ -731,14 +738,17 @@ pub fn dissolved_tombstone_rumor( ) } -pub fn seal_dissolved( +pub async fn seal_dissolved( rumor: &UnsignedEvent, community_id: &CommunityId, - owner: &Keys, + owner: &S, at_secs: u64, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, +{ let group = dissolved_group_key(community_id).map_err(crypto_error)?; - let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?; + let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?; let (wrap, _) = cord01::wrap_seal( &seal, &group, @@ -1059,7 +1069,7 @@ mod tests { let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1070,7 +1080,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 1); @@ -1269,26 +1279,26 @@ mod tests { content: String, at_secs: u64, ) -> Event { - writer - .publish( - owner, - Edition { - subkind, - entity, - content: &content, - head: None, - citation: None, - }, - at_secs, - ) - .expect("publishes") - .0 + smol::block_on(writer.publish( + owner, + Edition { + subkind, + entity, + content: &content, + head: None, + citation: None, + }, + at_secs, + )) + .expect("publishes") + .0 } #[test] fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() { let owner = Keys::generate(); - let minted = genesis(&owner, &CommunityMetadata::default(), AT).expect("mints"); + let minted = + smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints"); let community_id = minted.identity.community_id; let read = control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"); @@ -1426,7 +1436,7 @@ mod tests { }) .collect(); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1437,7 +1447,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 1, "a full send chunk is one event"); @@ -1452,7 +1462,7 @@ mod tests { wrapped: "x".to_owned(), }); - let chunks = build_rekey_chunks( + let chunks = smol::block_on(build_rekey_chunks( &rotator, &group, scope, @@ -1463,7 +1473,7 @@ mod tests { None, false, AT, - ) + )) .expect("builds"); assert_eq!(chunks.len(), 2, "one over the cap splits across two events"); @@ -1485,8 +1495,13 @@ mod tests { content: "{}", at_secs: AT, }); - let seal = - cord01::build_seal(&rumor, SealForm::Plaintext, &prior_read, &owner).expect("seals"); + let seal = smol::block_on(cord01::build_seal( + &rumor, + SealForm::Plaintext, + &prior_read, + &owner, + )) + .expect("seals"); let refounding = plan_refounding(Epoch(1)).expect("plans"); let read = refounding.read(&community_id).expect("derives"); @@ -1507,8 +1522,13 @@ mod tests { assert_eq!(reopened.author, owner.public_key()); // Only a plaintext seal can be carried forward. - let encrypted = - cord01::build_seal(&rumor, SealForm::Encrypted, &prior_read, &owner).expect("seals"); + let encrypted = smol::block_on(cord01::build_seal( + &rumor, + SealForm::Encrypted, + &prior_read, + &owner, + )) + .expect("seals"); assert!(matches!( compact(&[encrypted], &read, &signer, AT + 1), Err(RekeyError::Stream(StreamError::NotRewrappable)) @@ -1527,7 +1547,8 @@ mod tests { }; let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT); - let wrap = seal_dissolved(&rumor, &community_id, &owner, AT).expect("seals"); + let wrap = + smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals"); assert!(verify_dissolved(&wrap, &identity)); assert_eq!( @@ -1538,18 +1559,18 @@ mod tests { // Anyone holding the community id finds the address, but only the committed // owner's signature counts. let impostor = Keys::generate(); - let forged = seal_dissolved( + let forged = smol::block_on(seal_dissolved( &dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT), &community_id, &impostor, AT, - ) + )) .expect("seals"); assert!(!verify_dissolved(&forged, &identity)); // The spec's all-zero `eid` is refused: it would let one owner's genuine // tombstone be re-wrapped at another of their communities and kill it. - let zeroed = seal_dissolved( + let zeroed = smol::block_on(seal_dissolved( &cord01::build_rumor_secs( KIND_CONTROL, owner.public_key(), @@ -1563,7 +1584,7 @@ mod tests { &community_id, &owner, AT, - ) + )) .expect("seals"); assert!(matches!( open_dissolved(&zeroed, &community_id), diff --git a/crates/concord/src/store.rs b/crates/concord/src/store.rs index abf0b613..59da9e79 100644 --- a/crates/concord/src/store.rs +++ b/crates/concord/src/store.rs @@ -461,7 +461,11 @@ mod tests { at_ms, 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(); diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md new file mode 100644 index 00000000..ace944a2 --- /dev/null +++ b/docs/concord-simplification-plan.md @@ -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`), +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 `::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. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index acddea07..320c03fb 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -47,7 +47,7 @@ use concord::cord02::{self, CommunityMetadata}; use concord::store::{self, CommunityState, save_state}; 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.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 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?; ``` @@ -159,7 +159,7 @@ 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) = 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?; ``` @@ -253,7 +253,7 @@ let writer = ControlWriter { author: my_pk, read: read.clone(), signer: signer.c 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)?; + &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 @@ -405,12 +405,12 @@ A member's own memberships, synced across their devices: use concord::cord02::list; 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 { entries: vec![cord02::list::CommunityListEntry { community_id, seed: material.clone(), current: material, added_at: now_ms, extra: 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 @@ -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 first relay request. - NIP-46 signing is async: call `signer.get_public_key_async()` / - `sign_event_async` inside the background task. The builders still take - `&Keys`, so run them where device keys are available. + `sign_event_async` inside the background task. Every account-key writer takes + any signer (`Keys` or the app's `UniversalSigner`) and is `async`, so `await` + it there rather than requiring device keys. ### 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 address changes (join, channel added, rekey folded). GPUI integration above is the shape to build, not code that exists. -- **Every writer takes `&Keys`, not a `NostrSigner`.** NIP-46 is one deliberate - pass over the builders, not a per-call patch. +- **Account-key writers take any signer, not `&Keys`.** `genesis`, + `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 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