From aa3bd71351757f9a798473b2c45c3dcfd735a6db Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 19 Sep 2026 07:46:09 +0700 Subject: [PATCH] update concord backend --- crates/concord/src/cords/cord05.rs | 44 +++++++++++------ crates/concord/src/cords/cord06.rs | 77 +++++++++++++++-------------- crates/concord/src/cords/mod.rs | 3 -- docs/concord-simplification-plan.md | 42 +++++++++++----- docs/concord-usage.md | 41 +++++++++------ 5 files changed, 127 insertions(+), 80 deletions(-) diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs index 4736ad1d..495c5da3 100644 --- a/crates/concord/src/cords/cord05.rs +++ b/crates/concord/src/cords/cord05.rs @@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result { }) } -pub fn build_direct_invite( - inviter: &Keys, +pub async fn build_direct_invite( + inviter: &S, recipient: &PublicKey, invite: &CommunityInvite, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44, +{ invite.validate()?; let json = serde_json::to_string(invite).map_err(json_error)?; - let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json) - .finalize_unsigned(inviter.public_key()); + let author = inviter.get_public_key_async().await.map_err(crypto_error)?; + let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author); let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])]; @@ -469,15 +472,22 @@ pub fn build_direct_invite( GiftWrapBuilder::new(*recipient, rumor) .extra_tags(tags) - .finalize(inviter) + .finalize_async(inviter) + .await .map_err(crypto_error) } -pub fn unwrap_direct_invite( +/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too. +pub async fn unwrap_direct_invite( wrap: &Event, - recipient: &Keys, -) -> Result<(PublicKey, CommunityInvite), InviteError> { - let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?; + recipient: &S, +) -> Result<(PublicKey, CommunityInvite), InviteError> +where + S: AsyncNip44, +{ + let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap) + .await + .map_err(crypto_error)?; if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE { return Err(InviteError::BadEvent("rumor is not a direct invite")); @@ -927,7 +937,12 @@ mod tests { let recipient = Keys::generate(); let invite = bundle(); - let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds"); + let wrap = smol::block_on(build_direct_invite( + &inviter, + &recipient.public_key(), + &invite, + )) + .expect("builds"); assert_eq!(wrap.kind, Kind::GiftWrap); assert_ne!( wrap.pubkey, @@ -939,13 +954,14 @@ mod tests { "the k tag is what makes an invite indexable" ); - let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps"); + let (sender, opened) = + smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps"); assert_eq!(sender, inviter.public_key()); assert_eq!(opened.community_id, invite.community_id); // Somebody else's wrap is not ours to open... let stranger = Keys::generate(); - assert!(unwrap_direct_invite(&wrap, &stranger).is_err()); + assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err()); // ...and a wrap that opens to some other kind is not an invite. let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello") @@ -954,7 +970,7 @@ mod tests { .finalize(&recipient) .expect("wraps"); assert!(matches!( - unwrap_direct_invite(&wrap, &recipient), + smol::block_on(unwrap_direct_invite(&wrap, &recipient)), Err(InviteError::BadEvent(_)) )); } diff --git a/crates/concord/src/cords/cord06.rs b/crates/concord/src/cords/cord06.rs index 8f2fe436..e7229367 100644 --- a/crates/concord/src/cords/cord06.rs +++ b/crates/concord/src/cords/cord06.rs @@ -2,11 +2,9 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use anyhow::Result; -use data_encoding::HEXLOWER; -use nostr::nips::nip44::v2::ConversationKey; +use data_encoding::{BASE64, HEXLOWER}; use nostr_sdk::prelude::{ - AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp, - UnsignedEvent, + AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent, }; use serde::{Deserialize, Serialize}; @@ -301,34 +299,48 @@ pub fn blob_locator( )) } -pub fn build_blob( - rotator: &Keys, +pub async fn build_blob( + rotator: &S, recipient: &PublicKey, scope: RekeyScope, epoch: Epoch, new_key: &[u8; 32], control_pk: Option<&[u8; 32]>, control_root: Option<&[u8; 32]>, -) -> Result { +) -> Result +where + S: AsyncGetPublicKey + AsyncNip44 + ?Sized, +{ let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?; + let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?; + + let wrapped = rotator + .nip44_encrypt_async(recipient, &BASE64.encode(&plaintext)) + .await + .map_err(crypto_error)?; Ok(RekeyBlob { - locator: blob_locator(&rotator.public_key(), recipient, scope, epoch), - wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?, + locator: blob_locator(&rotator_pk, recipient, scope, epoch), + wrapped, }) } -pub fn open_blob( - recipient: &Keys, +pub async fn open_blob( + recipient: &S, rotator: &PublicKey, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob, community_id: &CommunityId, -) -> Result { - let conversation = - ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?; - let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?; +) -> Result +where + S: AsyncNip44 + ?Sized, +{ + let text = recipient + .nip44_decrypt_async(rotator, &blob.wrapped) + .await + .map_err(crypto_error)?; + let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?; parse_blob_plaintext(&plaintext, scope, epoch, community_id) } @@ -344,15 +356,6 @@ pub fn find_my_blobs<'a>( blobs.iter().filter(move |blob| blob.locator == wanted) } -fn seal_to( - secret: &SecretKey, - recipient: &PublicKey, - plaintext: &[u8], -) -> Result { - let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?; - Ok(cord01::seal_bytes(&conversation, plaintext)?) -} - #[derive(Debug, Clone)] pub struct RekeyChunk { pub rotator: PublicKey, @@ -868,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError { mod tests { use std::collections::BTreeSet; + use nostr_sdk::prelude::Keys; + use super::*; use crate::cord01::KIND_WRAP; use crate::cord02::{ @@ -922,16 +927,16 @@ mod tests { let scope = RekeyScope::Channel(channel()); let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| { - open_blob( + smol::block_on(open_blob( keys, &rotator.public_key(), scope, epoch, blob, &community_id, - ) + )) }; - let blob = build_blob( + let blob = smol::block_on(build_blob( &rotator, &recipient.public_key(), scope, @@ -939,7 +944,7 @@ mod tests { &key, None, None, - ) + )) .expect("builds"); assert_eq!( @@ -972,7 +977,7 @@ mod tests { .to_bytes(); let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| { - build_blob( + smol::block_on(build_blob( &rotator, &recipient.public_key(), RekeyScope::Base, @@ -980,7 +985,7 @@ mod tests { &key, pk, root, - ) + )) .expect("builds") }; @@ -1053,7 +1058,7 @@ mod tests { let community_id = community(); let blob_for = |recipient: &Keys, key: [u8; 32]| { - build_blob( + smol::block_on(build_blob( &rotator, &recipient.public_key(), scope, @@ -1061,7 +1066,7 @@ mod tests { &key, None, None, - ) + )) .expect("builds") }; let mine = blob_for(&me, [0xAA; 32]); @@ -1139,14 +1144,14 @@ mod tests { .next() .expect("located"); assert_eq!( - open_blob( + smol::block_on(open_blob( &me, &rotator.public_key(), scope, epoch, located, &community_id - ) + )) .expect("opens") .new_key, [0xAA; 32] @@ -1423,7 +1428,7 @@ mod tests { .map(|_| { let member = Keys::generate(); - build_blob( + smol::block_on(build_blob( &rotator, &member.public_key(), scope, @@ -1431,7 +1436,7 @@ mod tests { &[0xCD; 32], None, None, - ) + )) .expect("builds") }) .collect(); diff --git a/crates/concord/src/cords/mod.rs b/crates/concord/src/cords/mod.rs index 202ccb31..2a1028c4 100644 --- a/crates/concord/src/cords/mod.rs +++ b/crates/concord/src/cords/mod.rs @@ -1,6 +1,3 @@ -//! One module per CORD document. CORD-07 (audio/video) is unimplemented, and -//! CORD-08's timer rides the Chat and Control planes it edits rather than owning a file. - pub mod cord01; pub mod cord02; pub mod cord03; diff --git a/docs/concord-simplification-plan.md b/docs/concord-simplification-plan.md index c9508ff6..aaaf3f89 100644 --- a/docs/concord-simplification-plan.md +++ b/docs/concord-simplification-plan.md @@ -132,10 +132,10 @@ Account-key sites to migrate: | `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_direct_invite, unwrap_direct_invite}` (`:451,478`) | `inviter`/`recipient: &Keys` | **done** | build: all three (`Sized`); unwrap: `AsyncNip44` (`Sized`) | | `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_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` | +| `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` | | `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` | Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all @@ -190,8 +190,8 @@ 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. +`cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59 +and group-key paths, not the migrated helpers — and were migrated in Phase 3. ### Phase 2 — app uses the signer — DONE @@ -228,14 +228,28 @@ contract the registry depends on: `create` persists a state `load` returns, the subscription filter addresses the genesis wraps, `fold` yields the created community, and an inbound control edit folds over it. -### Phase 3 — migrate the remaining unwired writers +### Phase 3 — migrate the remaining unwired writers — DONE -`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. +`cord05::{build_direct_invite, unwrap_direct_invite}` and +`cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a +`Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build, +`AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and +`UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is +`AsyncGetPublicKey + AsyncNip44` to build and `AsyncNip44` to open, with `?Sized`. + +The blobs forced one behavior change, because a signer's NIP-44 is text-only +(`nip44_encrypt_async(public_key, &str)`) while the blob plaintext is a +fixed-width binary record. `build_blob` now carries that record base64-encoded +inside the NIP-44 envelope and `open_blob` decodes it again. The record layout, +the `locator`, and the envelope are unchanged; only the bytes inside the envelope +differ. There are no golden vectors for blobs and no producer or consumer other +than these two functions, so the round-trip stays self-consistent; cord06 remains +unwired and persists nothing. + +Validation: `cargo test -p concord` — 46 passed, 0 failed (the 80-blob +`a_full_send_chunk_stays_within_a_relay_event` size assertion still holds under +the base64 record). `cargo clippy -p concord --all-targets` and +`cargo fmt -p concord --check` are clean. ### Phase 4 — duplication and hygiene (independent, low risk) @@ -282,7 +296,9 @@ Truly unreferenced even by tests (safe candidates, but kept per D1): - No mass deletion of unwired modules (D1). - No changes to frozen HKDF derivations, locators, golden vectors, or `cord01` - envelope semantics. + envelope semantics. The one exception Phase 3 forced is the blob plaintext + encoding (base64 inside the envelope, see Phase 3); the blob record layout and + `locator` are untouched. - No group-key encryption through the signer. - Tests move only alongside the code they cover. diff --git a/docs/concord-usage.md b/docs/concord-usage.md index 320c03fb..19d295a0 100644 --- a/docs/concord-usage.md +++ b/docs/concord-usage.md @@ -98,7 +98,7 @@ let invite = match cord05::parse_bundle_event(&event, &link.link_signer, &invite A Direct Invite arrives as a NIP-59 gift wrap addressed to the member: ```rust -let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys)?; +let (inviter, invite) = cord05::unwrap_direct_invite(&wrap, &my_keys).await?; ``` Either way the invite carries `community_id`, `owner`, `owner_salt`, @@ -308,7 +308,7 @@ keep it against the token in the member's own Invite List — a local document encrypted to self, exactly like the Community List: ```rust -let mut list = cord05::parse_invite_list(&my_keys, &event)?; +let mut list = cord05::parse_invite_list(&my_keys, &event).await?; list.entries.push(InviteEntry { token: HEXLOWER.encode(&token), signer_sk: link_signer.secret_key().to_secret_hex(), @@ -319,7 +319,7 @@ list.entries.push(InviteEntry { expires_at: None, extra: Default::default(), }); -let event = cord05::build_invite_list(&my_keys, &list)?; // kind 13303 +let event = cord05::build_invite_list(&my_keys, &list).await?; // kind 13303 // Retiring is a tombstone, never a deletion: it beats a stale copy terminally. list.tombstones.push(InviteTombstone { @@ -356,12 +356,16 @@ let (control_pk, control_root) = match scope { RekeyScope::Channel(_) => (None, None), }; -let blobs = members - .iter() - .map(|member| { - cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root) - }) - .collect::, _>>()?; +let mut blobs = Vec::with_capacity(members.len()); + +for member in &members { + blobs.push( + cord06::build_blob( + &my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root, + ) + .await?, + ); +} let rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?; let wraps = cord06::build_rekey_chunks( @@ -375,7 +379,8 @@ let wraps = cord06::build_rekey_chunks( citation, false, now_secs, -)?; +) +.await?; ``` On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then @@ -385,11 +390,15 @@ member finds their delivery with `find_my_blobs` / `open_blob`, and adopts the k only if the plaintext binds to the scope and epoch they expect and its `prevcommit` matches the key they already hold. Two concurrent rotations settle on `fork_winner`. +The blob plaintext is a fixed-width binary record, but a signer's NIP-44 is +text-only, so `build_blob` carries it base64-encoded inside the envelope. +`open_blob` mirrors that, so the record layout and the `locator` are unchanged. + Dissolution is owner-only and terminal: ```rust let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs); -let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs)?; +let wrap = cord06::seal_dissolved(&rumor, &community_id, &my_keys, now_secs).await?; // A receiver seals the community read-only on sight. if cord06::verify_dissolved(&wrap, &identity) { @@ -534,9 +543,13 @@ client.subscribe(filter).with_id(sub_id).await?; address changes (join, channel added, rekey folded). GPUI integration above is the shape to build, not code that exists. - **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. + `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and + the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`, + `build_invite_list` / `parse_invite_list`) and `cord06` blob writers + (`build_blob` / `open_blob`) are `async` and generic over the SDK's + `AsyncGetPublicKey` / `AsyncSignEvent` / `AsyncNip44` traits, so a `Keys` and an + app `UniversalSigner` both work. The NIP-59 paths (`build_direct_invite`, + `unwrap_direct_invite`) stay `Sized` because the SDK's gift-wrap helpers are. Group-key and locally-held-secret writers (`cord01` wrap functions, `cord05::build_bundle_event`, `store`) still take the raw key material they genuinely need.