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() {
ReqTarget::auto(vec![filter])
} else {
+39 -19
View File
@@ -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<Vec<u
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
check_plaintext_cap(plaintext.len())?;
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub fn build_seal(
pub async fn build_seal<S>(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
author: &S,
) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string()))
}
@@ -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 {
+20 -9
View File
@@ -183,18 +183,21 @@ pub fn build_snapshot_chunks(
.collect()
}
pub fn seal_rumor(
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
author: &S,
) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
@@ -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))
+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()?;
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<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 {
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
));
+190 -146
View File
@@ -4,7 +4,9 @@ pub mod list;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent,
};
use serde::{Deserialize, Serialize};
use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
@@ -114,17 +116,24 @@ pub struct CommunityGenesis {
pub wraps: Vec<Event>,
}
pub fn genesis(
owner: &Keys,
pub async fn genesis<S>(
owner: &S,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
) -> Result<CommunityGenesis>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let owner_key = owner
.get_public_key_async()
.await
.map_err(|error| anyhow::anyhow!("signer: {error}"))?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
community_id: community_id_of(&owner_key.to_bytes(), &owner_salt),
owner: owner_key,
owner_salt,
};
@@ -167,7 +176,7 @@ pub fn genesis(
let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?);
}
Ok(CommunityGenesis {
@@ -211,12 +220,15 @@ pub struct Edition<'a> {
}
impl ControlWriter {
pub fn publish(
pub async fn publish<S>(
&self,
keys: &Keys,
keys: &S,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
@@ -231,20 +243,23 @@ impl ControlWriter {
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
pub async fn set_community_metadata<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = encode_metadata(metadata)?;
self.publish(
@@ -258,17 +273,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_channel_metadata(
pub async fn set_channel_metadata<S>(
&self,
keys: &Keys,
keys: &S,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -286,16 +305,20 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_role(
pub async fn set_role<S>(
&self,
keys: &Keys,
keys: &S,
role: &Role,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
}
@@ -313,17 +336,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_grant(
pub async fn set_grant<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
grant: &Grant,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = grant.to_content()?;
self.publish(
@@ -337,17 +364,21 @@ impl ControlWriter {
},
at_secs,
)
.await
}
pub fn set_banlist(
pub async fn set_banlist<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries");
}
@@ -366,19 +397,23 @@ impl ControlWriter {
},
at_secs,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
pub async fn set_registry<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
@@ -397,20 +432,24 @@ impl ControlWriter {
},
at_secs,
)
.await
}
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
pub async fn set_pin_list<S>(
&self,
keys: &Keys,
keys: &S,
community_id: &CommunityId,
channel: &ChannelId,
content: &str,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
) -> Result<(Event, EntityHead)>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
self.publish(
keys,
Edition {
@@ -422,6 +461,7 @@ impl ControlWriter {
},
at_secs,
)
.await
}
}
@@ -708,14 +748,17 @@ fn authorized_head<'a>(
selection.head.map(|index| authorized[index])
}
fn seal_edition(
async fn seal_edition<S>(
edition: &UnsignedEvent,
owner: &Keys,
owner: &S,
read: &GroupKey,
signer: &GroupKey,
at_secs: u64,
) -> Result<Event> {
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
) -> Result<Event>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?;
let (wrap, _) = wrap_seal_with(
&seal,
@@ -731,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()
);
}
}
+13 -7
View File
@@ -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<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
author: &S,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
) -> Result<(Event, Keys), ChatError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL
} else {
@@ -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))
));
+6 -4
View File
@@ -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!(
+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()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, json.as_bytes())?;
let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.finalize_async(keys)
.await
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content)?;
let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_slice(&json).map_err(json_error)
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
+61 -40
View File
@@ -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<S>(
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<Vec<Event>, RekeyError> {
) -> Result<Vec<Event>, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?;
let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect();
if groups.is_empty() {
@@ -621,7 +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<S>(
rumor: &UnsignedEvent,
community_id: &CommunityId,
owner: &Keys,
owner: &S,
at_secs: u64,
) -> Result<Event, RekeyError> {
) -> Result<Event, RekeyError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let group = dissolved_group_key(community_id).map_err(crypto_error)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner)?;
let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?;
let (wrap, _) = cord01::wrap_seal(
&seal,
&group,
@@ -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),
+5 -1
View File
@@ -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();
+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};
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