diff --git a/PLAN.md b/PLAN.md index 22f691dd..bf8c6968 100644 --- a/PLAN.md +++ b/PLAN.md @@ -85,7 +85,8 @@ crates/concord/ src/roles.rs CORD-04 §2–§4: permissions, roles, grants, banlist, delegation fixpoint src/guestbook.rs CORD-02 §5: join/leave/kick/snapshot, coalesce, complete memberlist src/chat.rs CORD-03: channel plane — message/edit/delete/reaction builders + message view - src/invite.rs CORD-05: bundle, link, registry, Invite List, Direct Invite + src/invite.rs CORD-05 §1–§3, §6: bundle, link (naddr + fragment), Direct Invite + src/list.rs CORD-02 §8: the Community List — join material, merge, to-self envelope src/rekey.rs CORD-06: blob codec, continuity, refounding, compaction, dissolution src/store.rs local persistence + opened-rumor cache + history queries ``` @@ -555,30 +556,142 @@ pub struct ChatMessage { gpui type, and a protocol crate does not take a UI dependency for a derived field. They land with the first consumer that renders them. -### 8.5 Invites (`invite.rs`) +### 8.5 Invites (`invite.rs`) — implemented in M6 ```rust -pub struct CommunityInvite { community_id, owner, owner_salt, community_root, root_epoch, - control_pk: Option, channels: Vec, - relays: Vec, name: String, icon: Option, - expires_at: Option, creator_npub: Option, label: Option, - extra: Map } -impl CommunityInvite { pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id - pub fn expired(&self, now_ms: u64) -> bool; } +pub const KIND_BUNDLE: u16 = 33301; +pub const KIND_DIRECT_INVITE: u16 = 3313; +pub const FRAGMENT_VERSION: u8 = 4; // the dictionary generation +pub const MAX_BUNDLE_CHANNELS: usize = 256; +pub const MAX_BOOTSTRAP_RELAYS: usize = 3; +pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40; // attacker-set, so bound it -pub fn build_bundle(token: &[u8; 16], link_signer: &Keys, invite: &CommunityInvite) -> Result; // 33301, d = "" -pub fn build_revocation(link_signer: &Keys) -> Result; // vsk 9 +pub struct ChannelGrant { id: ChannelId, key: Option, epoch: Epoch, name: String, extra } +pub struct CommunityInvite { community_id: CommunityId, owner: PublicKey, owner_salt: String, + community_root: String, root_epoch: Epoch, + control_pk: Option, channels: Vec, + relays: Vec, name: String, icon: Option, + expires_at: Option, creator_npub: Option, + label: Option, extra } +impl CommunityInvite { + pub fn from_bundle_json(json: &str) -> Result; // bound, truncate, validate + pub fn validate(&self) -> Result<(), InviteError>; // recompute community_id + pub fn expired(&self, now_ms: u64) -> bool; +} +pub enum BundleState { Live(Box), Revoked } + +pub fn build_bundle_event(link_signer: &Keys, invite: &CommunityInvite, bundle_key: &[u8; 32]) + -> Result; +pub fn build_revocation(link_signer: &Keys) -> Result; +pub fn parse_bundle_event(event: &Event, expected_signer: &PublicKey, bundle_key: &[u8; 32]) + -> Result; + +pub fn stock_relays() -> Vec; +pub fn encode_fragment(token: &[u8; 16], relays: &[String]) -> Result; +pub fn decode_fragment(fragment: &str) -> Result<([u8; 16], Vec), InviteError>; +pub fn bundle_naddr(link_signer: &PublicKey) -> Result; +pub fn build_invite_url(base: &str, link_signer, token, relays) -> Result; +pub struct ParsedInviteLink { link_signer: PublicKey, token: [u8; 16], + bootstrap_relays: Vec, naddr: String } pub fn parse_link(input: &str) -> Result; -pub fn encode_fragment(relays: &[RelayUrl], token: &[u8; 16]) -> String; // version byte 4, flags, ≤ 3 relays, base64url -pub fn decode_fragment(fragment: &str) -> Result<(Vec, [u8; 16]), InviteError>; -pub fn build_direct_invite(receiver: &PublicKey, invite: &CommunityInvite, signer: &UniversalSigner) -> Task>; // 3313 rumor → 13 seal → k-tagged 1059 + +pub fn build_direct_invite(inviter: &Keys, recipient: &PublicKey, invite: &CommunityInvite) + -> Result; +pub fn unwrap_direct_invite(wrap: &Event, recipient: &Keys) + -> Result<(PublicKey, CommunityInvite), InviteError>; ``` -The link rides `naddr` (`Nip19Coordinate` for kind 33301, link signer, empty `d`) in the path and the token + bootstrap relays in the fragment. A fragment is never sent to a server. The bundle is decrypted with `invite_bundle_key(token)`, and the joiner must recompute `community_id` from `owner` + `owner_salt`. +- The link is `…/invite/#`: `Nip19Coordinate` for `(33301, link_signer, "")` in +the path, and `[version][flags][relays?][token:16]` base64url-no-pad in the fragment, which is +never sent to a server. `parse_link` also accepts the domain-agnostic bare `#`. +- **The fragment's byte layout is frozen and golden-tested** against hand-computed base64url: the +stock set is flag `0x01` with zero relay bytes (and exempt from the 3-relay cap, which applies to +explicit entries), otherwise a count then per-relay dictionary id, `0x00 len host` for a +`wss://`-implied literal, or `0xff len url` verbatim. A version this client will not decode is +fatal in **both** directions, since a legacy link would be decoded against the wrong dictionary. +- The bundle is sealed with `invite_bundle_key(token)` used **directly** as the NIP-44 conversation +key (`ConversationKey::new`, not an ECDH pair) — the one place in the protocol where that is so. +- Trust is the `community_id`, which `validate` recomputes from `owner` + `owner_salt`. The bundle +is attacker-reached input, so it is bounded *before* it is used: an over-count of channels, an +epoch past the ceiling, and a secret that is not 32 bytes of hex are all refused up front. +`expires_at` is deliberately not part of `validate`: a parked invite still renders past expiry, +only joining refuses. +- `channels` and `relays` default to empty rather than being required: a bundle vending no channel +keys omits the field entirely, and a required `Vec` would turn a stale read into a join failure. +A channel `key` is `Option`, because a public channel derives from `community_root` and never +carries one. +- The bundle's `name` is a preview, not authority — the Control fold is — so it is not bounded here. +`relays` **is** truncated to the community cap, because a hostile list is a connect storm. +- `parse_bundle_event` re-checks the author, the empty `d` (an absent `d` is that coordinate, so +absent and empty are both accepted and a non-empty one is fatal), the signature, and the `vsk` +marker before it decrypts anything. `vsk 6` is live, `vsk 9` is the tombstone. +- The Direct Invite is a **standard** NIP-59 giftwrap, built by `nip59::GiftWrapBuilder` and read +by `nip59::UnwrappedGift::from_gift_wrap`, both from nostr: the builder does the ephemeral wrap and +the tweaked timestamps, and the reader verifies the wrap, the seal's signature, and the rumor/seal +author bind, which is exactly the gate set the reference implementation hand-rolls. Everything left +is the rumor kind and the bundle's own validation. The wrap carries `["k","3313"]` so a recipient +can index their invites without decrypting their whole giftwrap inbox, and mirrors `expires_at` +as a NIP-40 tag in seconds. +- The earlier sketch's `signer: &UniversalSigner` is `&Keys`, matching every other builder in the +crate: NIP-46 is one deliberate pass, not a per-milestone patch (see §14.9). -Bounds before allocation: reject a bundle with more than 256 channels, truncate the relay list to 5, refuse an expired one. +**The Invite List (13303) and the Registry (vsk 8) are deferred to M7**, together and for the same +reason: nothing in M6 consumes them. A link's signer is held by its caller, so minting, refreshing +and revoking need no document, and a Registry write whose fold does not exist is dead wire. M7 is +where both become load-bearing — the Registry's aggregate is the Public/Private source of truth and +retiring the last live link is what triggers a Refounding. -### 8.6 Rekeys and refoundings (`rekey.rs`) +### 8.6 The Community List (`list.rs`) — implemented in M6 + +The member's own memberships, `13302` replaceable, NIP-44-encrypted to self. + +```rust +pub const KIND_COMMUNITY_LIST: u16 = 13302; +pub const MAX_MEMBERSHIPS: usize = 50; + +pub struct JoinMaterial { community_id: CommunityId, owner: PublicKey, owner_salt: String, + community_root: String, root_epoch: Epoch, + control_pk: Option, control_root: Option, + channels: Vec, relays: Vec, name: String, extra } +pub struct CommunityListEntry { community_id: CommunityId, seed: JoinMaterial, + current: JoinMaterial, added_at: u64, extra } +pub struct Tombstone { community_id: CommunityId, removed_at: u64, extra } +pub struct CommunityList { entries: Vec, tombstones: Vec, extra } +impl CommunityList { + pub fn is_live(&self, community_id: &CommunityId) -> bool; + pub fn fits(&self) -> Result<(), ListError>; // the write gate +} + +pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial; +pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList; +pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result; +pub fn parse_list_event(keys: &Keys, event: &Event) -> Result; +``` + +- Join material is the bundle's **membership subset**: the link-only fields (icon, expiry, label, +creator) are dropped, and `control_root` is added when the holder is staff, since no bundle carries +it. `join_material` is the one conversion between the two documents. +- The two snapshots solve opposite problems and merge the same way: `seed` keeps the **lower** +`root_epoch` and `current` keeps the **higher**, each anchoring an end of the history so a fresh +device needs no epoch-by-epoch walk. +- An epoch tie breaks on the **lowest canonical bytes of the whole snapshot** — a total order, so +two devices never flap competing republishes. `extra` maps union on the same principle (the lower +canonical value wins a key clash), which keeps the merge order-independent while still preserving +unknown fields. +- `added_at` merges to the newest and `removed_at` likewise, so a re-join legitimately resurrects a +membership while a stale device's republish can never re-add a tombstoned id. **A tombstoned entry +stays in the document** — pruning it would make the merge depend on gossip order — and +`is_live` is what reads the newest of the two timestamps. +- `parse_list_event`'s failure is deliberately meaningful: the caller must read it as "no news" and +merge whatever does arrive, never clobber a populated local list with an absence. +- `fits` is the write gate: over the membership cap or over the NIP-44 plaintext cap, the build is +refused rather than truncating memberships to make it fit. The *join* gate that refuses a 51st +membership is the registry's, and the byte-level cap audit is M8's. +- `13302` is implemented per spec. Vector has retired it for fragmented `33302` (a replaceable kind +holds one event per pubkey, so it cannot shard past the size cap); that remains an interop +follow-up, recorded in §14.1. + +### 8.7 Rekeys and refoundings (`rekey.rs`) ```rust pub enum RekeyScope { Channel(ChannelId), Base } @@ -815,6 +928,7 @@ Each of these has burned a real implementation, or is a documented cross-client - **Enforced in M3:** a Role's `role_id` is its own coordinate and never 0; a Grant's `member` hashes to its coordinate; a `vsk 4` sits at this community's banlist locator; a banned npub's editions are dropped and a grant naming them carries no rank; a revocation carries a citation; the 100-role cap keeps the lowest ids *after* authorization; a below-floor edition is never a candidate. - **Enforced in M4:** the chat plane's encrypted-seal requirement, at both publish and open; `channel` **and** `epoch` strict-equal to the plane whose key opened the wrap; a retired or unregistered rumor kind rejected on both sides; a target bearing tag that appears twice rejected outright; and a delete from anybody other than the message's own author refused by the caller's gate. - **Enforced in M5:** a Kick counts only where the roster admits its actor under `KICK` with a strictly higher rank and a resolvable citation; a Snapshot counts only from the epoch's refounder, with no owner fallback; a guestbook entry more than an hour ahead is dropped, and an out-of-range `ms` or a non-verb `3306` entry is malformed, not interpreted; a duplicated `vac` is rejected outright; and a delete is honored from its target's author unconditionally, or from another actor only where `can_delete` admits them. +- **Enforced in M6:** an invite fragment whose version is not this one is refused in either direction, as is one with a bad count or trailing bytes, and its encoding caps bootstrap relays at three; a bundle past the channel cap, past the epoch ceiling, or carrying a secret that is not 32 bytes of hex is refused before it is used, and one whose `owner` + `owner_salt` does not reproduce its `community_id` is refused outright; a bundle event off its coordinate, off its author, or unsigned is refused, and a tombstone at the coordinate reads as revoked; a Direct Invite's wrap, its seal signature and its rumor/seal author bind are all verified before the bundle is even parsed; and a Community List refuses to build past its membership cap or the NIP-44 plaintext cap. - Still owed to M8's audit: the byte caps, the 256-channel and 25-pin caps, and the write-side counterparts of the caps the folds already apply. ## 13. Milestones @@ -827,7 +941,7 @@ Each of these has burned a real implementation, or is a documented cross-client | M3 | Control fold + roster + metadata/channels | ✅ `cargo test -p concord` (14 tests): the chain fold, its gaps, fork tiebreak, downgrade refusal and compaction dangle are pinned; the delegation fixpoint resolves outward from the owner and refuses escalation, an unauthorized higher version, rank inversion by republish and an uncited revoke; a community minted by one holder has its metadata and channel edits fold for a second holder from the invite keys alone | | M4 | Chat plane | ✅ `cargo test -p concord` (19 tests): a second holder folds a message's reactions, its author's edit and its author's delete, and ignores an edit or a delete from anybody else; a comment's root and parent survive the wire; a foreign channel, a replayed epoch, a plaintext seal, a retired kind and a duplicated target are each rejected; and history pages backwards across a rekey in order | | M5 | Guestbook + member list + moderation | ✅ `cargo test -p concord` (23 tests): a second holder folds joins, leaves, a cited kick and a chunked snapshot into one memberlist in either arrival order, with a ban and a Grant deciding the edges; an uncited, unranked or owner-directed kick and a foreign snapshot are dropped; a future-dated entry, a malformed `ms`, a non-verb `3306`, a duplicated `vac` and a bad snapshot chunk are each refused; and a moderator delete lands only under a citation the roster admits, while a self-delete never asks | -| M6 | Invites + Community List | link mint → fetch → join round-trips; revoked link refuses; direct invite lands in the recipient's giftwrap inbox via the `k` tag; a second device reconstructs membership from 13302 | +| M6 | Invites + Community List | ✅ `cargo test -p concord` (32 tests): the fragment's byte layout is pinned by golden base64url for the stock set, a dictionary mix and a verbatim literal, with a wrong version in either direction, trailing bytes and an over-cap count each fatal; a link round-trips as a full URL and as a bare naddr, and refuses a non-invite; a bundle round-trips while a revocation tombstone reads as revoked, and a wrong token, a squatter's author, a foreign `d`, a forged owner, a malformed secret and an over-cap channel count are each refused; a Direct Invite round-trips to its verified inviter and refuses a stranger's keys and a non-invite rumor; and the Community List keeps the earlier seed and the later current in either merge order, refuses to resurrect a tombstoned id until a newer join outruns it, and rebuilds on a second device with unknown fields intact while refusing an over-cap or oversized list | | M7 | Rekeys + refounding + dissolution | a removed member stops reading after a rekey; continuity and race rules tested; a tombstone seals the community and a foreign-id tombstone is refused | | M8 | Pins + disappearing messages + hardening | pins verify from a keyless reader's view; expiry is refused at ingest and purged by the sweep; the audit of §12 is complete with a test per bullet | @@ -849,11 +963,15 @@ M5 closed at 23 tests, again with no dependency change and `Cargo.lock` untouche What M5 still defers, and to what: the **guestbook's fetch and ingest path** — `coalesce` is fed wraps by its caller, so the plane has no `backfill` twin of `chat::backfill` until §10's sync engine needs one, and no `CommunityState.observed` to persist what it would learn; the **banlist head's timestamp** that `complete_memberlist`'s `banned_at` wants (the fold does not surface it, so an empty map means "every ban is terminal" until the registry plumbs it); and the **`Refound` seed** argument to `complete_memberlist`, which waits for M7 to mint one. +M6 closed at 32 tests, again with no dependency change and `Cargo.lock` untouched — the fragment codec is `data_encoding::BASE64URL_NOPAD` and the Direct Invite is nostr's own `nip59` builder and unwrapper, so nothing new was needed. New: `src/invite.rs` (bundle, link, fragment, Direct Invite) and `src/list.rs` (the Community List). In `stream.rs`, the seal and open primitives were factored out of `seal_content`/`decode_content` as `seal_bytes`/`open_bytes`, so the bundle's raw-token key and the List's to-self envelope share the crate's one NIP-44 shape instead of re-implementing it twice; `edition::TAG_SUBKIND` became public so the invite sub-kind tags do not restate the string. + +What M6 still defers, and to what: the **Invite List (13303) and the Registry (`vsk 8`)**, both to M7 and for the same reason — nothing in M6 consumes them, and a Registry write whose fold does not exist is dead wire, while M7's refounding is what reads the Registry's aggregate as the Public/Private source of truth (see §8.5); the **join gate that refuses a 51st membership** (the registry's, since `fits` protects the write rather than the add); and the byte-level cap audit, which is M8's. + **M2's "created and published" is verified offline**: "published" is the two wraps existing and being openable by the invite keys, not a relay round-trip. There is no registry to publish through until §10, and a relay test would be testing the SDK, not the protocol. ## 14. Open questions and risks -1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. We implement `13302` per spec, enforce the 50-membership cap and pre-publish size check, and treat `33302` as an interop follow-up. Confirm with Armada before writing the multi-device code. +1. **Community List kind.** CORD-02 §8 specifies `13302`, replaceable. Vector has retired it in favour of fragmented `33302`, because a replaceable kind holds one event per pubkey and so cannot shard past the NIP-44 size cap. **M6 implements `13302` per spec**, with the 50-membership cap and the pre-publish size check as the write gate (`list::CommunityList::fits`); `33302` remains an interop follow-up, so a coop member's list is invisible to a Vector device until it lands. Confirm the sharded form with Armada before writing it. 2. **NIP-42 for stream-authored REQs.** Relays that gate kind 1059 by author (for example `ditto-relay`'s `AUTH_KINDS`) need an AUTH event signed by that plane's derived key. `nostr-sdk`'s `Authenticator` is per-client and returns one identity, while a Concord client holds many plane keys — so this cannot be solved by swapping the authenticator. Vector's answer is a dedicated stream-auth responder installed on the client (`community/v2/streamauth`), primed before any relay interaction, which retains the relay's challenge so plane keys registering later can still answer it: a gating relay challenges once per connection and will not re-challenge an authed one, so a responder attached later never gets the chance. Read that module before deciding; the alternative options remain a per-REQ auth hook upstream or documenting the limitation. 3. **`invite_bundle_key` — resolved in M0.** Appendix A.6 was read in full: the raw HKDF output *is* the NIP-44 conversation key, and the derivation is now pinned by a vector. 4. **`pins_locator` has no upstream vector.** Resolved in M0 by minting one from our own implementation and flagging it self-referential in the test. @@ -861,7 +979,7 @@ What M5 still defers, and to what: the **guestbook's fetch and ingest path** — 6. **Local plaintext state.** §9 records the decision. Revisit only if the local database stops being treated as trusted. 7. **Was a `community_id` ever hashed into a tag?** No — it must never appear on the wire. A lint-style test should assert it appears in no published event other than inside an invite bundle and a direct invite. 8. **The Pin List's message-key disclosure has no public API (M8).** CORD-04 §7 Pins let a keyless reader verify a disclosure, which means revealing one message's NIP-44 keys rather than the plane's conversation key. `nostr`'s `nip44::v2::get_message_keys(conversation_key, nonce)` is a private `fn`, and both public entry points (`encrypt_to_bytes_with_nonce`, `decrypt_to_bytes`) take the whole conversation key — so the expansion has to be reproduced as `hkdf::expand_into(conversation_key, nonce, 76 bytes)` plus ChaCha20 and an HMAC-SHA256, exactly as Vector does, and round-tripped against nostr's own `encrypt` in a test. Read CORD-04 §7 in full at M8 before writing it: the reproduction is only worth it once the exact verification the pin must support is settled, and the alternative is contributing a `pub` message-key accessor upstream (we already track git master, so a patch branch or an upstream PR is viable and strictly better than a reproduction we must keep in sync). -9. **A remote signer is not plumbed.** `ControlWriter::publish` and `stream`'s seal builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4–M8 depends on it except the UX of using a remote signer at all. +9. **A remote signer is not plumbed.** `ControlWriter::publish`, `stream`'s seal builders and the invite builders all take `&Keys`. NIP-46 is a stated Vector feature (§10's task slots are already cleared on signer change), but making the writers async over a `NostrSigner` is a change to every builder, so it should be one deliberate pass rather than a patch per milestone. Nothing in M4–M8 depends on it except the UX of using a remote signer at all. 10. **The fold is not incremental.** `fold_control` re-parses and re-folds the whole control edition window on every call, and each fold is up to `2 × entities + 8` passes. That is fine at the caps the spec sets (100 roles, 400-odd grants) and it is the simplest thing that is correct, but if the sync engine ends up calling it per event rather than per batch, the candidate maps and their parse belong in a cache keyed by edition id. Measure before optimizing. ## 15. Test strategy diff --git a/crates/concord/src/edition.rs b/crates/concord/src/edition.rs index cb5fed73..c5e070f4 100644 --- a/crates/concord/src/edition.rs +++ b/crates/concord/src/edition.rs @@ -13,7 +13,7 @@ pub const KIND_CONTROL: u16 = 3308; const EDITION_LABEL: &[u8] = b"vector-community/v1/edition"; -/// Entity types an edition can address (CORD-02 Appendix B). +/// Entity types an edition can address. pub mod vsk { pub const COMMUNITY_METADATA: &str = "0"; pub const ROLE: &str = "1"; @@ -27,11 +27,12 @@ pub mod vsk { pub const PINS: &str = "11"; } -const TAG_SUBKIND: &str = "vsk"; +pub const TAG_SUBKIND: &str = "vsk"; +pub const TAG_CITATION: &str = "vac"; + const TAG_ENTITY: &str = "eid"; const TAG_VERSION: &str = "ev"; const TAG_PREV: &str = "ep"; -pub const TAG_CITATION: &str = "vac"; #[derive(Debug)] pub enum EditionError { diff --git a/crates/concord/src/invite.rs b/crates/concord/src/invite.rs new file mode 100644 index 00000000..9c8a0b57 --- /dev/null +++ b/crates/concord/src/invite.rs @@ -0,0 +1,810 @@ +use std::fmt; + +use data_encoding::BASE64URL_NOPAD; +use nostr::nips::nip01::Coordinate; +use nostr::nips::nip19::{Nip19, Nip19Coordinate}; +use nostr::nips::nip44::v2::ConversationKey; +use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift}; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::control::{ImageRef, MAX_RELAYS}; +use crate::derive::{TOKEN_LEN, verify_community_id}; +use crate::edition::{TAG_SUBKIND, vsk}; +use crate::stream::{self, StreamError}; +use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; + +pub const KIND_BUNDLE: u16 = 33301; +pub const KIND_DIRECT_INVITE: u16 = 3313; +pub const FRAGMENT_VERSION: u8 = 4; +pub const MAX_BUNDLE_CHANNELS: usize = 256; +pub const MAX_BOOTSTRAP_RELAYS: usize = 3; +pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40; + +const FLAG_STOCK_SET: u8 = 0x01; +const INVITE_PATH: &str = "/invite/"; +const TAG_IDENTIFIER: &str = "d"; +const TAG_EXPIRATION: &str = "expiration"; + +const RELAY_DICT: [&str; 4] = [ + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", +]; + +#[derive(Debug)] +pub enum InviteError { + Stream(StreamError), + Json(String), + BadHex(&'static str), + TooManyChannels(usize), + EpochTooLarge(u64), + OwnerMismatch, + BadFragment(&'static str), + BadVersion(u8), + BadLink(&'static str), + BadEvent(&'static str), + Crypto(String), +} + +impl fmt::Display for InviteError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InviteError::Stream(error) => write!(f, "stream: {error}"), + InviteError::Json(error) => write!(f, "json: {error}"), + InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"), + InviteError::TooManyChannels(count) => { + write!( + f, + "bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})" + ) + } + InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"), + InviteError::OwnerMismatch => { + write!(f, "bundle owner does not reproduce its community_id") + } + InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"), + InviteError::BadVersion(version) => { + write!(f, "unsupported invite fragment version {version}") + } + InviteError::BadLink(why) => write!(f, "bad invite link: {why}"), + InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"), + InviteError::Crypto(error) => write!(f, "crypto: {error}"), + } + } +} + +impl std::error::Error for InviteError {} + +impl From for InviteError { + fn from(error: StreamError) -> Self { + InviteError::Stream(error) + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChannelGrant { + pub id: ChannelId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + pub epoch: Epoch, + #[serde(default)] + pub name: String, + #[serde(flatten)] + pub extra: Extra, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommunityInvite { + pub community_id: CommunityId, + pub owner: PublicKey, + pub owner_salt: String, + pub community_root: String, + pub root_epoch: Epoch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_pk: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub channels: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relays: Vec, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Unix **ms**: past it the preview still renders, joining refuses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_npub: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(flatten)] + pub extra: Extra, +} + +impl CommunityInvite { + /// Parse, bound and validate a decrypted bundle, whichever lane carried it. + pub fn from_bundle_json(json: &str) -> Result { + let mut invite: Self = + serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?; + + if invite.channels.len() > MAX_BUNDLE_CHANNELS { + return Err(InviteError::TooManyChannels(invite.channels.len())); + } + + invite.relays.truncate(MAX_RELAYS); + invite.validate()?; + + Ok(invite) + } + + pub fn validate(&self) -> Result<(), InviteError> { + if self.channels.len() > MAX_BUNDLE_CHANNELS { + return Err(InviteError::TooManyChannels(self.channels.len())); + } + + for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) { + if epoch.0 > MAX_BUNDLE_EPOCH { + return Err(InviteError::EpochTooLarge(epoch.0)); + } + } + + let owner_salt = hex32(&self.owner_salt, "owner_salt")?; + hex32(&self.community_root, "community_root")?; + + for channel in &self.channels { + if let Some(key) = &channel.key { + hex32(key, "channel key")?; + } + } + + if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) { + return Err(InviteError::OwnerMismatch); + } + + Ok(()) + } + + pub fn expired(&self, now_ms: u64) -> bool { + self.expires_at.is_some_and(|expires| now_ms > expires) + } +} + +#[derive(Debug, Clone)] +pub enum BundleState { + Live(Box), + Revoked, +} + +pub fn build_bundle_event( + link_signer: &Keys, + invite: &CommunityInvite, + bundle_key: &[u8; 32], +) -> Result { + invite.validate()?; + + let json = serde_json::to_string(invite).map_err(json_error)?; + let content = seal_bundle(bundle_key, &json)?; + + EventBuilder::new(Kind::Custom(KIND_BUNDLE), content) + .tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)]) + .finalize(link_signer) + .map_err(crypto_error) +} + +pub fn build_revocation(link_signer: &Keys) -> Result { + EventBuilder::new(Kind::Custom(KIND_BUNDLE), "") + .tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)]) + .finalize(link_signer) + .map_err(crypto_error) +} + +pub fn parse_bundle_event( + event: &Event, + expected_signer: &PublicKey, + bundle_key: &[u8; 32], +) -> Result { + if event.kind.as_u16() != KIND_BUNDLE { + return Err(InviteError::BadEvent("wrong kind")); + } + + if event.pubkey != *expected_signer { + return Err(InviteError::BadEvent("author is not the link signer")); + } + + if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) { + return Err(InviteError::BadEvent( + "bundle is not at the link's coordinate", + )); + } + + event + .verify() + .map_err(|_| InviteError::BadEvent("signature invalid"))?; + + match first_tag(event, TAG_SUBKIND).as_deref() { + Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked), + Some(vsk::INVITE_LIVE) => {} + _ => return Err(InviteError::BadEvent("unknown or missing bundle marker")), + } + + let json = open_bundle(bundle_key, &event.content)?; + + Ok(BundleState::Live(Box::new( + CommunityInvite::from_bundle_json(&json)?, + ))) +} + +pub fn stock_relays() -> Vec { + RELAY_DICT.iter().map(|relay| relay.to_string()).collect() +} + +pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result { + let stock = relays == RELAY_DICT; + + let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8); + bytes.push(FRAGMENT_VERSION); + + if stock { + bytes.push(FLAG_STOCK_SET); + } else { + bytes.push(0x00); + + let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)]; + bytes.push(bounded.len() as u8); + + for relay in bounded { + match dict_id(relay) { + Some(id) => bytes.push(id), + None => { + let (lead, literal) = match relay.strip_prefix("wss://") { + Some(host) => (0x00, host), + None => (0xff, relay.as_str()), + }; + + if literal.len() > u8::MAX as usize { + return Err(InviteError::BadFragment("relay too long")); + } + + bytes.extend_from_slice(&[lead, literal.len() as u8]); + bytes.extend_from_slice(literal.as_bytes()); + } + } + } + } + + bytes.extend_from_slice(token); + + Ok(BASE64URL_NOPAD.encode(&bytes)) +} + +pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec), InviteError> { + let bytes = BASE64URL_NOPAD + .decode(fragment.trim().as_bytes()) + .map_err(|_| InviteError::BadFragment("not base64url"))?; + + let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?; + + if version != FRAGMENT_VERSION { + return Err(InviteError::BadVersion(version)); + } + + let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?; + + let mut offset = 2; + let mut relays = Vec::new(); + + if flags & FLAG_STOCK_SET != 0 { + relays = stock_relays(); + } else { + let count = *bytes + .get(offset) + .ok_or(InviteError::BadFragment("truncated"))? as usize; + offset += 1; + + if count > MAX_BOOTSTRAP_RELAYS { + return Err(InviteError::BadFragment("too many bootstrap relays")); + } + + for _ in 0..count { + let lead = *bytes + .get(offset) + .ok_or(InviteError::BadFragment("truncated"))?; + offset += 1; + + if (1..=254).contains(&lead) { + if let Some(url) = dict_url(lead) { + relays.push(url.to_string()); + } + + continue; + } + + let len = *bytes + .get(offset) + .ok_or(InviteError::BadFragment("truncated"))? as usize; + offset += 1; + + let end = offset + .checked_add(len) + .ok_or(InviteError::BadFragment("truncated"))?; + + let raw = bytes + .get(offset..end) + .ok_or(InviteError::BadFragment("truncated"))?; + + let text = std::str::from_utf8(raw) + .map_err(|_| InviteError::BadFragment("relay is not utf8"))?; + + relays.push(match lead { + 0x00 => format!("wss://{text}"), + 0xff => text.to_string(), + _ => return Err(InviteError::BadFragment("unknown relay lead byte")), + }); + + offset = end; + } + } + + let end = offset + .checked_add(TOKEN_LEN) + .ok_or(InviteError::BadFragment("truncated"))?; + + let raw = bytes + .get(offset..end) + .ok_or(InviteError::BadFragment("truncated"))?; + + if end != bytes.len() { + return Err(InviteError::BadFragment("trailing bytes")); + } + + let mut token = [0u8; TOKEN_LEN]; + token.copy_from_slice(raw); + + Ok((token, relays)) +} + +pub fn bundle_naddr(link_signer: &PublicKey) -> Result { + let coordinate = Coordinate { + kind: Kind::Custom(KIND_BUNDLE), + public_key: *link_signer, + identifier: String::new(), + }; + + Nip19::Coordinate(Nip19Coordinate { + coordinate, + relays: Vec::new(), + }) + .to_bech32() + .map_err(|_| InviteError::BadLink("invalid naddr")) +} + +pub fn build_invite_url( + base: &str, + link_signer: &PublicKey, + token: &[u8; TOKEN_LEN], + relays: &[String], +) -> Result { + let naddr = bundle_naddr(link_signer)?; + let fragment = encode_fragment(token, relays)?; + + Ok(format!( + "{}{INVITE_PATH}{naddr}#{fragment}", + base.trim_end_matches('/') + )) +} + +#[derive(Debug, Clone)] +pub struct ParsedInviteLink { + /// The bundle coordinate's author. + pub link_signer: PublicKey, + pub token: [u8; TOKEN_LEN], + pub bootstrap_relays: Vec, + /// The bare naddr as it appeared in the link, for the fetch. + pub naddr: String, +} + +pub fn parse_link(input: &str) -> Result { + let (locator, fragment) = input + .trim() + .split_once('#') + .ok_or(InviteError::BadLink("no fragment"))?; + + if fragment.is_empty() { + return Err(InviteError::BadLink("empty fragment")); + } + + let naddr = match locator.find(INVITE_PATH) { + Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'), + None => locator.trim_start_matches("nostr:"), + }; + + let link_signer = signer_from_naddr(naddr)?; + let (token, bootstrap_relays) = decode_fragment(fragment)?; + + Ok(ParsedInviteLink { + link_signer, + token, + bootstrap_relays, + naddr: naddr.to_string(), + }) +} + +pub fn build_direct_invite( + inviter: &Keys, + recipient: &PublicKey, + invite: &CommunityInvite, +) -> Result { + 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 mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])]; + + if let Some(expires_at) = invite.expires_at { + tags.push(Tag::custom( + TAG_EXPIRATION, + [(expires_at / 1000).to_string()], + )); + } + + GiftWrapBuilder::new(*recipient, rumor) + .extra_tags(tags) + .finalize(inviter) + .map_err(crypto_error) +} + +pub fn unwrap_direct_invite( + wrap: &Event, + recipient: &Keys, +) -> Result<(PublicKey, CommunityInvite), InviteError> { + let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?; + + if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE { + return Err(InviteError::BadEvent("rumor is not a direct invite")); + } + + Ok(( + unwrapped.sender, + CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?, + )) +} + +fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result { + Ok(stream::seal_bytes( + &ConversationKey::new(*bundle_key), + json.as_bytes(), + )?) +} + +fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result { + let plaintext = stream::open_bytes(&ConversationKey::new(*bundle_key), content)?; + + String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8")) +} + +fn signer_from_naddr(naddr: &str) -> Result { + match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) { + Ok(Nip19::Coordinate(coordinate)) + if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE + && coordinate.coordinate.identifier.is_empty() => + { + Ok(coordinate.coordinate.public_key) + } + _ => Err(InviteError::BadLink( + "naddr is not an invite-bundle coordinate", + )), + } +} + +fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> { + decode_hex_32(value).map_err(|_| InviteError::BadHex(field)) +} + +fn dict_id(relay: &str) -> Option { + RELAY_DICT + .iter() + .position(|known| *known == relay) + .map(|index| index as u8 + 1) +} + +fn dict_url(id: u8) -> Option<&'static str> { + RELAY_DICT.get(id.checked_sub(1)? as usize).copied() +} + +fn empty_identifier() -> Tag { + Tag::identifier("") +} + +fn subkind_tag(value: &str) -> Tag { + Tag::custom(TAG_SUBKIND, [value]) +} + +fn first_tag(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + let fields = tag.as_slice(); + + (fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone()) + }) +} + +fn json_error(error: serde_json::Error) -> InviteError { + InviteError::Json(error.to_string()) +} + +fn crypto_error(error: impl fmt::Display) -> InviteError { + InviteError::Crypto(error.to_string()) +} + +#[cfg(test)] +mod tests { + use data_encoding::HEXLOWER; + + use super::*; + use crate::derive::{community_id_of, invite_bundle_key}; + + const SALT: [u8; 32] = [0x33u8; 32]; + + fn bundle() -> CommunityInvite { + let owner = Keys::generate(); + + CommunityInvite { + community_id: community_id_of(&owner.public_key().to_bytes(), &SALT), + owner: owner.public_key(), + owner_salt: HEXLOWER.encode(&SALT), + community_root: "44".repeat(32), + root_epoch: Epoch(0), + control_pk: None, + channels: vec![ChannelGrant { + id: ChannelId::from_bytes([0x9cu8; 32]), + key: Some("55".repeat(32)), + epoch: Epoch(1), + name: "lounge".to_owned(), + extra: Extra::default(), + }], + relays: vec!["wss://relay.example".to_owned()], + name: "Test community".to_owned(), + icon: None, + expires_at: None, + creator_npub: None, + label: None, + extra: Extra::default(), + } + } + + fn token16() -> [u8; TOKEN_LEN] { + std::array::from_fn(|i| i as u8) + } + + #[test] + fn fragment_goldens_pin_the_wire_layout() { + let token = token16(); + + // [04 version][01 stock flag][token 00..0f] + let stock = encode_fragment(&token, &stock_relays()).expect("encodes"); + assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P"); + assert_eq!( + decode_fragment(&stock).expect("decodes"), + (token, stock_relays()) + ); + + // [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f] + let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()]; + let encoded = encode_fragment(&token, &mixed).expect("encodes"); + assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P"); + assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed)); + + // [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f] + let verbatim = vec!["ws://h".to_owned()]; + let encoded = encode_fragment(&token, &verbatim).expect("encodes"); + assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P"); + assert_eq!( + decode_fragment(&encoded).expect("decodes"), + (token, verbatim) + ); + } + + #[test] + fn a_fragment_is_strict_about_framing_and_counts() { + let token = token16(); + + for version in [3u8, 5] { + let mut bytes = vec![version, FLAG_STOCK_SET]; + bytes.extend_from_slice(&token); + let encoded = BASE64URL_NOPAD.encode(&bytes); + assert!( + matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version), + "a legacy and a future version are both refused" + ); + } + + let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET]; + trailing.extend_from_slice(&token); + trailing.push(0xff); + assert!(matches!( + decode_fragment(&BASE64URL_NOPAD.encode(&trailing)), + Err(InviteError::BadFragment(_)) + )); + + let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4]; + over.extend_from_slice(&token); + assert!(matches!( + decode_fragment(&BASE64URL_NOPAD.encode(&over)), + Err(InviteError::BadFragment(_)) + )); + + // An unknown dictionary id is skipped, not fatal, so the dictionary can grow. + let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200]; + unknown.extend_from_slice(&token); + let (decoded, relays) = + decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes"); + assert_eq!(decoded, token); + assert!(relays.is_empty()); + + let relays: Vec = (0..4).map(|i| format!("wss://r{i}.example")).collect(); + let (_, capped) = + decode_fragment(&encode_fragment(&token, &relays).expect("encodes")).expect("decodes"); + assert_eq!(capped.len(), MAX_BOOTSTRAP_RELAYS); + } + + #[test] + fn a_link_round_trips_and_refuses_a_non_invite() { + let link_signer = Keys::generate(); + let token = token16(); + let relays = vec!["wss://a.example".to_owned()]; + + let url = build_invite_url( + "https://vectorapp.io/", + &link_signer.public_key(), + &token, + &relays, + ) + .expect("builds"); + + let parsed = parse_link(&url).expect("parses"); + assert_eq!(parsed.link_signer, link_signer.public_key()); + assert_eq!(parsed.token, token); + assert_eq!(parsed.bootstrap_relays, relays); + + let fragment = url.split('#').nth(1).expect("carries a fragment"); + let bare = format!("{}#{fragment}", parsed.naddr); + let reparsed = parse_link(&bare).expect("parses the domain-agnostic form"); + assert_eq!(reparsed.link_signer, link_signer.public_key()); + assert_eq!(reparsed.token, token); + + assert!( + parse_link("https://x/invite/#frag").is_err(), + "the naddr is not optional" + ); + assert!( + parse_link("wss://relay.example.com").is_err(), + "nor the fragment" + ); + } + + #[test] + fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() { + let invite = bundle(); + let link_signer = Keys::generate(); + let key = invite_bundle_key(&[7u8; TOKEN_LEN]); + + let event = build_bundle_event(&link_signer, &invite, &key).expect("builds"); + assert_eq!(event.pubkey, link_signer.public_key()); + + match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") { + BundleState::Live(opened) => { + assert_eq!(opened.community_id, invite.community_id); + assert_eq!(opened.channels.len(), 1); + } + BundleState::Revoked => panic!("expected a live bundle"), + } + + let revocation = build_revocation(&link_signer).expect("builds"); + assert!(matches!( + parse_bundle_event(&revocation, &link_signer.public_key(), &key), + Ok(BundleState::Revoked) + )); + + // The token is the only way in, and a squatter is a different coordinate. + assert!( + parse_bundle_event( + &event, + &link_signer.public_key(), + &invite_bundle_key(&[8u8; TOKEN_LEN]) + ) + .is_err() + ); + let squatter = Keys::generate(); + assert!(matches!( + parse_bundle_event(&event, &squatter.public_key(), &key), + Err(InviteError::BadEvent(_)) + )); + } + + #[test] + fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() { + let invite = bundle(); + let link_signer = Keys::generate(); + let key = invite_bundle_key(&[9u8; TOKEN_LEN]); + let json = serde_json::to_string(&invite).expect("serializes"); + let content = seal_bundle(&key, &json).expect("seals"); + + // The fetch filters on the author, so the empty `d` is pinned here: a + // signature-valid event of the same author at another `d` is not the bundle. + let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content) + .tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)]) + .finalize(&link_signer) + .expect("signs"); + assert!(matches!( + parse_bundle_event(&elsewhere, &link_signer.public_key(), &key), + Err(InviteError::BadEvent(_)) + )); + + let mut forged = bundle(); + forged.owner = Keys::generate().public_key(); + assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch))); + assert!(matches!( + build_bundle_event(&link_signer, &forged, &key), + Err(InviteError::OwnerMismatch) + )); + + let mut malformed = bundle(); + malformed.community_root = "not hex".to_owned(); + assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_)))); + + let mut crowded = bundle(); + crowded.channels = (0..=MAX_BUNDLE_CHANNELS) + .map(|_| ChannelGrant { + id: ChannelId::from_bytes([0x01; 32]), + key: None, + epoch: Epoch(0), + name: String::new(), + extra: Extra::default(), + }) + .collect(); + assert!(matches!( + crowded.validate(), + Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1 + )); + } + + #[test] + fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() { + let inviter = Keys::generate(); + let recipient = Keys::generate(); + let invite = bundle(); + + let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds"); + assert_eq!(wrap.kind, Kind::GiftWrap); + assert_ne!( + wrap.pubkey, + inviter.public_key(), + "the wrap author is ephemeral" + ); + assert!( + wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]), + "the k tag is what makes an invite indexable" + ); + + let (sender, opened) = 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()); + + // ...and a wrap that opens to some other kind is not an invite. + let rumor = EventBuilder::new(Kind::Custom(crate::chat::KIND_MESSAGE), "hello") + .finalize_unsigned(recipient.public_key()); + let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor) + .finalize(&recipient) + .expect("wraps"); + assert!(matches!( + unwrap_direct_invite(&wrap, &recipient), + Err(InviteError::BadEvent(_)) + )); + } +} diff --git a/crates/concord/src/lib.rs b/crates/concord/src/lib.rs index 088dadf6..da4bb3cd 100644 --- a/crates/concord/src/lib.rs +++ b/crates/concord/src/lib.rs @@ -3,6 +3,8 @@ pub mod control; pub mod derive; pub mod edition; pub mod guestbook; +pub mod invite; +pub mod list; pub mod roles; pub mod store; pub mod stream; diff --git a/crates/concord/src/list.rs b/crates/concord/src/list.rs new file mode 100644 index 00000000..0c291440 --- /dev/null +++ b/crates/concord/src/list.rs @@ -0,0 +1,480 @@ +use std::collections::BTreeMap; +use std::collections::btree_map::Entry; +use std::fmt; + +use nostr::nips::nip44::{self, Version}; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::invite::{ChannelGrant, CommunityInvite}; +use crate::stream::NIP44_MAX_PLAINTEXT; +use crate::{CommunityId, Epoch, Extra}; + +pub const KIND_COMMUNITY_LIST: u16 = 13302; +pub const MAX_MEMBERSHIPS: usize = 50; + +#[derive(Debug)] +pub enum ListError { + Kind(u16), + Crypto(String), + Json(String), + TooManyMemberships(usize), + Oversize(usize), +} + +impl fmt::Display for ListError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), + ListError::Crypto(error) => write!(f, "crypto: {error}"), + ListError::Json(error) => write!(f, "json: {error}"), + ListError::TooManyMemberships(count) => { + write!( + f, + "list carries {count} memberships (cap {MAX_MEMBERSHIPS})" + ) + } + ListError::Oversize(len) => { + write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})") + } + } + } +} + +impl std::error::Error for ListError {} + +/// A membership's keys. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JoinMaterial { + pub community_id: CommunityId, + pub owner: PublicKey, + pub owner_salt: String, + pub community_root: String, + pub root_epoch: Epoch, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_pk: Option, + /// Present only when the holder is staff. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub control_root: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub channels: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub relays: Vec, + pub name: String, + #[serde(flatten)] + pub extra: Extra, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommunityListEntry { + pub community_id: CommunityId, + pub seed: JoinMaterial, + pub current: JoinMaterial, + pub added_at: u64, + #[serde(flatten)] + pub extra: Extra, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Tombstone { + pub community_id: CommunityId, + pub removed_at: u64, + #[serde(flatten)] + pub extra: Extra, +} + +/// A member's own memberships. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct CommunityList { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub entries: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tombstones: Vec, + #[serde(flatten)] + pub extra: Extra, +} + +impl CommunityList { + pub fn is_live(&self, community_id: &CommunityId) -> bool { + let added = self + .entries + .iter() + .find(|entry| entry.community_id == *community_id) + .map(|entry| entry.added_at); + + match added { + None => false, + Some(added) => self + .tombstones + .iter() + .find(|tombstone| tombstone.community_id == *community_id) + .is_none_or(|tombstone| added > tombstone.removed_at), + } + } + + pub fn fits(&self) -> Result<(), ListError> { + if self.entries.len() > MAX_MEMBERSHIPS { + return Err(ListError::TooManyMemberships(self.entries.len())); + } + + let json = serde_json::to_string(self).map_err(json_error)?; + + if json.len() > NIP44_MAX_PLAINTEXT { + return Err(ListError::Oversize(json.len())); + } + + Ok(()) + } +} + +pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial { + JoinMaterial { + community_id: invite.community_id, + owner: invite.owner, + owner_salt: invite.owner_salt.clone(), + community_root: invite.community_root.clone(), + root_epoch: invite.root_epoch, + control_pk: invite.control_pk, + control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)), + channels: invite.channels.clone(), + relays: invite.relays.clone(), + name: invite.name.clone(), + extra: Extra::default(), + } +} + +pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { + let mut entries: BTreeMap = BTreeMap::new(); + + for entry in held.entries.into_iter().chain(incoming.entries) { + match entries.entry(entry.community_id) { + Entry::Vacant(slot) => { + slot.insert(entry); + } + Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry), + } + } + + let mut tombstones: BTreeMap = BTreeMap::new(); + + for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) { + match tombstones.entry(tombstone.community_id) { + Entry::Vacant(slot) => { + slot.insert(tombstone); + } + Entry::Occupied(mut slot) => { + let held = slot.get_mut(); + held.removed_at = held.removed_at.max(tombstone.removed_at); + union(&mut held.extra, tombstone.extra); + } + } + } + + let mut extra = held.extra; + union(&mut extra, incoming.extra); + + CommunityList { + entries: entries.into_values().collect(), + tombstones: tombstones.into_values().collect(), + extra, + } +} + +pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result { + list.fits()?; + + let json = serde_json::to_string(list).map_err(json_error)?; + let content = nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + json.as_bytes(), + Version::V2, + ) + .map_err(crypto_error)?; + + EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) + .finalize(keys) + .map_err(crypto_error) +} + +pub fn parse_list_event(keys: &Keys, event: &Event) -> Result { + if event.kind.as_u16() != KIND_COMMUNITY_LIST { + return Err(ListError::Kind(event.kind.as_u16())); + } + + let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(crypto_error)?; + + serde_json::from_str(&json).map_err(json_error) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Snapshot { + Seed, + Current, +} + +fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) { + held.added_at = held.added_at.max(incoming.added_at); + held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); + held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); + union(&mut held.extra, incoming.extra); +} + +fn pick<'a>( + held: &'a JoinMaterial, + incoming: &'a JoinMaterial, + which: Snapshot, +) -> &'a JoinMaterial { + let preferred = match which { + Snapshot::Seed => incoming.root_epoch < held.root_epoch, + Snapshot::Current => incoming.root_epoch > held.root_epoch, + }; + + if preferred { + return incoming; + } + + if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) { + return incoming; + } + + held +} + +fn union(into: &mut Extra, other: Extra) { + for (key, value) in other { + let replace = match into.get(&key) { + Some(existing) => canonical(&value) < canonical(existing), + None => true, + }; + + if replace { + into.insert(key, value); + } + } +} + +fn canonical(value: &T) -> String { + serde_json::to_string(value).unwrap_or_default() +} + +fn json_error(error: serde_json::Error) -> ListError { + ListError::Json(error.to_string()) +} + +fn crypto_error(error: impl fmt::Display) -> ListError { + ListError::Crypto(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> CommunityId { + CommunityId::from_bytes([byte; 32]) + } + + fn material( + community_id: CommunityId, + owner: PublicKey, + name: &str, + epoch: u64, + ) -> JoinMaterial { + JoinMaterial { + community_id, + owner, + owner_salt: "33".repeat(32), + community_root: "44".repeat(32), + root_epoch: Epoch(epoch), + control_pk: None, + control_root: None, + channels: vec![], + relays: vec!["wss://relay.example".to_owned()], + name: name.to_owned(), + extra: Extra::default(), + } + } + + fn entry( + community_id: CommunityId, + seed: JoinMaterial, + current: JoinMaterial, + added_at: u64, + ) -> CommunityListEntry { + CommunityListEntry { + community_id, + seed, + current, + added_at, + extra: Extra::default(), + } + } + + fn list(entries: Vec) -> CommunityList { + CommunityList { + entries, + ..Default::default() + } + } + + fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList { + CommunityList { + tombstones: vec![Tombstone { + community_id, + removed_at, + extra: Extra::default(), + }], + ..Default::default() + } + } + + #[test] + fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() { + let owner = Keys::generate().public_key(); + let older = material(id(0x11), owner, "Room", 1); + let newer = material(id(0x11), owner, "Room", 3); + + let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]); + let b = list(vec![entry(id(0x11), newer, older, 5_000)]); + + for merged in [merge(a.clone(), b.clone()), merge(b, a)] { + let merged = merged.entries.first().expect("one membership"); + assert_eq!( + merged.seed.root_epoch, + Epoch(1), + "seed anchors the earliest epoch held" + ); + assert_eq!(merged.current.root_epoch, Epoch(3)); + assert_eq!(merged.added_at, 5_000); + } + + // An epoch tie breaks on the whole snapshot's bytes, and does so for both + // orders, so two devices never flap competing republishes. + let alpha = material(id(0x11), owner, "Alpha", 2); + let beta = material(id(0x11), owner, "Beta", 2); + let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]); + let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]); + + let first = merge(a.clone(), b.clone()); + assert_eq!(first, merge(b, a)); + assert_eq!(first.entries[0].current.name, "Alpha"); + } + + #[test] + fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() { + let owner = Keys::generate().public_key(); + let joined = entry( + id(0x11), + material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, "Room", 0), + 5_000, + ); + + let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000)); + assert!(!left.is_live(&id(0x11))); + assert_eq!( + left.entries.len(), + 1, + "a retired entry stays in the document" + ); + + // A stale device re-merging the entry cannot resurrect it. + assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); + + // A re-join genuinely newer than the removal does. + let rejoined = list(vec![entry( + id(0x11), + material(id(0x11), owner, "Room", 0), + material(id(0x11), owner, "Room", 0), + 7_000, + )]); + let live = merge(left, rejoined); + assert!(live.is_live(&id(0x11))); + + // And the older removal is not re-applied on top of it. + assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11))); + } + + #[test] + fn a_second_device_reconstructs_membership_from_13302() { + let me = Keys::generate(); + let owner = Keys::generate().public_key(); + let mine = CommunityList { + entries: vec![ + entry( + id(0x11), + material(id(0x11), owner, "Room", 1), + material(id(0x11), owner, "Room", 4), + AT, + ), + entry( + id(0x22), + material(id(0x22), owner, "Other", 0), + material(id(0x22), owner, "Other", 0), + AT + 1, + ), + ], + tombstones: vec![Tombstone { + community_id: id(0x33), + removed_at: AT, + extra: Extra::default(), + }], + extra: Extra::default(), + }; + + let event = 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!( + !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()); + + // Unknown fields survive the round trip, so a republish cannot wipe them. + let mut held = mine.clone(); + held.extra + .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); + held.entries[0] + .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"); + assert_eq!(rebuilt, held); + + // The write gate refuses an over-cap or oversized List before publishing. + let crowded = list( + (0..=MAX_MEMBERSHIPS) + .map(|index| { + let community_id = CommunityId::from_bytes([index as u8; 32]); + entry( + community_id, + material(community_id, owner, "Room", 0), + material(community_id, owner, "Room", 0), + AT, + ) + }) + .collect(), + ); + assert!(matches!( + build_list_event(&me, &crowded), + Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 + )); + + let oversized = list(vec![entry( + id(0x11), + material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), + material(id(0x11), owner, "Room", 0), + AT, + )]); + assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); + } + + const AT: u64 = 1_719_800_000_000; +} diff --git a/crates/concord/src/stream.rs b/crates/concord/src/stream.rs index f1817c07..13fe9b3a 100644 --- a/crates/concord/src/stream.rs +++ b/crates/concord/src/stream.rs @@ -180,10 +180,23 @@ pub fn seal_content( match form { SealForm::Plaintext => Ok(json), - SealForm::Encrypted => Ok(BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?)), + SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()), } } +pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result { + Ok(BASE64.encode(&encrypt(conversation, plaintext)?)) +} + +pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result, StreamError> { + let payload = BASE64 + .decode(content.as_bytes()) + .map_err(|error| StreamError::Decrypt(error.to_string()))?; + + decrypt_to_bytes(conversation, &payload) + .map_err(|error| StreamError::Decrypt(error.to_string())) +} + pub fn build_seal( rumor: &UnsignedEvent, form: SealForm, @@ -357,12 +370,7 @@ fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result, } fn decode_content(conversation: &ConversationKey, content: &str) -> Result { - let payload = BASE64 - .decode(content.as_bytes()) - .map_err(|error| StreamError::Decrypt(error.to_string()))?; - - let plaintext = decrypt_to_bytes(conversation, &payload) - .map_err(|error| StreamError::Decrypt(error.to_string()))?; + let plaintext = open_bytes(conversation, content)?; String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string())) }