update concord backend

This commit is contained in:
2026-09-19 07:46:09 +07:00
parent d3b8fa08de
commit aa3bd71351
5 changed files with 127 additions and 80 deletions
+30 -14
View File
@@ -447,16 +447,19 @@ pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
}) })
} }
pub fn build_direct_invite( pub async fn build_direct_invite<S>(
inviter: &Keys, inviter: &S,
recipient: &PublicKey, recipient: &PublicKey,
invite: &CommunityInvite, invite: &CommunityInvite,
) -> Result<Event, InviteError> { ) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
{
invite.validate()?; invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?; let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json) let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
.finalize_unsigned(inviter.public_key()); 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()])]; 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) GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags) .extra_tags(tags)
.finalize(inviter) .finalize_async(inviter)
.await
.map_err(crypto_error) .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<S>(
wrap: &Event, wrap: &Event,
recipient: &Keys, recipient: &S,
) -> Result<(PublicKey, CommunityInvite), InviteError> { ) -> Result<(PublicKey, CommunityInvite), InviteError>
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?; 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 { if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite")); return Err(InviteError::BadEvent("rumor is not a direct invite"));
@@ -927,7 +937,12 @@ mod tests {
let recipient = Keys::generate(); let recipient = Keys::generate();
let invite = bundle(); 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_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!( assert_ne!(
wrap.pubkey, wrap.pubkey,
@@ -939,13 +954,14 @@ mod tests {
"the k tag is what makes an invite indexable" "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!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id); assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open... // Somebody else's wrap is not ours to open...
let stranger = Keys::generate(); 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. // ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello") let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
@@ -954,7 +970,7 @@ mod tests {
.finalize(&recipient) .finalize(&recipient)
.expect("wraps"); .expect("wraps");
assert!(matches!( assert!(matches!(
unwrap_direct_invite(&wrap, &recipient), smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
Err(InviteError::BadEvent(_)) Err(InviteError::BadEvent(_))
)); ));
} }
+41 -36
View File
@@ -2,11 +2,9 @@ use std::collections::{BTreeMap, BTreeSet};
use std::fmt; use std::fmt;
use anyhow::Result; use anyhow::Result;
use data_encoding::HEXLOWER; use data_encoding::{BASE64, HEXLOWER};
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{ use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncSignEvent, Event, Keys, PublicKey, SecretKey, Tag, Timestamp, AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent,
UnsignedEvent,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -301,34 +299,48 @@ pub fn blob_locator(
)) ))
} }
pub fn build_blob( pub async fn build_blob<S>(
rotator: &Keys, rotator: &S,
recipient: &PublicKey, recipient: &PublicKey,
scope: RekeyScope, scope: RekeyScope,
epoch: Epoch, epoch: Epoch,
new_key: &[u8; 32], new_key: &[u8; 32],
control_pk: Option<&[u8; 32]>, control_pk: Option<&[u8; 32]>,
control_root: Option<&[u8; 32]>, control_root: Option<&[u8; 32]>,
) -> Result<RekeyBlob, RekeyError> { ) -> Result<RekeyBlob, RekeyError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?; 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 { Ok(RekeyBlob {
locator: blob_locator(&rotator.public_key(), recipient, scope, epoch), locator: blob_locator(&rotator_pk, recipient, scope, epoch),
wrapped: seal_to(rotator.secret_key(), recipient, &plaintext)?, wrapped,
}) })
} }
pub fn open_blob( pub async fn open_blob<S>(
recipient: &Keys, recipient: &S,
rotator: &PublicKey, rotator: &PublicKey,
scope: RekeyScope, scope: RekeyScope,
epoch: Epoch, epoch: Epoch,
blob: &RekeyBlob, blob: &RekeyBlob,
community_id: &CommunityId, community_id: &CommunityId,
) -> Result<KeyDelivery, RekeyError> { ) -> Result<KeyDelivery, RekeyError>
let conversation = where
ConversationKey::derive(recipient.secret_key(), rotator).map_err(crypto_error)?; S: AsyncNip44 + ?Sized,
let plaintext = cord01::open_bytes(&conversation, &blob.wrapped)?; {
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) 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) blobs.iter().filter(move |blob| blob.locator == wanted)
} }
fn seal_to(
secret: &SecretKey,
recipient: &PublicKey,
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(cord01::seal_bytes(&conversation, plaintext)?)
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RekeyChunk { pub struct RekeyChunk {
pub rotator: PublicKey, pub rotator: PublicKey,
@@ -868,6 +871,8 @@ fn crypto_error(error: impl fmt::Display) -> RekeyError {
mod tests { mod tests {
use std::collections::BTreeSet; use std::collections::BTreeSet;
use nostr_sdk::prelude::Keys;
use super::*; use super::*;
use crate::cord01::KIND_WRAP; use crate::cord01::KIND_WRAP;
use crate::cord02::{ use crate::cord02::{
@@ -922,16 +927,16 @@ mod tests {
let scope = RekeyScope::Channel(channel()); let scope = RekeyScope::Channel(channel());
let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| { let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| {
open_blob( smol::block_on(open_blob(
keys, keys,
&rotator.public_key(), &rotator.public_key(),
scope, scope,
epoch, epoch,
blob, blob,
&community_id, &community_id,
) ))
}; };
let blob = build_blob( let blob = smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
scope, scope,
@@ -939,7 +944,7 @@ mod tests {
&key, &key,
None, None,
None, None,
) ))
.expect("builds"); .expect("builds");
assert_eq!( assert_eq!(
@@ -972,7 +977,7 @@ mod tests {
.to_bytes(); .to_bytes();
let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| { let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| {
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
RekeyScope::Base, RekeyScope::Base,
@@ -980,7 +985,7 @@ mod tests {
&key, &key,
pk, pk,
root, root,
) ))
.expect("builds") .expect("builds")
}; };
@@ -1053,7 +1058,7 @@ mod tests {
let community_id = community(); let community_id = community();
let blob_for = |recipient: &Keys, key: [u8; 32]| { let blob_for = |recipient: &Keys, key: [u8; 32]| {
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&recipient.public_key(), &recipient.public_key(),
scope, scope,
@@ -1061,7 +1066,7 @@ mod tests {
&key, &key,
None, None,
None, None,
) ))
.expect("builds") .expect("builds")
}; };
let mine = blob_for(&me, [0xAA; 32]); let mine = blob_for(&me, [0xAA; 32]);
@@ -1139,14 +1144,14 @@ mod tests {
.next() .next()
.expect("located"); .expect("located");
assert_eq!( assert_eq!(
open_blob( smol::block_on(open_blob(
&me, &me,
&rotator.public_key(), &rotator.public_key(),
scope, scope,
epoch, epoch,
located, located,
&community_id &community_id
) ))
.expect("opens") .expect("opens")
.new_key, .new_key,
[0xAA; 32] [0xAA; 32]
@@ -1423,7 +1428,7 @@ mod tests {
.map(|_| { .map(|_| {
let member = Keys::generate(); let member = Keys::generate();
build_blob( smol::block_on(build_blob(
&rotator, &rotator,
&member.public_key(), &member.public_key(),
scope, scope,
@@ -1431,7 +1436,7 @@ mod tests {
&[0xCD; 32], &[0xCD; 32],
None, None,
None, None,
) ))
.expect("builds") .expect("builds")
}) })
.collect(); .collect();
-3
View File
@@ -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 cord01;
pub mod cord02; pub mod cord02;
pub mod cord03; pub mod cord03;
+29 -13
View File
@@ -132,10 +132,10 @@ Account-key sites to migrate:
| `cord03::seal_rumor` (`cord03.rs:295`) | `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::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` | | `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` | | `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::build_blob` (`:302`) | `rotator: &Keys` | **done** | `AsyncGetPublicKey + AsyncNip44` |
| `cord06::open_blob` (`:319`) | `recipient: &Keys` | `recipient: &S` (stage 3) | `AsyncNip44` | | `cord06::open_blob` (`:319`) | `recipient: &Keys` | **done** | `AsyncNip44` |
| `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` | | `cord06::{build_rekey_chunks, seal_dissolved}` (`:602,737`) | actor `&Keys` | **done** | `AsyncGetPublicKey + AsyncSignEvent` |
Leave unchanged: `cord05::{build_bundle_event, build_revocation}`, all 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 keep the crate compiling: `cord05::{build_invite_list, parse_invite_list}` and
`cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part). `cord06::{build_rekey_chunks, seal_dissolved}` (Phase 3's mechanical part).
`cord05::{build_direct_invite, unwrap_direct_invite}` and `cord05::{build_direct_invite, unwrap_direct_invite}` and
`cord06::{build_blob, open_blob}` are untouched — they use the NIP-59 and `cord06::{build_blob, open_blob}` were untouched by Phase 1 — they use the NIP-59
group-key paths, not the migrated helpers — and remain `&Keys` for Phase 3. and group-key paths, not the migrated helpers — and were migrated in Phase 3.
### Phase 2 — app uses the signer — DONE ### 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 subscription filter addresses the genesis wraps, `fold` yields the created
community, and an inbound control edit folds over it. 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 `cord05::{build_direct_invite, unwrap_direct_invite}` and
before) the flows that use them are wired. The helpers already force the `cord06::{build_blob, open_blob}` now take a signer. The NIP-59 pair keeps a
`cord05` invite-list and `cord06` rekey/dissolved writers to be generic and `Sized` `S` (`AsyncGetPublicKey + AsyncSignEvent + AsyncNip44` to build,
`async` (see Phase 1); what remains is `cord05::{build_direct_invite, `AsyncNip44` to unwrap) because the SDK's `GiftWrapBuilder::finalize_async` and
unwrap_direct_invite}` and `cord06::{build_blob, open_blob}`, plus keeping the `UnwrappedGift::from_gift_wrap_async` are `Sized`-bounded. The blob pair is
`Sized` generics (no `&dyn`) for the NIP-59 paths. `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) ### 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 mass deletion of unwired modules (D1).
- No changes to frozen HKDF derivations, locators, golden vectors, or `cord01` - 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. - No group-key encryption through the signer.
- Tests move only alongside the code they cover. - Tests move only alongside the code they cover.
+27 -14
View File
@@ -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: A Direct Invite arrives as a NIP-59 gift wrap addressed to the member:
```rust ```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`, 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: encrypted to self, exactly like the Community List:
```rust ```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 { list.entries.push(InviteEntry {
token: HEXLOWER.encode(&token), token: HEXLOWER.encode(&token),
signer_sk: link_signer.secret_key().to_secret_hex(), signer_sk: link_signer.secret_key().to_secret_hex(),
@@ -319,7 +319,7 @@ list.entries.push(InviteEntry {
expires_at: None, expires_at: None,
extra: Default::default(), 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. // Retiring is a tombstone, never a deletion: it beats a stale copy terminally.
list.tombstones.push(InviteTombstone { list.tombstones.push(InviteTombstone {
@@ -356,12 +356,16 @@ let (control_pk, control_root) = match scope {
RekeyScope::Channel(_) => (None, None), RekeyScope::Channel(_) => (None, None),
}; };
let blobs = members let mut blobs = Vec::with_capacity(members.len());
.iter()
.map(|member| { for member in &members {
cord06::build_blob(&my_keys, member, scope, plan.epoch, &new_key, control_pk.as_ref(), control_root) blobs.push(
}) cord06::build_blob(
.collect::<Result<Vec<_>, _>>()?; &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 rekey_group = cord06::rekey_group(scope, &community_root, &community_id, plan.epoch)?;
let wraps = cord06::build_rekey_chunks( let wraps = cord06::build_rekey_chunks(
@@ -375,7 +379,8 @@ let wraps = cord06::build_rekey_chunks(
citation, citation,
false, false,
now_secs, now_secs,
)?; )
.await?;
``` ```
On the receiving side, `cord06::parse_rekey_chunk(&opened)` per wrap, then 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` 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`. 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: Dissolution is owner-only and terminal:
```rust ```rust
let rumor = cord06::dissolved_tombstone_rumor(owner_pk, &community_id, now_secs); 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. // A receiver seals the community read-only on sight.
if cord06::verify_dissolved(&wrap, &identity) { 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 address changes (join, channel added, rekey folded). GPUI integration above is
the shape to build, not code that exists. the shape to build, not code that exists.
- **Account-key writers take any signer, not `&Keys`.** `genesis`, - **Account-key writers take any signer, not `&Keys`.** `genesis`,
`ControlWriter`, the guestbook and chat `seal_rumor`s and the `list` builders are `ControlWriter`, the guestbook and chat `seal_rumor`s, the `list` builders, and
`async` and generic over the SDK's `AsyncGetPublicKey` / `AsyncSignEvent` / the `cord05` invite writers (`build_direct_invite` / `unwrap_direct_invite`,
`AsyncNip44` traits, so a `Keys` and an app `UniversalSigner` both work. `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, Group-key and locally-held-secret writers (`cord01` wrap functions,
`cord05::build_bundle_event`, `store`) still take the raw key material they `cord05::build_bundle_event`, `store`) still take the raw key material they
genuinely need. genuinely need.